From 475d32fabbb3349057887a19811a796267545038 Mon Sep 17 00:00:00 2001 From: Claude Code Agent Date: Mon, 13 Jul 2026 21:06:38 -0700 Subject: [PATCH 1/5] =?UTF-8?q?feat(chronicle):=20events=20foundation=20?= =?UTF-8?q?=E2=80=94=20versioned=20schema,=20analyst=20events,=20timeline?= =?UTF-8?q?=20lane=20(Phase=203=20task=203.1,=20#105)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit app_events/app_event_versions/app_event_claims per spec Table 15; analyst event CRUD with immutable version history, validated status transitions, optimistic concurrency, and citation source validation; events lane returns sparse individual events (dismissed excluded, 500 cap); amber diamond/span marks with textual origin glyphs and day-floor precision positioning; event inspector card with confirm/dismiss/edit; create- event-from-brush. Implemented via cheap-coder (grok-4.5 @ max). Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01XP7M17NHxw6mjpTFtMqwZu --- .../server/src/chronicle_server/app.py | 2 + .../server/src/chronicle_server/chronicle.py | 99 +- .../server/src/chronicle_server/db.py | 47 + .../server/src/chronicle_server/events.py | 920 ++++++++++++++++++ apps/chronicle/server/tests/test_chronicle.py | 145 +++ apps/chronicle/server/tests/test_events.py | 445 +++++++++ apps/chronicle/web/src/api/types.ts | 143 ++- .../chronicle/TimelineCanvas.events.test.ts | 46 + .../web/src/chronicle/TimelineCanvas.tsx | 179 +++- .../web/src/chronicle/laneModel.test.ts | 50 + apps/chronicle/web/src/chronicle/laneModel.ts | 62 +- .../src/events/CreateEventFromBrush.test.tsx | 74 ++ .../web/src/events/CreateEventFromBrush.tsx | 166 ++++ .../web/src/events/EventCard.test.tsx | 177 ++++ apps/chronicle/web/src/events/EventCard.tsx | 382 ++++++++ apps/chronicle/web/src/events/api.ts | 40 + apps/chronicle/web/src/events/format.ts | 82 ++ .../web/src/inspector/InspectorPanel.test.tsx | 35 + .../web/src/inspector/InspectorPanel.tsx | 10 + .../web/src/workingset/urlState.test.ts | 14 +- apps/chronicle/web/src/workingset/urlState.ts | 13 + .../plans/2026-07-13-life-chronicle-plan.md | 16 +- 22 files changed, 3110 insertions(+), 37 deletions(-) create mode 100644 apps/chronicle/server/src/chronicle_server/events.py create mode 100644 apps/chronicle/server/tests/test_events.py create mode 100644 apps/chronicle/web/src/chronicle/TimelineCanvas.events.test.ts create mode 100644 apps/chronicle/web/src/events/CreateEventFromBrush.test.tsx create mode 100644 apps/chronicle/web/src/events/CreateEventFromBrush.tsx create mode 100644 apps/chronicle/web/src/events/EventCard.test.tsx create mode 100644 apps/chronicle/web/src/events/EventCard.tsx create mode 100644 apps/chronicle/web/src/events/api.ts create mode 100644 apps/chronicle/web/src/events/format.ts diff --git a/apps/chronicle/server/src/chronicle_server/app.py b/apps/chronicle/server/src/chronicle_server/app.py index de35c76..bdf6a47 100644 --- a/apps/chronicle/server/src/chronicle_server/app.py +++ b/apps/chronicle/server/src/chronicle_server/app.py @@ -14,6 +14,7 @@ from chronicle_server.chronicle import router as chronicle_router from chronicle_server.config import ChronicleSettings from chronicle_server.db import create_pool, ensure_user, init_app_tables +from chronicle_server.events import router as events_router from chronicle_server.files import router as files_router from chronicle_server.health import router as health_router from chronicle_server.interpret import router as interpret_router @@ -74,6 +75,7 @@ async def lifespan(app: FastAPI) -> AsyncIterator[None]: app.include_router(files_router, prefix="/api") app.include_router(ask_router, prefix="/api") app.include_router(workspaces_router, prefix="/api") + app.include_router(events_router, prefix="/api") # Stash settings early so tests can inspect before lifespan if needed. app.state.settings = resolved return app diff --git a/apps/chronicle/server/src/chronicle_server/chronicle.py b/apps/chronicle/server/src/chronicle_server/chronicle.py index 6b0f862..5829297 100644 --- a/apps/chronicle/server/src/chronicle_server/chronicle.py +++ b/apps/chronicle/server/src/chronicle_server/chronicle.py @@ -25,12 +25,13 @@ router = APIRouter(tags=["chronicle"]) VALID_UNITS: tuple[str, ...] = ("hour", "day", "week", "month", "quarter", "year") -VALID_LANES = frozenset({"messages", "attachments", "people", "top_people"}) +VALID_LANES = frozenset({"messages", "attachments", "people", "top_people", "events"}) MAX_BUCKETS = 2000 MIN_PIXEL_WIDTH = 320 MAX_PIXEL_WIDTH = 8192 DENSITY_PIXEL_WIDTH = 1600 TOP_PEOPLE_LIMIT = 8 +EVENTS_LANE_CAP = 500 # Approximate unit widths for bucket-count estimation (pixel-width rule). _UNIT_SECONDS: dict[str, float] = { @@ -91,6 +92,27 @@ class TopPeopleLaneData(BaseModel): contacts: list[TopPeopleContactSeries] +class EventLaneMark(BaseModel): + """Sparse event diamond mark for the events lane (not a bucket count).""" + + event_id: str + title: str + time_start: str + time_end: str | None = None + time_precision: str + origin: str + event_type: str + status: str + evidence_strength: str | None = None + + +class EventsLaneData(BaseModel): + """events lane: individual marks (sparse diamonds), capped per viewport.""" + + events: list[EventLaneMark] + truncated: bool = False + + class DensitySeries(BaseModel): unit: str buckets: list[BucketPoint] @@ -120,12 +142,15 @@ def _check_lanes(cls, value: list[str]) -> list[str]: return value +LanePayload = list[BucketPoint] | TopPeopleLaneData | EventsLaneData + + class BucketsResponse(BaseModel): scope_fingerprint: str aggregation: str unit: str viewport: TimeRange - lanes: dict[str, list[BucketPoint] | TopPeopleLaneData] + lanes: dict[str, LanePayload] density: DensitySeries extent: ExtentRange generated_at: str @@ -493,6 +518,67 @@ def _density_buckets( } +def _events_lane( + pool: ConnectionPool, + *, + viewport_from: str, + viewport_to: str, + scope: QueryScope, +) -> EventsLaneData: + """Individual events for the viewport (NOT bucket counts); dismissed excluded. + + Set-based single query. Cap EVENTS_LANE_CAP with truncated flag. + Scope date only when present (person/topic scoping arrives later). + Intersection: time_start < to AND coalesce(time_end, time_start) >= from. + """ + conditions = [ + "time_start < %(viewport_to)s", + "coalesce(time_end, time_start) >= %(viewport_from)s", + "status <> 'dismissed'", + ] + params: dict[str, Any] = { + "viewport_from": viewport_from, + "viewport_to": viewport_to, + "cap": EVENTS_LANE_CAP + 1, + } + if scope.date is not None: + if scope.date.from_ is not None: + conditions.append("time_start >= %(scope_from)s") + params["scope_from"] = scope.date.from_ + if scope.date.to is not None: + conditions.append("time_start < %(scope_to)s") + params["scope_to"] = scope.date.to + + sql = f""" + SELECT id, title, time_start, time_end, time_precision, origin, + event_type, status, evidence_strength + FROM app_events + WHERE {" AND ".join(conditions)} + ORDER BY time_start ASC, id ASC + LIMIT %(cap)s + """ + with pool.connection() as conn: + rows = conn.execute(sql, params).fetchall() + + truncated = len(rows) > EVENTS_LANE_CAP + marks: list[EventLaneMark] = [] + for row in rows[:EVENTS_LANE_CAP]: + marks.append( + EventLaneMark( + event_id=str(row[0]), + title=str(row[1]), + time_start=_iso_utc(row[2]) or "", + time_end=_iso_utc(row[3]), + time_precision=str(row[4]), + origin=str(row[5]), + event_type=str(row[6]), + status=str(row[7]), + evidence_strength=str(row[8]) if row[8] is not None else None, + ) + ) + return EventsLaneData(events=marks, truncated=truncated) + + def get_buckets(pool: ConnectionPool, body: BucketsRequest) -> BucketsResponse: """Compute lane aggregates, density series, and scope extent (read-only).""" pixel_width = clamp_pixel_width(body.pixel_width) @@ -526,7 +612,7 @@ def get_buckets(pool: ConnectionPool, body: BucketsRequest) -> BucketsResponse: viewport_from_s = body.viewport.from_ viewport_to_s = body.viewport.to - lane_data: dict[str, list[BucketPoint] | TopPeopleLaneData] = {} + lane_data: dict[str, LanePayload] = {} for lane in body.lanes: if lane == "top_people": lane_data[lane] = _top_people_buckets( @@ -537,6 +623,13 @@ def get_buckets(pool: ConnectionPool, body: BucketsRequest) -> BucketsResponse: scope_conds=scope_conds, scope_params=scope_params, ) + elif lane == "events": + lane_data[lane] = _events_lane( + pool, + viewport_from=viewport_from_s, + viewport_to=viewport_to_s, + scope=body.scope, + ) else: handler = _LANE_HANDLERS[lane] lane_data[lane] = handler( diff --git a/apps/chronicle/server/src/chronicle_server/db.py b/apps/chronicle/server/src/chronicle_server/db.py index 10b8880..1e7c847 100644 --- a/apps/chronicle/server/src/chronicle_server/db.py +++ b/apps/chronicle/server/src/chronicle_server/db.py @@ -66,6 +66,53 @@ created_at TIMESTAMPTZ NOT NULL DEFAULT now(), updated_at TIMESTAMPTZ NOT NULL DEFAULT now() ); +CREATE TABLE IF NOT EXISTS app_events ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + title TEXT NOT NULL, + time_start TIMESTAMPTZ NOT NULL, + time_end TIMESTAMPTZ, + time_precision TEXT NOT NULL DEFAULT 'day' + CHECK (time_precision IN ( + 'year','quarter','month','week','day','hour')), + origin TEXT NOT NULL + CHECK (origin IN ('source','imported','automatic','analyst')), + event_type TEXT NOT NULL DEFAULT 'communication' + CHECK (event_type IN ( + 'decision','meeting','travel','purchase','deadline', + 'transition','document','communication','user_defined')), + status TEXT NOT NULL DEFAULT 'unreviewed' + CHECK (status IN ( + 'unreviewed','confirmed','edited','dismissed', + 'superseded','unresolved')), + evidence_strength TEXT + CHECK (evidence_strength IN ('low','medium','high')), + scope_fingerprint TEXT, + current_version INT NOT NULL DEFAULT 1, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +); +CREATE TABLE IF NOT EXISTS app_event_versions ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + event_id UUID NOT NULL REFERENCES app_events(id) ON DELETE CASCADE, + version INT NOT NULL, + author TEXT NOT NULL CHECK (author IN ('automatic','analyst')), + title TEXT NOT NULL, + summary TEXT, + derivation JSONB NOT NULL DEFAULT '{}', + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + UNIQUE (event_id, version) +); +CREATE TABLE IF NOT EXISTS app_event_claims ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + event_id UUID NOT NULL REFERENCES app_events(id) ON DELETE CASCADE, + version INT NOT NULL, + position INT NOT NULL, + text TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'direct' + CHECK (status IN ( + 'direct','supported','conflicting','unresolved')), + citations JSONB NOT NULL DEFAULT '[]' +); """ diff --git a/apps/chronicle/server/src/chronicle_server/events.py b/apps/chronicle/server/src/chronicle_server/events.py new file mode 100644 index 0000000..c510884 --- /dev/null +++ b/apps/chronicle/server/src/chronicle_server/events.py @@ -0,0 +1,920 @@ +"""Analyst-authored events: CRUD, versioning, list, and status transitions. + +Phase 3 Task 3.1 — origin ``analyst`` events only. Automatic generation arrives +in 3.2. Version rows are append-only; edits never overwrite history. +""" + +from __future__ import annotations + +from datetime import UTC, datetime +from typing import TYPE_CHECKING, Any, Literal +from uuid import UUID + +import structlog +from fastapi import APIRouter, Depends, HTTPException, Request +from psycopg.types.json import Jsonb +from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator + +from chronicle_server.auth import require_user +from chronicle_server.cursor import decode_cursor, encode_cursor +from chronicle_server.db import audit +from chronicle_server.ids import decode_source_id, msg_key_to_uuid +from chronicle_server.scope import QueryScope + +if TYPE_CHECKING: + from psycopg_pool import ConnectionPool + + from chronicle_server.config import ChronicleSettings + +logger = structlog.get_logger() + +router = APIRouter(tags=["events"]) + +TimePrecision = Literal["year", "quarter", "month", "week", "day", "hour"] +EventType = Literal[ + "decision", + "meeting", + "travel", + "purchase", + "deadline", + "transition", + "document", + "communication", + "user_defined", +] +EventStatus = Literal[ + "unreviewed", + "confirmed", + "edited", + "dismissed", + "superseded", + "unresolved", +] +ClaimStatus = Literal["direct", "supported", "conflicting", "unresolved"] +Origin = Literal["source", "imported", "automatic", "analyst"] + +_VALID_PRECISIONS = frozenset({"year", "quarter", "month", "week", "day", "hour"}) +_VALID_TYPES = frozenset( + { + "decision", + "meeting", + "travel", + "purchase", + "deadline", + "transition", + "document", + "communication", + "user_defined", + } +) +_VALID_STATUSES = frozenset( + {"unreviewed", "confirmed", "edited", "dismissed", "superseded", "unresolved"} +) +_STATUS_ONLY_TARGETS = frozenset({"confirmed", "dismissed", "unreviewed"}) +_LIST_DEFAULT_LIMIT = 100 +_LIST_MAX_LIMIT = 200 + + +# --- request / response models --- + + +class ClaimCreate(BaseModel): + text: str = Field(min_length=1) + citations: list[str] = Field(default_factory=list) # source_ids + status: ClaimStatus = "direct" + + +class EventCreate(BaseModel): + title: str = Field(min_length=1) + time_start: str + time_end: str | None = None + time_precision: TimePrecision = "day" + event_type: EventType = "communication" + summary: str | None = None + claims: list[ClaimCreate] = Field(default_factory=list) + + +class EventPatch(BaseModel): + """Optimistic edit: requires ``current_version``; 409 on mismatch.""" + + current_version: int = Field(ge=1) + title: str | None = Field(default=None, min_length=1) + time_start: str | None = None + time_end: str | None = None + time_precision: TimePrecision | None = None + event_type: EventType | None = None + summary: str | None = None + claims: list[ClaimCreate] | None = None + # Target status for confirm/dismiss/restore transitions. + status: EventStatus | None = None + + @model_validator(mode="after") + def _at_least_one_change(self) -> EventPatch: + content = any( + v is not None + for v in ( + self.title, + self.time_start, + self.time_end, + self.time_precision, + self.event_type, + self.summary, + self.claims, + ) + ) + if not content and self.status is None: + raise ValueError("at least one of title, time, type, summary, claims, status required") + return self + + +class TimeRange(BaseModel): + from_: str = Field(..., alias="from") + to: str + + model_config = ConfigDict(populate_by_name=True) + + +class EventListRequest(BaseModel): + scope: QueryScope = Field(default_factory=QueryScope) + viewport: TimeRange + include_dismissed: bool = False + cursor: str | None = None + limit: int = _LIST_DEFAULT_LIMIT + + @field_validator("limit") + @classmethod + def _clamp_limit(cls, value: int) -> int: + if value < 1: + raise ValueError("limit must be >= 1") + return min(value, _LIST_MAX_LIMIT) + + +# --- helpers --- + + +def _iso(value: Any) -> str | None: + if value is None: + return None + if isinstance(value, datetime): + dt = value if value.tzinfo is not None else value.replace(tzinfo=UTC) + return dt.astimezone(UTC).isoformat().replace("+00:00", "Z") + if hasattr(value, "isoformat"): + return str(value.isoformat()) + return str(value) + + +def _parse_ts(value: str) -> datetime: + text = value.strip() + if text.endswith("Z"): + text = text[:-1] + "+00:00" + if len(text) == 10 and text[4] == "-" and text[7] == "-": + text = text + "T00:00:00+00:00" + dt = datetime.fromisoformat(text) + if dt.tzinfo is None: + return dt.replace(tzinfo=UTC) + return dt.astimezone(UTC) + + +def _source_type_for_kind(kind: str) -> str: + if kind == "msg": + return "message" + if kind == "att": + return "attachment" + if kind == "thr": + return "thread" + return kind + + +def _validate_source_ids(pool: ConnectionPool, source_ids: list[str]) -> list[dict[str, Any]]: + """Decode + existence-check source_ids; return citation dicts. 404 on unknown.""" + citations: list[dict[str, Any]] = [] + for sid in source_ids: + try: + kind, key = decode_source_id(sid) + except ValueError as exc: + raise HTTPException(status_code=404, detail=f"Unknown source_id: {sid}") from exc + + with pool.connection() as conn: + exists = False + if kind == "msg" and isinstance(key, int): + row = conn.execute( + "SELECT 1 FROM emails WHERE id = %(id)s", + {"id": msg_key_to_uuid(key)}, + ).fetchone() + exists = row is not None + elif kind == "att" and isinstance(key, int): + row = conn.execute( + "SELECT 1 FROM attachments WHERE id = %(id)s", + {"id": key}, + ).fetchone() + exists = row is not None + elif kind == "thr" and isinstance(key, str): + row = conn.execute( + "SELECT 1 FROM emails WHERE thread_id = %(tid)s LIMIT 1", + {"tid": key}, + ).fetchone() + exists = row is not None + + if not exists: + raise HTTPException(status_code=404, detail=f"Unknown source_id: {sid}") + + citations.append( + { + "source_id": sid, + "source_type": _source_type_for_kind(kind), + "excerpt": None, + "excerpt_hash": None, + "location": None, + } + ) + return citations + + +def _hydrate_citations( + pool: ConnectionPool, + citations: list[dict[str, Any]], +) -> list[dict[str, Any]]: + """Attach date/sender/subject display metadata via one set-based query per kind.""" + if not citations: + return [] + + msg_ids: list[UUID] = [] + att_ids: list[int] = [] + thr_ids: list[str] = [] + sid_to_kind: dict[str, str] = {} + + for cit in citations: + sid = cit.get("source_id") + if not sid or not isinstance(sid, str): + continue + try: + kind, key = decode_source_id(sid) + except ValueError: + continue + sid_to_kind[sid] = kind + if kind == "msg" and isinstance(key, int): + msg_ids.append(msg_key_to_uuid(key)) + elif kind == "att" and isinstance(key, int): + att_ids.append(key) + elif kind == "thr" and isinstance(key, str): + thr_ids.append(key) + + meta: dict[str, dict[str, Any]] = {} + + with pool.connection() as conn: + if msg_ids: + # import encode here to map back to source_id + from chronicle_server.ids import encode_source_id + + rows = conn.execute( + """ + SELECT id, date, sender_name, sender_address, subject + FROM emails + WHERE id = ANY(%(ids)s) + """, + {"ids": msg_ids}, + ).fetchall() + for row in rows: + sid = encode_source_id("msg", row[0]) + meta[sid] = { + "date": _iso(row[1]), + "sender": row[2] or row[3], + "subject": row[4], + } + if att_ids: + from chronicle_server.ids import encode_source_id + + rows = conn.execute( + """ + SELECT a.id, e.date, e.sender_name, e.sender_address, a.filename + FROM attachments a + LEFT JOIN email_attachments ea ON ea.attachment_id = a.id + LEFT JOIN emails e ON e.id = ea.email_id + WHERE a.id = ANY(%(ids)s) + """, + {"ids": att_ids}, + ).fetchall() + for row in rows: + sid = encode_source_id("att", int(row[0])) + # First row wins if multiple parent emails + if sid not in meta: + meta[sid] = { + "date": _iso(row[1]), + "sender": row[2] or row[3], + "subject": row[4], + } + if thr_ids: + from chronicle_server.ids import encode_source_id + + rows = conn.execute( + """ + SELECT DISTINCT ON (thread_id) + thread_id, date, sender_name, sender_address, subject + FROM emails + WHERE thread_id = ANY(%(ids)s) + ORDER BY thread_id, date ASC NULLS LAST + """, + {"ids": thr_ids}, + ).fetchall() + for row in rows: + sid = encode_source_id("thr", str(row[0])) + meta[sid] = { + "date": _iso(row[1]), + "sender": row[2] or row[3], + "subject": row[4], + } + + out: list[dict[str, Any]] = [] + for cit in citations: + entry = dict(cit) + sid = entry.get("source_id") + if isinstance(sid, str) and sid in meta: + entry.update(meta[sid]) + else: + entry.setdefault("date", None) + entry.setdefault("sender", None) + entry.setdefault("subject", None) + out.append(entry) + return out + + +def _event_row_dict(row: Any) -> dict[str, Any]: + return { + "id": str(row[0]), + "title": row[1], + "time_start": _iso(row[2]), + "time_end": _iso(row[3]), + "time_precision": row[4], + "origin": row[5], + "event_type": row[6], + "status": row[7], + "evidence_strength": row[8], + "scope_fingerprint": row[9], + "current_version": int(row[10]), + "created_at": _iso(row[11]), + "updated_at": _iso(row[12]), + } + + +def _fetch_event(pool: ConnectionPool, event_id: UUID) -> dict[str, Any] | None: + with pool.connection() as conn: + row = conn.execute( + """ + SELECT id, title, time_start, time_end, time_precision, origin, + event_type, status, evidence_strength, scope_fingerprint, + current_version, created_at, updated_at + FROM app_events + WHERE id = %(id)s + """, + {"id": event_id}, + ).fetchone() + if row is None: + return None + return _event_row_dict(row) + + +def _fetch_version( + pool: ConnectionPool, + event_id: UUID, + version: int, +) -> dict[str, Any] | None: + with pool.connection() as conn: + row = conn.execute( + """ + SELECT version, author, title, summary, derivation, created_at + FROM app_event_versions + WHERE event_id = %(eid)s AND version = %(ver)s + """, + {"eid": event_id, "ver": version}, + ).fetchone() + if row is None: + return None + derivation = row[4] if isinstance(row[4], dict) else {} + return { + "version": int(row[0]), + "author": row[1], + "title": row[2], + "summary": row[3], + "derivation": derivation, + "created_at": _iso(row[5]), + } + + +def _fetch_claims( + pool: ConnectionPool, + event_id: UUID, + version: int, +) -> list[dict[str, Any]]: + with pool.connection() as conn: + rows = conn.execute( + """ + SELECT id, position, text, status, citations + FROM app_event_claims + WHERE event_id = %(eid)s AND version = %(ver)s + ORDER BY position ASC + """, + {"eid": event_id, "ver": version}, + ).fetchall() + + claims: list[dict[str, Any]] = [] + all_cits: list[dict[str, Any]] = [] + raw_per_claim: list[list[dict[str, Any]]] = [] + + for row in rows: + raw = row[4] if isinstance(row[4], list) else [] + cits = [dict(c) if isinstance(c, dict) else {} for c in raw] + raw_per_claim.append(cits) + all_cits.extend(cits) + + hydrated_all = _hydrate_citations(pool, all_cits) + # Re-slice hydrated citations back onto claims in order. + # Hydration preserves input order. + idx = 0 + for i, row in enumerate(rows): + n = len(raw_per_claim[i]) + claim_cits = hydrated_all[idx : idx + n] + idx += n + claims.append( + { + "id": str(row[0]), + "position": int(row[1]), + "text": row[2], + "status": row[3], + "citations": claim_cits, + } + ) + return claims + + +def _full_event(pool: ConnectionPool, event_id: UUID) -> dict[str, Any]: + event = _fetch_event(pool, event_id) + if event is None: + raise HTTPException(status_code=404, detail="Event not found") + ver = int(event["current_version"]) + version = _fetch_version(pool, event_id, ver) + claims = _fetch_claims(pool, event_id, ver) + out = dict(event) + out["version"] = version + out["claims"] = claims + # Prefer version title/summary when present (title also on event row). + if version: + out["summary"] = version.get("summary") + out["derivation"] = version.get("derivation") or {} + else: + out["summary"] = None + out["derivation"] = {} + return out + + +def _insert_claims( + conn: Any, + *, + event_id: UUID, + version: int, + claims: list[tuple[str, str, list[dict[str, Any]]]], +) -> None: + for position, (text, status, citations) in enumerate(claims): + conn.execute( + """ + INSERT INTO app_event_claims (event_id, version, position, text, status, citations) + VALUES (%(eid)s, %(ver)s, %(pos)s, %(text)s, %(status)s, %(cits)s) + """, + { + "eid": event_id, + "ver": version, + "pos": position, + "text": text, + "status": status, + "cits": Jsonb(citations), + }, + ) + + +def _build_claim_rows( + pool: ConnectionPool, + claims: list[ClaimCreate], +) -> list[tuple[str, str, list[dict[str, Any]]]]: + out: list[tuple[str, str, list[dict[str, Any]]]] = [] + for claim in claims: + cits = _validate_source_ids(pool, claim.citations) + out.append((claim.text, claim.status, cits)) + return out + + +def _copy_claims_from_version( + pool: ConnectionPool, + event_id: UUID, + from_version: int, +) -> list[tuple[str, str, list[dict[str, Any]]]]: + with pool.connection() as conn: + rows = conn.execute( + """ + SELECT text, status, citations + FROM app_event_claims + WHERE event_id = %(eid)s AND version = %(ver)s + ORDER BY position ASC + """, + {"eid": event_id, "ver": from_version}, + ).fetchall() + out: list[tuple[str, str, list[dict[str, Any]]]] = [] + for row in rows: + raw = row[2] if isinstance(row[2], list) else [] + cits = [dict(c) if isinstance(c, dict) else {} for c in raw] + out.append((str(row[0]), str(row[1]), cits)) + return out + + +def _validate_status_transition(current: str, target: str) -> None: + """Endpoint-validated transitions: confirm, dismiss, restore.""" + if target == "confirmed": + # confirm from unreviewed / edited / unresolved + if current in ("confirmed", "dismissed", "superseded"): + if current == "confirmed": + return # idempotent confirm ok + raise HTTPException( + status_code=422, + detail=f"cannot confirm from status {current!r}", + ) + return + if target == "dismissed": + if current == "superseded": + raise HTTPException( + status_code=422, + detail=f"cannot dismiss from status {current!r}", + ) + return + if target == "unreviewed": + # restore: only from dismissed + if current != "dismissed": + raise HTTPException( + status_code=422, + detail="restore only allowed from dismissed", + ) + return + if target == "edited": + return + raise HTTPException(status_code=422, detail=f"invalid status transition to {target!r}") + + +# --- routes --- + + +@router.post("/events", status_code=201) +def create_event( + body: EventCreate, + request: Request, + user: str = Depends(require_user), +) -> dict[str, Any]: + """Create an analyst-authored event (origin analyst, status confirmed).""" + pool: ConnectionPool = request.app.state.pool + + if body.time_precision not in _VALID_PRECISIONS: + raise HTTPException(status_code=422, detail="invalid time_precision") + if body.event_type not in _VALID_TYPES: + raise HTTPException(status_code=422, detail="invalid event_type") + + time_start = _parse_ts(body.time_start) + time_end = _parse_ts(body.time_end) if body.time_end else None + claim_rows = _build_claim_rows(pool, body.claims) + + with pool.connection() as conn: + row = conn.execute( + """ + INSERT INTO app_events ( + title, time_start, time_end, time_precision, origin, + event_type, status, current_version + ) VALUES ( + %(title)s, %(ts)s, %(te)s, %(prec)s, 'analyst', + %(etype)s, 'confirmed', 1 + ) + RETURNING id, title, time_start, time_end, time_precision, origin, + event_type, status, evidence_strength, scope_fingerprint, + current_version, created_at, updated_at + """, + { + "title": body.title, + "ts": time_start, + "te": time_end, + "prec": body.time_precision, + "etype": body.event_type, + }, + ).fetchone() + assert row is not None + event_id: UUID = row[0] + conn.execute( + """ + INSERT INTO app_event_versions ( + event_id, version, author, title, summary, derivation + ) VALUES ( + %(eid)s, 1, 'analyst', %(title)s, %(summary)s, '{}'::jsonb + ) + """, + { + "eid": event_id, + "title": body.title, + "summary": body.summary, + }, + ) + _insert_claims(conn, event_id=event_id, version=1, claims=claim_rows) + conn.commit() + + audit( + pool, + username=user, + action="event_create", + detail={"event_id": str(event_id), "origin": "analyst"}, + ) + logger.info("event_created", event_id=str(event_id), username=user) + return _full_event(pool, event_id) + + +@router.get("/events/{event_id}") +def get_event( + event_id: UUID, + request: Request, + _user: str = Depends(require_user), +) -> dict[str, Any]: + """Event + current version + ordered claims with hydrated citations.""" + pool: ConnectionPool = request.app.state.pool + return _full_event(pool, event_id) + + +@router.patch("/events/{event_id}") +def patch_event( + event_id: UUID, + body: EventPatch, + request: Request, + user: str = Depends(require_user), +) -> dict[str, Any]: + """Analyst edit or status transition with optimistic concurrency.""" + pool: ConnectionPool = request.app.state.pool + event = _fetch_event(pool, event_id) + if event is None: + raise HTTPException(status_code=404, detail="Event not found") + + if int(event["current_version"]) != body.current_version: + raise HTTPException( + status_code=409, + detail={ + "error": "version_conflict", + "current_version": event["current_version"], + }, + ) + + content_fields = any( + v is not None + for v in ( + body.title, + body.time_start, + body.time_end, + body.time_precision, + body.event_type, + body.summary, + body.claims, + ) + ) + # time_end alone with explicit None is still a content change only if provided — + # model uses None for "not set"; we treat only non-None fields as content. + + if content_fields: + new_title = body.title if body.title is not None else event["title"] + new_type = body.event_type if body.event_type is not None else event["event_type"] + new_prec = ( + body.time_precision if body.time_precision is not None else event["time_precision"] + ) + if new_prec not in _VALID_PRECISIONS: + raise HTTPException(status_code=422, detail="invalid time_precision") + if new_type not in _VALID_TYPES: + raise HTTPException(status_code=422, detail="invalid event_type") + + new_ts = _parse_ts(body.time_start) if body.time_start else _parse_ts(event["time_start"]) + if body.time_end is not None: + new_te: datetime | None = _parse_ts(body.time_end) if body.time_end else None + else: + new_te = _parse_ts(event["time_end"]) if event["time_end"] else None + + cur_ver = int(event["current_version"]) + next_ver = cur_ver + 1 + prev_version = _fetch_version(pool, event_id, cur_ver) + new_summary = ( + body.summary + if body.summary is not None + else (prev_version.get("summary") if prev_version else None) + ) + + if body.claims is not None: + claim_rows = _build_claim_rows(pool, body.claims) + else: + claim_rows = _copy_claims_from_version(pool, event_id, cur_ver) + + with pool.connection() as conn: + updated = conn.execute( + """ + UPDATE app_events + SET title = %(title)s, + time_start = %(ts)s, + time_end = %(te)s, + time_precision = %(prec)s, + event_type = %(etype)s, + status = 'edited', + current_version = %(ver)s, + updated_at = now() + WHERE id = %(id)s AND current_version = %(cur)s + RETURNING id + """, + { + "id": event_id, + "title": new_title, + "ts": new_ts, + "te": new_te, + "prec": new_prec, + "etype": new_type, + "ver": next_ver, + "cur": cur_ver, + }, + ).fetchone() + if updated is None: + raise HTTPException( + status_code=409, + detail={"error": "version_conflict", "current_version": cur_ver}, + ) + conn.execute( + """ + INSERT INTO app_event_versions ( + event_id, version, author, title, summary, derivation + ) VALUES ( + %(eid)s, %(ver)s, 'analyst', %(title)s, %(summary)s, '{}'::jsonb + ) + """, + { + "eid": event_id, + "ver": next_ver, + "title": new_title, + "summary": new_summary, + }, + ) + _insert_claims(conn, event_id=event_id, version=next_ver, claims=claim_rows) + conn.commit() + + audit( + pool, + username=user, + action="event_edit", + detail={"event_id": str(event_id), "version": next_ver}, + ) + return _full_event(pool, event_id) + + # Status-only transition + assert body.status is not None + target = body.status + if target not in _STATUS_ONLY_TARGETS and target not in _VALID_STATUSES: + raise HTTPException(status_code=422, detail=f"invalid status {target!r}") + if target not in _STATUS_ONLY_TARGETS: + raise HTTPException( + status_code=422, + detail=f"status-only transitions limited to {sorted(_STATUS_ONLY_TARGETS)}", + ) + + _validate_status_transition(str(event["status"]), target) + + action_map = { + "confirmed": "event_confirm", + "dismissed": "event_dismiss", + "unreviewed": "event_restore", + } + action = action_map.get(target, "event_status") + + with pool.connection() as conn: + updated = conn.execute( + """ + UPDATE app_events + SET status = %(status)s, + updated_at = now() + WHERE id = %(id)s AND current_version = %(cur)s + RETURNING id + """, + { + "id": event_id, + "status": target, + "cur": body.current_version, + }, + ).fetchone() + if updated is None: + raise HTTPException( + status_code=409, + detail={ + "error": "version_conflict", + "current_version": event["current_version"], + }, + ) + conn.commit() + + audit( + pool, + username=user, + action=action, + detail={"event_id": str(event_id), "status": target}, + ) + return _full_event(pool, event_id) + + +@router.delete("/events/{event_id}", status_code=204) +def delete_event( + event_id: UUID, + request: Request, + user: str = Depends(require_user), +) -> None: + """Hard-delete only analyst-origin events; others must be dismissed (403).""" + pool: ConnectionPool = request.app.state.pool + event = _fetch_event(pool, event_id) + if event is None: + raise HTTPException(status_code=404, detail="Event not found") + if event["origin"] != "analyst": + raise HTTPException( + status_code=403, + detail="Only analyst-origin events can be deleted; dismiss others instead", + ) + with pool.connection() as conn: + conn.execute("DELETE FROM app_events WHERE id = %(id)s", {"id": event_id}) + conn.commit() + audit( + pool, + username=user, + action="event_delete", + detail={"event_id": str(event_id)}, + ) + + +@router.post("/events/list") +def list_events( + body: EventListRequest, + request: Request, + _user: str = Depends(require_user), +) -> dict[str, Any]: + """Events intersecting the viewport; scope date only; keyset (time_start, id).""" + pool: ConnectionPool = request.app.state.pool + settings: ChronicleSettings = request.app.state.settings + + vp_from = _parse_ts(body.viewport.from_) + vp_to = _parse_ts(body.viewport.to) + if vp_to <= vp_from: + raise HTTPException(status_code=422, detail="viewport.to must be after viewport.from") + + conditions = [ + "time_start < %(vp_to)s", + "coalesce(time_end, time_start) >= %(vp_from)s", + ] + params: dict[str, Any] = { + "vp_from": vp_from, + "vp_to": vp_to, + "limit": body.limit + 1, + } + + if not body.include_dismissed: + conditions.append("status <> 'dismissed'") + + # Scope date only (person/topic scoping arrives later). + if body.scope.date is not None: + if body.scope.date.from_ is not None: + conditions.append("time_start >= %(scope_from)s") + params["scope_from"] = body.scope.date.from_ + if body.scope.date.to is not None: + conditions.append("time_start < %(scope_to)s") + params["scope_to"] = body.scope.date.to + + if body.cursor: + try: + cur = decode_cursor(body.cursor, settings.secret_key) + except ValueError as exc: + raise HTTPException(status_code=422, detail="invalid cursor") from exc + try: + cur_ts = _parse_ts(str(cur["time_start"])) + cur_id = UUID(str(cur["id"])) + except (KeyError, ValueError, TypeError) as exc: + raise HTTPException(status_code=422, detail="invalid cursor") from exc + conditions.append("(time_start, id) > (%(cur_ts)s, %(cur_id)s)") + params["cur_ts"] = cur_ts + params["cur_id"] = cur_id + + sql = f""" + SELECT id, title, time_start, time_end, time_precision, origin, + event_type, status, evidence_strength, scope_fingerprint, + current_version, created_at, updated_at + FROM app_events + WHERE {" AND ".join(conditions)} + ORDER BY time_start ASC, id ASC + LIMIT %(limit)s + """ + with pool.connection() as conn: + rows = conn.execute(sql, params).fetchall() + + items = [_event_row_dict(r) for r in rows[: body.limit]] + next_cursor = None + if len(rows) > body.limit: + last = items[-1] + next_cursor = encode_cursor( + {"time_start": last["time_start"], "id": last["id"]}, + settings.secret_key, + ) + + return {"items": items, "next_cursor": next_cursor} diff --git a/apps/chronicle/server/tests/test_chronicle.py b/apps/chronicle/server/tests/test_chronicle.py index dbe598a..cbb9392 100644 --- a/apps/chronicle/server/tests/test_chronicle.py +++ b/apps/chronicle/server/tests/test_chronicle.py @@ -496,3 +496,148 @@ def test_top_people_lane_shape_and_rules(db_client: TestClient, db_pool: Connect assert empty_total == 0 finally: _cleanup_top_people_fixtures(db_pool) + + +def test_buckets_request_accepts_events_lane() -> None: + req = BucketsRequest.model_validate( + { + "viewport": {"from": "2020-01-01", "to": "2021-01-01"}, + "lanes": ["messages", "events"], + } + ) + assert "events" in req.lanes + + +def _cleanup_events_for_lane(pool: ConnectionPool) -> None: + with pool.connection() as conn: + conn.execute("DELETE FROM app_events") + conn.commit() + + +def test_events_lane_cap_and_dismissed_exclusion( + db_client: TestClient, db_pool: ConnectionPool +) -> None: + """Events lane returns sparse marks (not bucket counts), excludes dismissed, caps 500.""" + from chronicle_server.chronicle import EVENTS_LANE_CAP + + _cleanup_events_for_lane(db_pool) + try: + with db_pool.connection() as conn: + # One dismissed inside viewport — must not appear + conn.execute( + """ + INSERT INTO app_events ( + title, time_start, time_end, time_precision, origin, + event_type, status, current_version + ) VALUES ( + 'Dismissed mark', '2020-06-15T00:00:00Z', null, 'day', 'analyst', + 'meeting', 'dismissed', 1 + ) + """ + ) + # Span event overlapping viewport + conn.execute( + """ + INSERT INTO app_events ( + title, time_start, time_end, time_precision, origin, + event_type, status, current_version + ) VALUES ( + 'Span mark', '2020-05-20T00:00:00Z', '2020-06-10T00:00:00Z', 'day', + 'analyst', 'travel', 'confirmed', 1 + ) + """ + ) + # Point event inside + conn.execute( + """ + INSERT INTO app_events ( + title, time_start, time_end, time_precision, origin, + event_type, status, current_version + ) VALUES ( + 'Inside mark', '2020-07-01T00:00:00Z', null, 'day', 'source', + 'document', 'unreviewed', 1 + ) + """ + ) + # Outside viewport + conn.execute( + """ + INSERT INTO app_events ( + title, time_start, time_end, time_precision, origin, + event_type, status, current_version + ) VALUES ( + 'Outside mark', '2019-01-01T00:00:00Z', null, 'day', 'analyst', + 'meeting', 'confirmed', 1 + ) + """ + ) + conn.commit() + + _login(db_client) + r = db_client.post( + "/api/chronicle/buckets", + json={ + "scope": {}, + "viewport": {"from": "2020-01-01T00:00:00Z", "to": "2021-01-01T00:00:00Z"}, + "pixel_width": 920, + "aggregation": "month", + "lanes": ["events"], + }, + ) + assert r.status_code == 200, r.text + lane = r.json()["lanes"]["events"] + assert isinstance(lane, dict) + assert "events" in lane + assert "truncated" in lane + assert lane["truncated"] is False + titles = {m["title"] for m in lane["events"]} + assert "Inside mark" in titles + assert "Span mark" in titles + assert "Dismissed mark" not in titles + assert "Outside mark" not in titles + for m in lane["events"]: + assert "event_id" in m + assert "time_start" in m + assert "time_precision" in m + assert "origin" in m + assert "event_type" in m + assert "status" in m + assert "count" not in m # not bucket counts + + # Cap + truncated flag + _cleanup_events_for_lane(db_pool) + with db_pool.connection() as conn: + for i in range(EVENTS_LANE_CAP + 5): + conn.execute( + """ + INSERT INTO app_events ( + title, time_start, time_end, time_precision, origin, + event_type, status, current_version + ) VALUES ( + %(title)s, %(ts)s, null, 'day', 'analyst', + 'communication', 'confirmed', 1 + ) + """, + { + "title": f"Cap {i}", + "ts": datetime(2020, 1, 1, tzinfo=UTC) + timedelta(hours=i), + }, + ) + conn.commit() + + r2 = db_client.post( + "/api/chronicle/buckets", + json={ + "scope": {}, + "viewport": {"from": "2020-01-01T00:00:00Z", "to": "2021-01-01T00:00:00Z"}, + "pixel_width": 920, + "aggregation": "month", + "lanes": ["events"], + }, + ) + assert r2.status_code == 200, r2.text + lane2 = r2.json()["lanes"]["events"] + assert lane2["truncated"] is True + assert len(lane2["events"]) == EVENTS_LANE_CAP + finally: + _cleanup_events_for_lane(db_pool) diff --git a/apps/chronicle/server/tests/test_events.py b/apps/chronicle/server/tests/test_events.py new file mode 100644 index 0000000..8de53cb --- /dev/null +++ b/apps/chronicle/server/tests/test_events.py @@ -0,0 +1,445 @@ +# tests/test_events.py +from __future__ import annotations + +from datetime import UTC, datetime +from typing import TYPE_CHECKING, Any +from uuid import uuid4 + +from chronicle_server.ids import encode_source_id +from tests.conftest import PASSWORD, USERNAME + +if TYPE_CHECKING: + from fastapi.testclient import TestClient + from psycopg_pool import ConnectionPool + + +def _login(client: TestClient) -> None: + r = client.post("/api/auth/login", json={"username": USERNAME, "password": PASSWORD}) + assert r.status_code == 200 + + +def _seed_email( + pool: ConnectionPool, + *, + subject: str = "Event seed", + sender_name: str = "Alice", + sender_address: str = "alice@example.com", + date: str = "2015-06-01T12:00:00+00:00", +) -> dict[str, Any]: + eid = uuid4() + with pool.connection() as conn: + conn.execute( + """ + INSERT INTO emails ( + id, message_id, thread_id, subject, + sender_name, sender_address, sender_domain, + recipients, date, body_text, body_html, + has_attachment, labels, source_account, created_at + ) VALUES ( + %(id)s, %(mid)s, %(tid)s, %(subject)s, + %(sname)s, %(saddr)s, 'example.com', + '{"to": ["bob@example.com"]}'::jsonb, %(date)s::timestamptz, + 'body text', null, false, %(labels)s, 'test@example.com', now() + ) + """, + { + "id": eid, + "mid": f"", + "tid": f"thread-{eid}", + "subject": subject, + "sname": sender_name, + "saddr": sender_address, + "date": date, + "labels": ["INBOX"], + }, + ) + conn.commit() + return { + "id": eid, + "source_id": encode_source_id("msg", eid), + "subject": subject, + "sender_name": sender_name, + "sender_address": sender_address, + "date": date, + } + + +def _create_event( + client: TestClient, + *, + title: str = "Analyst event", + time_start: str = "2015-06-15T00:00:00Z", + time_end: str | None = None, + time_precision: str = "day", + event_type: str = "meeting", + summary: str | None = "A summary", + claims: list[dict[str, Any]] | None = None, +) -> dict[str, Any]: + body: dict[str, Any] = { + "title": title, + "time_start": time_start, + "time_precision": time_precision, + "event_type": event_type, + } + if time_end is not None: + body["time_end"] = time_end + if summary is not None: + body["summary"] = summary + if claims is not None: + body["claims"] = claims + r = client.post("/api/events", json=body) + assert r.status_code == 201, r.text + return r.json() + + +def _insert_event_row( + pool: ConnectionPool, + *, + title: str = "Seeded", + time_start: datetime, + time_end: datetime | None = None, + origin: str = "automatic", + status: str = "unreviewed", + event_type: str = "communication", + time_precision: str = "day", +) -> str: + with pool.connection() as conn: + row = conn.execute( + """ + INSERT INTO app_events ( + title, time_start, time_end, time_precision, origin, + event_type, status, current_version + ) VALUES ( + %(title)s, %(ts)s, %(te)s, %(prec)s, %(origin)s, + %(etype)s, %(status)s, 1 + ) + RETURNING id + """, + { + "title": title, + "ts": time_start, + "te": time_end, + "prec": time_precision, + "origin": origin, + "etype": event_type, + "status": status, + }, + ).fetchone() + assert row is not None + eid = row[0] + conn.execute( + """ + INSERT INTO app_event_versions ( + event_id, version, author, title, summary, derivation + ) VALUES ( + %(eid)s, 1, 'automatic', %(title)s, null, '{}'::jsonb + ) + """, + {"eid": eid, "title": title}, + ) + conn.commit() + return str(eid) + + +def _cleanup_events(pool: ConnectionPool) -> None: + with pool.connection() as conn: + conn.execute("DELETE FROM app_events") + conn.execute("DELETE FROM emails WHERE message_id LIKE ''") + conn.execute("DELETE FROM app_audit WHERE action LIKE 'event_%%'") + conn.commit() + + +# --- auth --- + + +def test_events_require_auth(client: TestClient) -> None: + create = client.post( + "/api/events", + json={"title": "x", "time_start": "2015-01-01"}, + ) + assert create.status_code == 401 + fake = str(uuid4()) + assert client.get(f"/api/events/{fake}").status_code == 401 + assert ( + client.patch( + f"/api/events/{fake}", + json={"current_version": 1, "title": "y"}, + ).status_code + == 401 + ) + assert client.delete(f"/api/events/{fake}").status_code == 401 + assert ( + client.post( + "/api/events/list", + json={"viewport": {"from": "2015-01-01", "to": "2016-01-01"}}, + ).status_code + == 401 + ) + + +# --- CRUD + versioning --- + + +def test_create_get_event_versioned(db_client: TestClient, db_pool: ConnectionPool) -> None: + _cleanup_events(db_pool) + email = _seed_email(db_pool) + _login(db_client) + + created = _create_event( + db_client, + title="Roof decision", + claims=[{"text": "Chose metal roof", "citations": [email["source_id"]]}], + ) + assert created["origin"] == "analyst" + assert created["status"] == "confirmed" + assert created["current_version"] == 1 + assert created["version"]["version"] == 1 + assert created["version"]["author"] == "analyst" + assert created["version"]["title"] == "Roof decision" + assert created["summary"] == "A summary" + assert len(created["claims"]) == 1 + claim = created["claims"][0] + assert claim["text"] == "Chose metal roof" + assert claim["status"] == "direct" + assert len(claim["citations"]) == 1 + cit = claim["citations"][0] + assert cit["source_id"] == email["source_id"] + assert cit["subject"] == "Event seed" + assert cit["sender"] == "Alice" + + # Version row exists and is immutable baseline + with db_pool.connection() as conn: + vrows = conn.execute( + "SELECT version, author, title FROM app_event_versions WHERE event_id = %(id)s", + {"id": created["id"]}, + ).fetchall() + assert len(vrows) == 1 + assert vrows[0][0] == 1 + assert vrows[0][1] == "analyst" + + got = db_client.get(f"/api/events/{created['id']}") + assert got.status_code == 200 + assert got.json()["id"] == created["id"] + + +def test_edit_creates_version_2_immutable_v1( + db_client: TestClient, db_pool: ConnectionPool +) -> None: + _cleanup_events(db_pool) + _login(db_client) + created = _create_event(db_client, title="V1 title", summary="v1 summary") + + r = db_client.patch( + f"/api/events/{created['id']}", + json={ + "current_version": 1, + "title": "V2 title", + "summary": "v2 summary", + }, + ) + assert r.status_code == 200, r.text + body = r.json() + assert body["current_version"] == 2 + assert body["status"] == "edited" + assert body["title"] == "V2 title" + assert body["summary"] == "v2 summary" + assert body["version"]["version"] == 2 + assert body["version"]["author"] == "analyst" + + with db_pool.connection() as conn: + rows = conn.execute( + """ + SELECT version, title, summary + FROM app_event_versions + WHERE event_id = %(id)s + ORDER BY version + """, + {"id": created["id"]}, + ).fetchall() + assert len(rows) == 2 + assert rows[0][0] == 1 and rows[0][1] == "V1 title" and rows[0][2] == "v1 summary" + assert rows[1][0] == 2 and rows[1][1] == "V2 title" and rows[1][2] == "v2 summary" + + +def test_optimistic_concurrency_409(db_client: TestClient, db_pool: ConnectionPool) -> None: + _cleanup_events(db_pool) + _login(db_client) + created = _create_event(db_client) + + r = db_client.patch( + f"/api/events/{created['id']}", + json={"current_version": 99, "title": "stale"}, + ) + assert r.status_code == 409 + assert r.json()["detail"]["error"] == "version_conflict" + + +def test_status_transitions(db_client: TestClient, db_pool: ConnectionPool) -> None: + _cleanup_events(db_pool) + _login(db_client) + created = _create_event(db_client) + eid = created["id"] + ver = created["current_version"] + + # dismiss + r = db_client.patch( + f"/api/events/{eid}", + json={"current_version": ver, "status": "dismissed"}, + ) + assert r.status_code == 200, r.text + assert r.json()["status"] == "dismissed" + assert r.json()["current_version"] == ver # no version bump for status-only + + # restore + r = db_client.patch( + f"/api/events/{eid}", + json={"current_version": ver, "status": "unreviewed"}, + ) + assert r.status_code == 200 + assert r.json()["status"] == "unreviewed" + + # confirm + r = db_client.patch( + f"/api/events/{eid}", + json={"current_version": ver, "status": "confirmed"}, + ) + assert r.status_code == 200 + assert r.json()["status"] == "confirmed" + + # invalid: restore from confirmed + r = db_client.patch( + f"/api/events/{eid}", + json={"current_version": ver, "status": "unreviewed"}, + ) + assert r.status_code == 422 + + +def test_citation_validation_404(db_client: TestClient, db_pool: ConnectionPool) -> None: + _cleanup_events(db_pool) + _login(db_client) + r = db_client.post( + "/api/events", + json={ + "title": "Bad cit", + "time_start": "2015-01-01T00:00:00Z", + "claims": [{"text": "x", "citations": ["msg_999999999999999999999"]}], + }, + ) + # malformed or missing → 404 + assert r.status_code == 404 + + r = db_client.post( + "/api/events", + json={ + "title": "Unknown cit", + "time_start": "2015-01-01T00:00:00Z", + "claims": [ + { + "text": "x", + "citations": [encode_source_id("msg", uuid4())], + } + ], + }, + ) + assert r.status_code == 404 + + +def test_delete_guard_403_non_analyst(db_client: TestClient, db_pool: ConnectionPool) -> None: + _cleanup_events(db_pool) + _login(db_client) + auto_id = _insert_event_row( + db_pool, + title="Auto", + time_start=datetime(2015, 6, 1, tzinfo=UTC), + origin="automatic", + ) + r = db_client.delete(f"/api/events/{auto_id}") + assert r.status_code == 403 + + created = _create_event(db_client, title="Deletable") + r = db_client.delete(f"/api/events/{created['id']}") + assert r.status_code == 204 + assert db_client.get(f"/api/events/{created['id']}").status_code == 404 + + +def test_list_viewport_intersection_span(db_client: TestClient, db_pool: ConnectionPool) -> None: + _cleanup_events(db_pool) + _login(db_client) + + # Point event inside + _create_event(db_client, title="Inside", time_start="2015-06-15T00:00:00Z") + # Span event that overlaps viewport [2015-06-01, 2015-07-01) + _create_event( + db_client, + title="Span", + time_start="2015-05-20T00:00:00Z", + time_end="2015-06-10T00:00:00Z", + ) + # Outside (ends before viewport) + _create_event( + db_client, + title="Before", + time_start="2015-01-01T00:00:00Z", + time_end="2015-02-01T00:00:00Z", + ) + # Dismissed excluded by default + dismissed = _create_event(db_client, title="Dismissed", time_start="2015-06-20T00:00:00Z") + db_client.patch( + f"/api/events/{dismissed['id']}", + json={"current_version": 1, "status": "dismissed"}, + ) + + r = db_client.post( + "/api/events/list", + json={ + "scope": {}, + "viewport": {"from": "2015-06-01T00:00:00Z", "to": "2015-07-01T00:00:00Z"}, + }, + ) + assert r.status_code == 200, r.text + titles = {item["title"] for item in r.json()["items"]} + assert "Inside" in titles + assert "Span" in titles + assert "Before" not in titles + assert "Dismissed" not in titles + + r2 = db_client.post( + "/api/events/list", + json={ + "scope": {}, + "viewport": {"from": "2015-06-01T00:00:00Z", "to": "2015-07-01T00:00:00Z"}, + "include_dismissed": True, + }, + ) + titles2 = {item["title"] for item in r2.json()["items"]} + assert "Dismissed" in titles2 + + +def test_audit_rows(db_client: TestClient, db_pool: ConnectionPool) -> None: + _cleanup_events(db_pool) + _login(db_client) + created = _create_event(db_client, title="Audited") + db_client.patch( + f"/api/events/{created['id']}", + json={"current_version": 1, "title": "Edited"}, + ) + db_client.patch( + f"/api/events/{created['id']}", + json={"current_version": 2, "status": "dismissed"}, + ) + + with db_pool.connection() as conn: + actions = { + row[0] + for row in conn.execute( + """ + SELECT action FROM app_audit + WHERE action LIKE 'event_%%' + AND detail->>'event_id' = %(eid)s + """, + {"eid": created["id"]}, + ).fetchall() + } + assert "event_create" in actions + assert "event_edit" in actions + assert "event_dismiss" in actions diff --git a/apps/chronicle/web/src/api/types.ts b/apps/chronicle/web/src/api/types.ts index b5b10fa..18d89e1 100644 --- a/apps/chronicle/web/src/api/types.ts +++ b/apps/chronicle/web/src/api/types.ts @@ -149,8 +149,27 @@ export interface TopPeopleLane { contacts: TopPeopleContact[] } -/** A bars-style lane is BucketPoint[]; top_people is nested contact series. */ -export type LaneData = BucketPoint[] | TopPeopleLane +/** Sparse event diamond mark on the events lane. */ +export interface EventLaneMark { + event_id: string + title: string + time_start: string + time_end: string | null + time_precision: string + origin: string + event_type: string + status: string + evidence_strength: string | null +} + +/** events lane payload (sparse diamonds, not bucket counts). */ +export interface EventsLane { + events: EventLaneMark[] + truncated: boolean +} + +/** A bars-style lane is BucketPoint[]; top_people / events are nested objects. */ +export type LaneData = BucketPoint[] | TopPeopleLane | EventsLane export function isTopPeopleLane(data: LaneData | undefined): data is TopPeopleLane { return ( @@ -161,10 +180,130 @@ export function isTopPeopleLane(data: LaneData | undefined): data is TopPeopleLa ) } +export function isEventsLane(data: LaneData | undefined): data is EventsLane { + return ( + data != null && + typeof data === 'object' && + !Array.isArray(data) && + Array.isArray((data as EventsLane).events) + ) +} + export function isBucketSeries(data: LaneData | undefined): data is BucketPoint[] { return Array.isArray(data) } +/** Event origin (Table 15). */ +export type EventOrigin = 'source' | 'imported' | 'automatic' | 'analyst' + +export type EventTimePrecision = + | 'year' + | 'quarter' + | 'month' + | 'week' + | 'day' + | 'hour' + +export type EventType = + | 'decision' + | 'meeting' + | 'travel' + | 'purchase' + | 'deadline' + | 'transition' + | 'document' + | 'communication' + | 'user_defined' + +export type EventStatus = + | 'unreviewed' + | 'confirmed' + | 'edited' + | 'dismissed' + | 'superseded' + | 'unresolved' + +export type ClaimStatus = 'direct' | 'supported' | 'conflicting' | 'unresolved' + +export interface EventCitation { + source_id: string + source_type: string + excerpt?: string | null + excerpt_hash?: string | null + location?: Record | null + /** Hydrated display metadata */ + date?: string | null + sender?: string | null + subject?: string | null +} + +export interface EventClaim { + id: string + position: number + text: string + status: ClaimStatus | string + citations: EventCitation[] +} + +export interface EventVersion { + version: number + author: string + title: string + summary: string | null + derivation: Record + created_at?: string | null +} + +export interface ChronicleEvent { + id: string + title: string + time_start: string + time_end: string | null + time_precision: EventTimePrecision | string + origin: EventOrigin | string + event_type: EventType | string + status: EventStatus | string + evidence_strength: string | null + scope_fingerprint?: string | null + current_version: number + created_at?: string | null + updated_at?: string | null + summary?: string | null + derivation?: Record + version?: EventVersion | null + claims?: EventClaim[] +} + +export interface EventCreateRequest { + title: string + time_start: string + time_end?: string | null + time_precision: EventTimePrecision | string + event_type: EventType | string + summary?: string | null + claims?: Array<{ + text: string + citations?: string[] + status?: ClaimStatus | string + }> +} + +export interface EventPatchRequest { + current_version: number + title?: string + time_start?: string + time_end?: string | null + time_precision?: EventTimePrecision | string + event_type?: EventType | string + summary?: string | null + claims?: Array<{ + text: string + citations?: string[] + status?: ClaimStatus | string + }> + status?: EventStatus | string +} + export interface DensitySeries { unit: string buckets: BucketPoint[] diff --git a/apps/chronicle/web/src/chronicle/TimelineCanvas.events.test.ts b/apps/chronicle/web/src/chronicle/TimelineCanvas.events.test.ts new file mode 100644 index 0000000..a67ac77 --- /dev/null +++ b/apps/chronicle/web/src/chronicle/TimelineCanvas.events.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, it } from 'vitest' + +import { eventAtX } from './TimelineCanvas' +import type { EventLaneMark } from '../api/types' + +const vp = { + fromMs: Date.UTC(2015, 0, 1), + toMs: Date.UTC(2016, 0, 1), +} +const plotW = 365 + +const marks: EventLaneMark[] = [ + { + event_id: 'e-day', + title: 'Day event', + time_start: '2015-06-15T14:30:00.000Z', + time_end: null, + time_precision: 'day', + origin: 'analyst', + event_type: 'meeting', + status: 'confirmed', + evidence_strength: null, + }, + { + event_id: 'e-dismissed', + title: 'Hidden', + time_start: '2015-06-15T00:00:00.000Z', + time_end: null, + time_precision: 'day', + origin: 'automatic', + event_type: 'communication', + status: 'dismissed', + evidence_strength: null, + }, +] + +describe('eventAtX', () => { + it('hits diamond at day-floored position, ignores dismissed', () => { + // Day-floor of 2015-06-15T14:30 is midnight UTC + const t = Date.UTC(2015, 5, 15) + const x = ((t - vp.fromMs) / (vp.toMs - vp.fromMs)) * plotW + expect(eventAtX(x, vp, plotW, marks)).toBe('e-day') + // Far from mark + expect(eventAtX(0, vp, plotW, marks)).toBeNull() + }) +}) diff --git a/apps/chronicle/web/src/chronicle/TimelineCanvas.tsx b/apps/chronicle/web/src/chronicle/TimelineCanvas.tsx index 236ce3e..7ffef0c 100644 --- a/apps/chronicle/web/src/chronicle/TimelineCanvas.tsx +++ b/apps/chronicle/web/src/chronicle/TimelineCanvas.tsx @@ -9,21 +9,28 @@ import { type PointerEvent, } from 'react' -import type { BucketPoint, LaneData } from '../api/types' +import type { BucketPoint, EventLaneMark, LaneData } from '../api/types' import { isTopPeopleLane } from '../api/types' +import { CreateEventFromBrush } from '../events/CreateEventFromBrush' +import { useWorkingSetStore } from '../workingset/store' import { AXIS_H, BAR_LANE_COLORS, + EVENT_AMBER, LANE_GAP, LANE_H, LANE_LABEL_W, + MARKS_LANE_H, MULTIROW_HEADER_H, MULTIROW_ROW_H, PEOPLE_CYAN, barsPoints, canvasHeightForLanes, + eventPositionMs, + eventsMarks, laneAtY as laneAtYFromLayout, layoutLanes, + originGlyph, type LaneSpec, } from './laneModel' import { @@ -62,11 +69,15 @@ export interface TimelineCanvasProps { brush: Viewport | null /** Currently selected bucket (for highlight outline). */ selectedBucket?: { bucketIso: string; lane: string } | null + /** Currently selected event id (events lane diamond highlight). */ + selectedEventId?: string | null onViewportChange: (vp: Viewport) => void onBrushChange: (brush: Viewport | null) => void onWidthChange: (width: number) => void /** Fired when a bar/mark is clicked (lane + bucket ISO). */ onSelectBucket?: (bucketIso: string, laneName: string) => void + /** Fired when an event diamond is clicked. */ + onSelectEvent?: (eventId: string) => void /** * Double-click a mark/bucket enters focus on that bucket's span. * Alt+double-click keeps zoom ×0.25 (documented in toolbar hint). @@ -74,6 +85,38 @@ export interface TimelineCanvasProps { onFocusBucket?: (period: Viewport) => void } +const DIAMOND_HALF = 6 +const EVENT_HIT_R = 10 + +/** + * Hit-test event diamonds. Position uses eventPositionMs (day-floor for + * date-only precision). Returns event_id or null. + * Exported for unit tests. + */ +export function eventAtX( + plotX: number, + viewport: Viewport, + plotW: number, + marks: EventLaneMark[], +): string | null { + if (plotW <= 0 || marks.length === 0) return null + let hit: string | null = null + let bestDist = Infinity + for (const m of marks) { + // Dismissed never rendered / hit-tested (server already excludes; belt). + if (m.status === 'dismissed') continue + const t = eventPositionMs(m.time_start, m.time_precision) + if (t == null) continue + const x = xForTime(t, viewport, plotW) + const dist = Math.abs(plotX - x) + if (dist <= EVENT_HIT_R && dist < bestDist) { + bestDist = dist + hit = m.event_id + } + } + return hit +} + /** * Pure double-click policy for the canvas surface. * - altKey → zoom ×0.25 (retain prior zoom-on-double-click) @@ -187,6 +230,10 @@ function buildAriaLabel( const n = sumCounts(barsPoints(laneData, spec.key)).toLocaleString() return `${n} ${spec.label.toLowerCase()}` } + if (spec.kind === 'marks') { + const n = eventsMarks(laneData).length + return `${n} events` + } const tp = laneData[spec.key] const n = isTopPeopleLane(tp) ? tp.contacts.length : 0 return `${n} contacts` @@ -207,12 +254,20 @@ export function TimelineCanvas({ isFetching, brush, selectedBucket = null, + selectedEventId = null, onViewportChange, onBrushChange, onWidthChange, onSelectBucket, + onSelectEvent, onFocusBucket, }: TimelineCanvasProps) { + const setSelection = useWorkingSetStore((s) => s.setSelection) + const storeSelection = useWorkingSetStore((s) => s.selection) + // Prefer explicit prop; fall back to store so selection works without page wiring. + const resolvedSelectedEventId = + selectedEventId ?? + (storeSelection?.kind === 'event' ? storeSelection.eventId : null) const wrapRef = useRef(null) const wheelHandlerRef = useRef<(e: globalThis.WheelEvent) => void>(() => {}) @@ -341,6 +396,65 @@ export function TimelineCanvas({ ctx.strokeRect(x + 0.5, barY + 0.5, Math.max(0, bw - 1), Math.max(0, barH - 1)) } } + } else if (spec.kind === 'marks') { + // Events: amber diamonds at time_start (day-floored for date-only precision). + // Span events: thin amber line from start→end with diamond at start. + // Origin glyph is text (A/⚙/S/I) — never color-only. + const marks = eventsMarks(laneData) + ctx.fillStyle = COLORS.muted + ctx.font = '10px Inter, system-ui, sans-serif' + ctx.textBaseline = 'top' + ctx.fillText(spec.label, 4, top + 4) + + const cy = top + MARKS_LANE_H / 2 + 4 + for (const m of marks) { + if (m.status === 'dismissed') continue + const tStart = eventPositionMs(m.time_start, m.time_precision) + if (tStart == null) continue + const x0 = LANE_LABEL_W + xForTime(tStart, viewport, plotW) + + if (m.time_end) { + const tEnd = eventPositionMs(m.time_end, m.time_precision) + if (tEnd != null && tEnd > tStart) { + const x1 = LANE_LABEL_W + xForTime(tEnd, viewport, plotW) + ctx.strokeStyle = EVENT_AMBER + ctx.globalAlpha = 0.7 + ctx.lineWidth = 2 + ctx.beginPath() + ctx.moveTo(x0, cy) + ctx.lineTo(x1, cy) + ctx.stroke() + ctx.globalAlpha = 1 + ctx.lineWidth = 1 + } + } + + // Diamond + ctx.fillStyle = EVENT_AMBER + ctx.beginPath() + ctx.moveTo(x0, cy - DIAMOND_HALF) + ctx.lineTo(x0 + DIAMOND_HALF, cy) + ctx.lineTo(x0, cy + DIAMOND_HALF) + ctx.lineTo(x0 - DIAMOND_HALF, cy) + ctx.closePath() + ctx.fill() + + if (resolvedSelectedEventId && resolvedSelectedEventId === m.event_id) { + ctx.strokeStyle = COLORS.action + ctx.lineWidth = 1.5 + ctx.stroke() + ctx.lineWidth = 1 + } + + // Origin glyph (1-char, text not color-only) + const glyph = originGlyph(m.origin) + ctx.fillStyle = COLORS.primary + ctx.font = '9px Inter, system-ui, sans-serif' + ctx.textAlign = 'center' + ctx.textBaseline = 'middle' + ctx.fillText(glyph, x0, cy) + ctx.textAlign = 'start' + } } else { // multirow: top_people activity spans const data = laneData[spec.key] @@ -420,6 +534,7 @@ export function TimelineCanvas({ let total = 0 for (const spec of lanes) { if (spec.kind === 'bars') total += sumCounts(barsPoints(laneData, spec.key)) + else if (spec.kind === 'marks') total += eventsMarks(laneData).length else { const tp = laneData[spec.key] if (isTopPeopleLane(tp)) { @@ -479,6 +594,7 @@ export function TimelineCanvas({ lanes, layout, selectedBucket, + resolvedSelectedEventId, unit, viewport, ]) @@ -584,10 +700,10 @@ export function TimelineCanvas({ return } - // Bar / multirow mark click hit-test + // Bar / multirow / event diamond click hit-test const pending = clickRef.current clickRef.current = null - if (!pending || !onSelectBucket) return + if (!pending) return const rect = wrapRef.current?.getBoundingClientRect() if (!rect) return const localX = e.clientX - rect.left @@ -601,6 +717,18 @@ export function TimelineCanvas({ if (plotX < 0) return const unitTyped = parseUnit(unit) + // Events lane: diamond hit → e: selection + if (hitKey === 'events') { + const marks = eventsMarks(laneData) + const eventId = eventAtX(plotX, viewport, pw, marks) + if (eventId) { + if (onSelectEvent) onSelectEvent(eventId) + else setSelection({ kind: 'event', eventId }) + } + return + } + + if (!onSelectBucket) return let points: BucketPoint[] = [] if (hitKey.startsWith('top_people:')) { const contactId = hitKey.slice('top_people:'.length) @@ -673,26 +801,31 @@ export function TimelineCanvas({ } return ( -
- +
+ {brush && brush.toMs > brush.fromMs ? ( + onBrushChange(null)} /> + ) : null} +
+ +
) } diff --git a/apps/chronicle/web/src/chronicle/laneModel.test.ts b/apps/chronicle/web/src/chronicle/laneModel.test.ts index a13908b..dcaf17c 100644 --- a/apps/chronicle/web/src/chronicle/laneModel.test.ts +++ b/apps/chronicle/web/src/chronicle/laneModel.test.ts @@ -5,10 +5,13 @@ import { AXIS_H, LANE_GAP, LANE_H, + MARKS_LANE_H, MULTIROW_HEADER_H, MULTIROW_ROW_H, + eventPositionMs, layoutLanes, laneAtY, + originGlyph, specsForKeys, } from './laneModel' @@ -34,6 +37,9 @@ describe('laneModel', () => { expect(hMulti).toBe( AXIS_H + LANE_GAP + MULTIROW_HEADER_H + 3 * MULTIROW_ROW_H, ) + + const marks = specsForKeys(['events']) + expect(canvasHeightForLanes(marks)).toBe(AXIS_H + LANE_GAP + MARKS_LANE_H) }) it('layout hit-test maps multirow rows to top_people:contact_id', () => { @@ -53,4 +59,48 @@ describe('laneModel', () => { expect(laneAtY(multiTop, layout)).toBe('top_people:c1') expect(laneAtY(multiTop + MULTIROW_ROW_H, layout)).toBe('top_people:c2') }) + + it('events lane is marks kind and hit-tests as events', () => { + const specs = specsForKeys(['events']) + expect(specs[0]?.kind).toBe('marks') + const layout = layoutLanes(specs, { + events: { + events: [ + { + event_id: 'e1', + title: 'T', + time_start: '2015-06-01T00:00:00Z', + time_end: null, + time_precision: 'day', + origin: 'analyst', + event_type: 'meeting', + status: 'confirmed', + evidence_strength: null, + }, + ], + truncated: false, + }, + }) + expect(laneAtY(AXIS_H + LANE_GAP + 10, layout)).toBe('events') + }) }) + +describe('eventPositionMs day-floor', () => { + it('floors date-only precision to UTC start of day', () => { + const iso = '2015-06-15T14:30:00.000Z' + const day = eventPositionMs(iso, 'day') + expect(day).toBe(Date.UTC(2015, 5, 15)) + expect(eventPositionMs(iso, 'month')).toBe(Date.UTC(2015, 5, 15)) + expect(eventPositionMs(iso, 'year')).toBe(Date.UTC(2015, 5, 15)) + // Hour keeps full timestamp — no fabricated floor + expect(eventPositionMs(iso, 'hour')).toBe(Date.parse(iso)) + }) + + it('origin glyphs are text (not color-only)', () => { + expect(originGlyph('analyst')).toBe('A') + expect(originGlyph('automatic')).toBe('⚙') + expect(originGlyph('source')).toBe('S') + expect(originGlyph('imported')).toBe('I') + }) +}) + diff --git a/apps/chronicle/web/src/chronicle/laneModel.ts b/apps/chronicle/web/src/chronicle/laneModel.ts index 4cecb0a..26a799b 100644 --- a/apps/chronicle/web/src/chronicle/laneModel.ts +++ b/apps/chronicle/web/src/chronicle/laneModel.ts @@ -1,8 +1,8 @@ -import type { BucketPoint, LaneData, TopPeopleLane } from '../api/types' -import { isBucketSeries, isTopPeopleLane } from '../api/types' +import type { BucketPoint, EventLaneMark, LaneData, TopPeopleLane } from '../api/types' +import { isBucketSeries, isEventsLane, isTopPeopleLane } from '../api/types' import type { LaneKey } from '../workingset/urlState' -export type LaneKind = 'bars' | 'multirow' +export type LaneKind = 'bars' | 'multirow' | 'marks' export interface LaneSpec { key: LaneKey @@ -16,6 +16,7 @@ export const LANE_CATALOG: readonly LaneSpec[] = [ { key: 'attachments', label: 'Attachments', kind: 'bars' }, { key: 'people', label: 'People (distinct)', kind: 'bars' }, { key: 'top_people', label: 'Top people', kind: 'multirow' }, + { key: 'events', label: 'Events', kind: 'marks' }, ] as const const CATALOG_BY_KEY = new Map(LANE_CATALOG.map((s) => [s.key, s])) @@ -26,6 +27,9 @@ export const BAR_LANE_COLORS: Record = { people: '#56d4dd', // people cyan } +/** Spec event amber (#E0A84A) for diamond marks. */ +export const EVENT_AMBER = '#E0A84A' + export const PEOPLE_CYAN = '#56d4dd' export const AXIS_H = 28 @@ -34,6 +38,8 @@ export const LANE_LABEL_W = 72 export const LANE_GAP = 4 export const MULTIROW_HEADER_H = 16 export const MULTIROW_ROW_H = 18 +/** Marks lane height (events diamonds). */ +export const MARKS_LANE_H = 56 /** Build ordered LaneSpec[] from store lane keys (unknown keys dropped). */ export function specsForKeys(keys: readonly string[]): LaneSpec[] { @@ -54,6 +60,7 @@ export function laneContentHeight( laneData: Record | undefined, ): number { if (spec.kind === 'bars') return LANE_H + if (spec.kind === 'marks') return MARKS_LANE_H const data = laneData?.[spec.key] const n = isTopPeopleLane(data) ? data.contacts.length : 0 return multirowHeight(n) @@ -103,13 +110,13 @@ export function layoutLanes( y += LANE_GAP const height = laneContentHeight(spec, laneData) const rows: LaneLayoutRow[] = [] - if (spec.kind === 'bars') { + if (spec.kind === 'bars' || spec.kind === 'marks') { rows.push({ hitKey: spec.key, top: y, height, laneKey: spec.key, - kind: 'bars', + kind: spec.kind, }) } else { const data = laneData?.[spec.key] @@ -161,3 +168,48 @@ export function topPeopleData( const data = laneData?.top_people return isTopPeopleLane(data) ? data : undefined } + +export function eventsMarks( + laneData: Record | undefined, +): EventLaneMark[] { + const data = laneData?.events + return isEventsLane(data) ? data.events : [] +} + +/** + * Origin glyph (text, not color-only) for event marks. + * A=analyst, ⚙=automatic, S=source, I=imported. + */ +export function originGlyph(origin: string): string { + switch (origin) { + case 'analyst': + return 'A' + case 'automatic': + return '⚙' + case 'source': + return 'S' + case 'imported': + return 'I' + default: + return '?' + } +} + +/** + * Epoch ms used for x-position of an event mark. + * + * Date-only precisions (year, quarter, month, week, day) floor to UTC start of + * day — never a fabricated hour within the day. Hour precision uses the + * full timestamp. + */ +export function eventPositionMs( + timeStartIso: string, + timePrecision: string, +): number | null { + const ms = Date.parse(timeStartIso) + if (!Number.isFinite(ms)) return null + if (timePrecision === 'hour') return ms + // Floor to UTC midnight of the calendar day. + const d = new Date(ms) + return Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate()) +} diff --git a/apps/chronicle/web/src/events/CreateEventFromBrush.test.tsx b/apps/chronicle/web/src/events/CreateEventFromBrush.test.tsx new file mode 100644 index 0000000..c83ce51 --- /dev/null +++ b/apps/chronicle/web/src/events/CreateEventFromBrush.test.tsx @@ -0,0 +1,74 @@ +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { fireEvent, render, screen, waitFor } from '@testing-library/react' +import { afterEach, describe, expect, it, vi } from 'vitest' + +import { CreateEventFromBrush } from './CreateEventFromBrush' + +const brush = { + fromMs: Date.UTC(2015, 5, 1), + toMs: Date.UTC(2015, 5, 15), +} + +function renderForm() { + const client = new QueryClient({ + defaultOptions: { queries: { retry: false }, mutations: { retry: false } }, + }) + return render( + + + , + ) +} + +describe('CreateEventFromBrush', () => { + afterEach(() => { + vi.unstubAllGlobals() + }) + + it('posts correct payload from brush range', async () => { + const fetchMock = vi.fn().mockResolvedValue({ + ok: true, + status: 201, + json: async () => ({ + id: 'new-evt', + title: 'Trip', + current_version: 1, + origin: 'analyst', + status: 'confirmed', + time_start: '2015-06-01T00:00:00Z', + time_end: '2015-06-15T00:00:00Z', + time_precision: 'day', + event_type: 'travel', + evidence_strength: null, + claims: [], + }), + }) + vi.stubGlobal('fetch', fetchMock) + + renderForm() + fireEvent.click(screen.getByTestId('create-event-from-selection')) + fireEvent.change(screen.getByTestId('create-event-title'), { + target: { value: 'Trip' }, + }) + fireEvent.change(screen.getByTestId('create-event-type'), { + target: { value: 'travel' }, + }) + fireEvent.change(screen.getByTestId('create-event-precision'), { + target: { value: 'day' }, + }) + fireEvent.click(screen.getByTestId('create-event-submit')) + + await waitFor(() => { + expect(fetchMock).toHaveBeenCalled() + }) + const [url, init] = fetchMock.mock.calls[0]! + expect(String(url)).toContain('/api/events') + expect((init as RequestInit).method).toBe('POST') + const body = JSON.parse(String((init as RequestInit).body)) + expect(body.title).toBe('Trip') + expect(body.event_type).toBe('travel') + expect(body.time_precision).toBe('day') + expect(body.time_start).toBe('2015-06-01T00:00:00Z') + expect(body.time_end).toBe('2015-06-15T00:00:00Z') + }) +}) diff --git a/apps/chronicle/web/src/events/CreateEventFromBrush.tsx b/apps/chronicle/web/src/events/CreateEventFromBrush.tsx new file mode 100644 index 0000000..dbbd570 --- /dev/null +++ b/apps/chronicle/web/src/events/CreateEventFromBrush.tsx @@ -0,0 +1,166 @@ +import { useState } from 'react' +import { useMutation, useQueryClient } from '@tanstack/react-query' + +import type { EventType } from '../api/types' +import { ApiError } from '../api/client' +import type { Viewport } from '../chronicle/timeScale' +import { useWorkingSetStore } from '../workingset/store' +import { toIsoSeconds } from '../workingset/urlState' +import { createEvent } from './api' + +const EVENT_TYPES: EventType[] = [ + 'decision', + 'meeting', + 'travel', + 'purchase', + 'deadline', + 'transition', + 'document', + 'communication', + 'user_defined', +] + +const PRECISIONS = ['year', 'quarter', 'month', 'week', 'day', 'hour'] as const + +export interface CreateEventFromBrushProps { + brush: Viewport + onCreated?: () => void +} + +/** + * Toolbar/brush affordance: when a brush exists, "Create event from selection" + * opens a form (title, type, precision) → POST with time_start/end from brush. + */ +export function CreateEventFromBrush({ brush, onCreated }: CreateEventFromBrushProps) { + const [open, setOpen] = useState(false) + const [title, setTitle] = useState('') + const [eventType, setEventType] = useState('communication') + const [precision, setPrecision] = useState<(typeof PRECISIONS)[number]>('day') + const [error, setError] = useState(null) + const qc = useQueryClient() + + const mutation = useMutation({ + mutationFn: () => + createEvent({ + title: title.trim(), + time_start: toIsoSeconds(brush.fromMs), + time_end: toIsoSeconds(brush.toMs), + time_precision: precision, + event_type: eventType, + }), + onSuccess: (evt) => { + setError(null) + setOpen(false) + setTitle('') + void qc.invalidateQueries({ queryKey: ['chronicle', 'buckets'] }) + void qc.invalidateQueries({ queryKey: ['events'] }) + onCreated?.() + useWorkingSetStore.getState().setSelection({ kind: 'event', eventId: evt.id }) + }, + onError: (err: unknown) => { + if (err instanceof ApiError) { + setError(err.message) + } else { + setError('Failed to create event') + } + }, + }) + + if (!open) { + return ( +
+ +
+ ) + } + + return ( +
{ + e.preventDefault() + if (!title.trim()) { + setError('Title is required') + return + } + mutation.mutate() + }} + > + + + + + + {error ? ( +

+ {error} +

+ ) : null} +
+ ) +} diff --git a/apps/chronicle/web/src/events/EventCard.test.tsx b/apps/chronicle/web/src/events/EventCard.test.tsx new file mode 100644 index 0000000..bc91719 --- /dev/null +++ b/apps/chronicle/web/src/events/EventCard.test.tsx @@ -0,0 +1,177 @@ +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { fireEvent, render, screen, waitFor } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +import { resetWorkingSetStore, useWorkingSetStore } from '../workingset/store' +import { EventCard } from './EventCard' + +const eventPayload = { + id: 'evt-1', + title: 'Metal roof decision', + time_start: '2015-06-15T00:00:00Z', + time_end: null, + time_precision: 'month', + origin: 'analyst', + event_type: 'decision', + status: 'unreviewed', + evidence_strength: 'high', + current_version: 1, + summary: 'Chose metal roofing.', + version: { + version: 1, + author: 'analyst', + title: 'Metal roof decision', + summary: 'Chose metal roofing.', + derivation: {}, + }, + claims: [ + { + id: 'c1', + position: 0, + text: 'Metal roof selected', + status: 'direct', + citations: [ + { + source_id: 'msg_42', + source_type: 'message', + subject: 'Roof quote', + sender: 'Alice', + date: '2015-06-10T12:00:00Z', + }, + ], + }, + ], +} + +function renderCard(eventId = 'evt-1') { + const client = new QueryClient({ + defaultOptions: { queries: { retry: false }, mutations: { retry: false } }, + }) + return render( + + + , + ) +} + +describe('EventCard', () => { + beforeEach(() => { + resetWorkingSetStore() + }) + + afterEach(() => { + vi.unstubAllGlobals() + resetWorkingSetStore() + }) + + it('renders origin, status, precision text, and claims', async () => { + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue({ + ok: true, + status: 200, + json: async () => eventPayload, + }), + ) + + renderCard() + await waitFor(() => { + expect(screen.getByTestId('event-card')).toBeInTheDocument() + }) + expect(screen.getByTestId('event-origin-badge')).toHaveTextContent(/Analyst/) + expect(screen.getByTestId('event-status-badge')).toHaveTextContent(/unreviewed/) + expect(screen.getByTestId('event-time')).toHaveTextContent(/month precision/) + expect(screen.getByTestId('event-time')).toHaveTextContent(/June 2015/) + expect(screen.getByTestId('event-evidence-strength')).toHaveTextContent(/high/) + expect(screen.getByTestId('event-summary')).toHaveTextContent('Chose metal roofing.') + expect(screen.getByTestId('event-claim')).toHaveTextContent('Metal roof selected') + expect(screen.getByTestId('event-claim')).toHaveTextContent(/1 citation/) + expect(screen.getByTestId('event-reconstruction')).toBeDisabled() + expect(screen.getByTestId('event-reconstruction')).toHaveAttribute( + 'title', + 'Reconstruction arrives with the next task', + ) + }) + + it('confirm and dismiss patch with optimistic version; 409 shows banner', async () => { + const fetchMock = vi.fn().mockImplementation(async (url: string, init?: RequestInit) => { + const u = String(url) + if (u.includes('/api/events/evt-1') && (!init || init.method === 'GET' || !init.method)) { + return { + ok: true, + status: 200, + json: async () => eventPayload, + } as Response + } + if (u.includes('/api/events/evt-1') && init?.method === 'PATCH') { + const body = JSON.parse(String(init.body)) as { current_version: number; status?: string } + if (body.current_version !== 1) { + return { + ok: false, + status: 409, + json: async () => ({ detail: { error: 'version_conflict' } }), + } as Response + } + return { + ok: true, + status: 200, + json: async () => ({ ...eventPayload, status: body.status ?? eventPayload.status }), + } as Response + } + throw new Error(`unexpected: ${u} ${init?.method}`) + }) + vi.stubGlobal('fetch', fetchMock) + + renderCard() + await waitFor(() => expect(screen.getByTestId('event-card')).toBeInTheDocument()) + + fireEvent.click(screen.getByTestId('event-confirm')) + await waitFor(() => { + const patchCalls = fetchMock.mock.calls.filter( + (c) => (c[1] as RequestInit | undefined)?.method === 'PATCH', + ) + expect(patchCalls.length).toBeGreaterThanOrEqual(1) + const body = JSON.parse(String((patchCalls[0]![1] as RequestInit).body)) + expect(body).toEqual({ current_version: 1, status: 'confirmed' }) + }) + + // Force 409 path + fetchMock.mockImplementation(async (url: string, init?: RequestInit) => { + if (String(url).includes('/api/events/evt-1') && init?.method === 'PATCH') { + return { + ok: false, + status: 409, + json: async () => ({ detail: { error: 'version_conflict' } }), + } as Response + } + return { + ok: true, + status: 200, + json: async () => eventPayload, + } as Response + }) + + fireEvent.click(screen.getByTestId('event-dismiss')) + await waitFor(() => { + expect(screen.getByTestId('event-conflict-banner')).toBeInTheDocument() + }) + }) + + it('citation click selects source in inspector', async () => { + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue({ + ok: true, + status: 200, + json: async () => eventPayload, + }), + ) + renderCard() + await waitFor(() => expect(screen.getByTestId('event-citation')).toBeInTheDocument()) + fireEvent.click(screen.getByTestId('event-citation')) + expect(useWorkingSetStore.getState().selection).toEqual({ + kind: 'message', + sid: 'msg_42', + }) + }) +}) diff --git a/apps/chronicle/web/src/events/EventCard.tsx b/apps/chronicle/web/src/events/EventCard.tsx new file mode 100644 index 0000000..9a9abf8 --- /dev/null +++ b/apps/chronicle/web/src/events/EventCard.tsx @@ -0,0 +1,382 @@ +import { useState } from 'react' +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' + +import { ApiError } from '../api/client' +import type { EventType } from '../api/types' +import { useWorkingSetStore } from '../workingset/store' +import { getEvent, patchEvent } from './api' +import { formatEventTime, originLabel } from './format' + +const EVENT_TYPES: EventType[] = [ + 'decision', + 'meeting', + 'travel', + 'purchase', + 'deadline', + 'transition', + 'document', + 'communication', + 'user_defined', +] + +const PRECISIONS = ['year', 'quarter', 'month', 'week', 'day', 'hour'] as const + +export interface EventCardProps { + eventId: string + onClose?: () => void +} + +/** + * Inspector event card: origin badge, time+precision, type, status, evidence, + * summary, claims, Confirm/Dismiss/Edit, and reconstruction placeholder. + */ +export function EventCard({ eventId, onClose }: EventCardProps) { + const setSelection = useWorkingSetStore((s) => s.setSelection) + const qc = useQueryClient() + const [editing, setEditing] = useState(false) + const [conflictBanner, setConflictBanner] = useState(null) + const [editTitle, setEditTitle] = useState('') + const [editSummary, setEditSummary] = useState('') + const [editType, setEditType] = useState('communication') + const [editPrecision, setEditPrecision] = useState('day') + const [editTimeStart, setEditTimeStart] = useState('') + + const query = useQuery({ + queryKey: ['events', eventId], + queryFn: ({ signal }) => getEvent(eventId, signal), + retry: false, + }) + + const invalidate = () => { + void qc.invalidateQueries({ queryKey: ['events', eventId] }) + void qc.invalidateQueries({ queryKey: ['chronicle', 'buckets'] }) + } + + const statusMutation = useMutation({ + mutationFn: (status: string) => { + const ver = query.data?.current_version + if (ver == null) throw new Error('no version') + return patchEvent(eventId, { current_version: ver, status }) + }, + onSuccess: () => { + setConflictBanner(null) + invalidate() + }, + onError: (err: unknown) => { + if (err instanceof ApiError && err.status === 409) { + setConflictBanner( + 'This event was updated elsewhere. Refresh and try again.', + ) + void query.refetch() + } + }, + }) + + const editMutation = useMutation({ + mutationFn: () => { + const ver = query.data?.current_version + if (ver == null) throw new Error('no version') + return patchEvent(eventId, { + current_version: ver, + title: editTitle.trim(), + summary: editSummary, + event_type: editType, + time_precision: editPrecision, + time_start: editTimeStart || undefined, + }) + }, + onSuccess: () => { + setConflictBanner(null) + setEditing(false) + invalidate() + }, + onError: (err: unknown) => { + if (err instanceof ApiError && err.status === 409) { + setConflictBanner( + 'This event was updated elsewhere. Refresh and try again.', + ) + void query.refetch() + } + }, + }) + + if (query.isLoading) { + return ( +
+
+
+
+ ) + } + + if (query.isError || !query.data) { + return ( +
+

Failed to load event

+ +
+ ) + } + + const evt = query.data + const summary = evt.summary ?? evt.version?.summary ?? null + const claims = evt.claims ?? [] + + const startEdit = () => { + setEditTitle(evt.title) + setEditSummary(summary ?? '') + setEditType((evt.event_type as EventType) || 'communication') + setEditPrecision(evt.time_precision || 'day') + setEditTimeStart(evt.time_start?.slice(0, 16) ?? '') + setEditing(true) + } + + return ( +
+
+

{evt.title}

+

+ + Origin: {originLabel(String(evt.origin))} + + + Status: {evt.status} + + {evt.evidence_strength ? ( + + Evidence: {evt.evidence_strength} + + ) : null} +

+

+ {formatEventTime(evt.time_start, String(evt.time_precision), evt.time_end)} +

+

+ Type: {evt.event_type} +

+
+ + {conflictBanner ? ( +
+ {conflictBanner} +
+ ) : null} + + {editing ? ( +
{ + e.preventDefault() + editMutation.mutate() + }} + > + +