diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 3538a1f..cd7e07f 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -84,7 +84,10 @@ annotated, and do not silence an `unused-coroutine` error. (`_query`, `_fetch`, `_create`, `_update`, `_delete`). Capability checks, loss policies, client-side filtering and the rest are written once in the base classes. `_async/memory.py`, the reference backend, - is the smallest complete example. + is the smallest complete example. A backend with a time log of its + own (a tracker) also overrides `_time_entries`, `_time_log`, + `_add_time`, `_amend_time` and `_delete_time`; the defaults store + entries as `VEVENT`s. 2. Declare its capabilities honestly, in the `capabilities` each constructor takes. The conformance suite asserts that an operation declared unsupported fails as specified. diff --git a/docs/design/API_DESIGN.md b/docs/design/API_DESIGN.md index 83624cf..733b83f 100644 --- a/docs/design/API_DESIGN.md +++ b/docs/design/API_DESIGN.md @@ -793,7 +793,7 @@ class Task(Item): # component is an icalendar.Todo native_priority: str | None # likewise planned_start: datetime | None # X-PYCAL-PLANNED-START (see "Properties with no standard home") planned_end: datetime | None # X-PYCAL-PLANNED-END - # time_log, time_spent: added by roadmap 1.6 + # the time log: see "Time tracking" below, on every bound item def set_duration(self, duration: timedelta, keep: Literal["start", "due"] = "due") -> None: ... ``` @@ -843,6 +843,133 @@ Decisions in it, each from the survey: thing. The name, role and participation status are the `ATTENDEE`'s `CN`, `ROLE` and `PARTSTAT` parameters, on `icalendar`'s `vCalAddress`. +#### Time tracking + +From [roadmap 1.6](ROADMAP.md#16-time-tracking-model-and-api), on the +survey's model ([§4.2](TASK_MODEL_SURVEY.md#42-the-time-model), option D of +[§4.3](TASK_MODEL_SURVEY.md#43-the-icalendar-representation-of-time-spent--a-recommendation)). Time spent is an +append-only log of entries, and the total is derived from it. Time is +logged against a task, or against an event the user took part in (a +meeting). + +```python +@dataclass(frozen=True) +class TimeEntry: # calendaring.timetracking, mode-free + duration: timedelta + start: datetime | None = None + end: datetime | None = None # None, with start set: the running timer + users: tuple[str, ...] = () # calendar addresses (mailto: or a profile URL, as for assignees) + comment: str | None = None + logged: datetime | None = None # when it was recorded (CREATED) + id: str | None = None # the entry's UID, or the backend's id for it + +# on a bound item (task or event); reading the log is a query, so these are methods +await item.time_log() -> list[TimeEntry] # by start; a running timer last +await item.time_spent() -> timedelta # the sum, a running timer up to now +await item.log_time(*, spent=None, start=None, end=None, day=None, + users=(), comment=None) -> TimeEntry +await item.amend_time(entry, **changes) -> TimeEntry +await item.delete_time(entry) +await task.start_timer(at=None) # tasks only +await task.stop_timer(at=None, *, users=(), comment=None) -> TimeEntry +await collection.time_entries(start, end) -> list[tuple[str, TimeEntry]] # (item UID, entry) +``` + +- **`log_time` takes two of `spent`, `start` and `end`**, and raises + `ValueError` if three disagree, so a CLI's `--spent/--start/--end` is a + thin layer. `spent` alone ends the entry now. +- **`spent` with `day`** ("3 h on this, 2 h on that, on Tuesday") lines + the entry up after the last entry the same people logged that day, or + at `collection.workday_start` (08:00 local) when there is none. The + day's sum is then right, though the times are made up; a day that + fills up runs on past midnight rather than lose time. RFC 5545 has no + other way: a `DURATION` after a `DATE` `DTSTART` must be whole days. +- **`amend_time`** changes what it is given and keeps the rest: `spent` + alone keeps the start, `spent` with `end` keeps the end. It edits the + stored entry in place, so what other clients added to it stays. An + entry that has no times (from a backend that drops them) keeps none: + times are never invented. +- **Times are never floating by accident.** A time in a zone without an + IANA name (what `astimezone()` gives) is stored in UTC, and a floating + time mixed with a zoned one raises `ValueError`. +- **The running timer is not an entry.** `start_timer()` writes + `X-PYCAL-TIMER-START` on the `VTODO`, and `stop_timer()` removes it and + logs the entry. A `VEVENT` without `DTEND` is an instant (RFC 5545), so + an open entry cannot be one. The timer syncs to other devices with the + task, and works where entries do not. + +**In iCalendar**, an entry is a `VEVENT`: + +``` +BEGIN:VEVENT +UID:… +DTSTAMP:… +CREATED:20261020T163000Z when it was logged +DTSTART:20261020T130000Z +DTEND:20261020T150000Z +SUMMARY: +DESCRIPTION: +RELATED-TO;RELTYPE=PARENT: +X-PYCAL-TIME-ENTRY:TRUE +BEGIN:PARTICIPANT one per user (RFC 9073) +UID:… +PARTICIPANT-TYPE:ACTIVE +CALENDAR-ADDRESS:mailto:alice@example.com +END:PARTICIPANT +END:VEVENT +``` + +- **Who did the work is a `PARTICIPANT`, never an `ATTENDEE`.** RFC 9073 + §7.1.1 makes a participant schedulable only when its + `CALENDAR-ADDRESS` matches an `ATTENDEE`. With none, no server sends + invitations, which an `ATTENDEE` would make it do (RFC 6638). The + tasks draft (§6) assigns with the same component. An entry may name + several people; one duration then applies to each. Two people who + spent different times log two entries. +- **`X-PYCAL-TIME-ENTRY` marks it**, so the entry is recognised in any + collection and after a copy. `RELATED-TO` alone also matches ordinary + sub-events, and `CATEGORIES` collides with users' own. +- **Entries go to the item's collection by default**, or to + `collection.time_log_collection` (and are searched there). Many CalDAV + task lists hold only `VTODO`, and some servers cannot hold entries at + all, so a separate calendar for the log is normal. +- **A `VJOURNAL` is not used**: it has no `DTEND` or `DURATION`, and iCloud + and Google do not store it. A journal note can point at an entry with + `RELATED-TO`. + +**The capability is `time-log`.** +- `FULL` where entries round-trip. +- `LOSSY` where the backend keeps entries but drops fields, with + `details["lost"]` naming them (`start-time`, `end`, `users`, + `comment`). Request Tracker records only when time was logged, and + OpenProject only a date. Those fields read back as `None`, never + invented, so the backend's declaration says what an entry from it + lacks, and no event carries an "untimed" marker. +- `details["total-only"]` for a backend that keeps only a sum: reading + gives one entry with the total and no start, and `amend_time` and + `delete_time` raise `UnsupportedError`. +- `UNSUPPORTED` raises before any I/O. +- A tracker backend maps entries onto its own time log (roadmap 2.5) by + overriding the collection's `_time_entries`, `_time_log`, `_add_time`, + `_amend_time` and `_delete_time`. The rule: the capability is the log + collection's (the item's own, unless `time_log_collection` is set); the + hooks and settings are the item's collection's, and the default hooks + store into the log collection. +- A time entry is not a subtask: `relatives()` leaves entries out. + +**For `sum_hours`**, `calendaring.timetracking.summarize(pairs, group_by=…, +window=(start, end))` groups `(item, entries)` pairs into +`TimeSummary(estimate, spent)` per key. `estimate` is the +`ESTIMATED-DURATION` of the open tasks, and `spent` the logged time. The +two are kept apart, since committed and spent time answer different +questions. The window clips entries, prorating one that straddles an +edge; an entry with no start counts by when it was logged. + +**Not settled:** percent-complete per entry (no system in the survey has +it), an activity type or billable flag (would be `CATEGORIES`), and where +an entry belongs after a recurring task is completed "safely" (it stays +with the UID it was logged against: [survey §4.4](TASK_MODEL_SURVEY.md#44-details-to-be-decided-later)). + ### 3.5 Native passthrough `native_status` and `native_priority` are a plain `str | None`, set by the @@ -875,8 +1002,8 @@ not `X-CALENDARING-`: - the package name may change (see the top of this document), and once written into users' calendars a prefix cannot. -In this document: `X-PYCAL-PLANNED-START`, `X-PYCAL-PLANNED-END`. -[roadmap 1.6](ROADMAP.md#16-time-tracking-model-and-api) adds its own. Each gets a line in a +In this document: `X-PYCAL-PLANNED-START`, `X-PYCAL-PLANNED-END`, and from +[roadmap 1.6](ROADMAP.md#16-time-tracking-model-and-api) `X-PYCAL-TIME-ENTRY` and `X-PYCAL-TIMER-START` ("Time tracking" above). Each gets a line in a registry table in the user documentation ([roadmap 4.1](ROADMAP.md#41-documentation-structure-and-api-reference)), with the standard property that would replace it if one appears. @@ -1075,6 +1202,7 @@ initial set: | `alarms` | `VALARM` components survive; `UNSUPPORTED` drops them | | `timezones` | a time with a `TZID` keeps it (with its `VTIMEZONE`), a floating time stays floating; `LOSSY` stores both in UTC, a floating time read as local time ([§4](#4-search)) | | `dates` | a `DATE` value stays a date; `LOSSY` stores midnight UTC | +| `time-log` | the time log of a task or event ("Time tracking" in [§3.4](#34-task)); `LOSSY` with `details["lost"]`, or `details["total-only"]` | Text is not a feature: non-ASCII, escaped and multi-line text, and every value of a property with several, round-trip on every backend. The @@ -1085,7 +1213,7 @@ Feature names are identifiers, not attribute paths: `task.due` means "stores a task's `DUE`", which is read as `task.component.DUE`, and `task.estimate` means `ESTIMATED-DURATION`. -[roadmap 1.6](ROADMAP.md#16-time-tracking-model-and-api) adds `task.time-log` and friends. Below 1.0 the list grows as backends need +[roadmap 1.6](ROADMAP.md#16-time-tracking-model-and-api) added `time-log` ("Time tracking" in [§3.4](#34-task)). Below 1.0 the list grows as backends need it ([§10 Open questions](#10-open-questions), Q7). The conformance suite iterates over `Feature`, and a backend's declaration must cover every member (missing = test failure, not a silent default). diff --git a/docs/design/ROADMAP.md b/docs/design/ROADMAP.md index 4919702..721093c 100644 --- a/docs/design/ROADMAP.md +++ b/docs/design/ROADMAP.md @@ -455,25 +455,50 @@ were moved out of that grant for the same reason (see the note in the MoU). It therefore must not be trimmed, and it must land before `plann` reaches its Phase 4. **Tasks:** -- [ ] Implement the model 0.1 recommends: estimate, spent, and the relationship +- [x] Implement the model 0.1 recommends: estimate, spent, and the relationship between them -- [ ] Decide and document the iCalendar representation, including whether an `X-` +- [x] Decide and document the iCalendar representation, including whether an `X-` property is used and under whose namespace — iCalendar has no standard answer, and whatever is chosen here is what other tools will have to read -- [ ] Time spent is an append-only log with the total derived — settled by 0.1 +- [x] Time spent is an append-only log with the total derived — settled by 0.1 ([survey 3.2](TASK_MODEL_SURVEY.md#32-dimension-by-dimension)). What remains is what round-trips through a backend that stores only the total -- [ ] The API: start, stop, log a duration after the fact, amend, and read back +- [x] The API: start, stop, log a duration after the fact, amend, and read back - [ ] Map it onto each backend's native notion where one exists (task trackers generally have one; CalDAV does not), and declare the capability honestly - where it does not -- [ ] Extend the conformance suite (1.2) to cover it, including the lossy cases -- [ ] Aggregation primitives sufficient for `plann`'s `sum_hours` — grouping a + where it does not — the capability and the hooks are in, and CalDAV + (2.1) declares it; a tracker's own log is 2.5's +- [x] Extend the conformance suite (1.2) to cover it, including the lossy cases +- [x] Aggregation primitives sufficient for `plann`'s `sum_hours` — grouping a selection into budget lines with per-line subtotals, keeping committed and spent time separate -- [ ] Coordinate the design with `plann` before freezing it; a review of how +- [x] Coordinate the design with `plann` before freezing it; a review of how the design affects `plann` is cheap now and expensive after 1.0 +Notes from doing it: + +- The design is "Time tracking" in [API_DESIGN §3.4](API_DESIGN.md#34-task). + It was reviewed by plann's author in three rounds. His comments + changed who did the work from `ORGANIZER` to RFC 9073 `PARTICIPANT`, + and cut the planned `X-` properties from five to two + (`X-PYCAL-TIME-ENTRY`, `X-PYCAL-TIMER-START`). plann's earlier design + notes (a `VJOURNAL` per entry) were set aside: a `VJOURNAL` holds no + interval. +- Time is logged against events as well as tasks (time spent in a + meeting), so the methods are on every bound item. +- Research during the review overturned an assumption of the survey: the + trackers it took for total-only (GitLab, Request Tracker, Kanboard, + OpenProject, Gitea) mostly keep individual entries, and what they lack is + the start time. So `time-log` is `LOSSY` with `details["lost"]` naming + the missing fields; `total-only` is the exception. +- Two fake backends in `tests/_async/test_memory_conformance.py`, a + tracker's own log without start times and a total-only one, run the + lossy branches until 2.5 brings real ones. +- The 2.1 backend declares `time-log` `FULL` on a server caldav has hints + for (the conformance suite checks the `PARTICIPANT` component on + Xandikos and Radicale), `UNKNOWN` on another, and `UNSUPPORTED` in a + calendar holding no events, where `time_log_collection` points elsewhere. + --- ## Phase 2: Backends (62 hours) diff --git a/docs/design/TASK_MODEL_SURVEY.md b/docs/design/TASK_MODEL_SURVEY.md index f9e59e6..700eb3d 100644 --- a/docs/design/TASK_MODEL_SURVEY.md +++ b/docs/design/TASK_MODEL_SURVEY.md @@ -991,7 +991,7 @@ for time *recorded* rather than time *planned*. ### 4.4 Details to be decided later -* How to tag a `VEVENT` as a work-log; can be done through `CATEGORIES:WORKLOG`, an `X-` property, or membership of a dedicated collection. +* How to tag a `VEVENT` as a work-log; can be done through `CATEGORIES:WORKLOG`, an `X-` property, or membership of a dedicated collection. *Settled in roadmap 1.6: `X-PYCAL-TIME-ENTRY` ("Time tracking" in [API_DESIGN §3.4](API_DESIGN.md#34-task)).* * Should we carry the `remaining` duration, considering that only two of the nine systems with a task model of their own support it? diff --git a/news/+time-log.feature.rst b/news/+time-log.feature.rst new file mode 100644 index 0000000..8cbe40e --- /dev/null +++ b/news/+time-log.feature.rst @@ -0,0 +1 @@ +Time tracking: log the time spent on a task or in a meeting (``log_time()``, ``start_timer()`` and ``stop_timer()`` on a task), read it back with ``time_log()`` and ``time_spent()``, and sum it per customer or budget line with ``calendaring.timetracking.summarize()``. Entries are stored as ordinary calendar events, so any CalDAV client shows them and a calendar search finds a week's work. diff --git a/src/calendaring/_async/collection.py b/src/calendaring/_async/collection.py index 8ad57a6..3b8b718 100644 --- a/src/calendaring/_async/collection.py +++ b/src/calendaring/_async/collection.py @@ -13,10 +13,12 @@ import binascii import copy import json +import uuid from abc import ABC, abstractmethod from collections.abc import AsyncIterator, Generator, Iterable, Mapping, Sequence from contextlib import contextmanager, suppress -from datetime import UTC, datetime, timedelta +from dataclasses import replace +from datetime import UTC, date, datetime, time, timedelta from typing import TYPE_CHECKING, Any, Literal, overload import icalendar @@ -26,6 +28,7 @@ from .._mapping import Change, differences, plan_calendar_losses, plan_losses from ..capabilities import BACKEND_FEATURES, Capabilities, Feature, Support from ..errors import ( + AlreadyExistsError, CalendaringError, ConflictError, InvalidDataError, @@ -35,8 +38,31 @@ UnsupportedError, VerificationError, ) -from ..items import Event, Item, Journal, Task, _items_of, _parse, split_stored, without_occurrence +from ..items import ( + Event, + Item, + Journal, + Task, + _items_of, + _parse, + _set_datetime, + _wrap_component, + split_stored, + without_occurrence, +) from ..loss import RAISE, LossPolicy +from ..timetracking import ( + X_TIME_ENTRY, + X_TIMER_START, + TimeEntry, + apply_entry, + comparable, + entry_from_component, + entry_to_component, + is_entry, + resolve, + running_timer, +) from ..types import BrokenObject, ChangeSet, Component, Scope, StoredObject, SyncToken from .items import BOUND, AsyncEvent, AsyncItem, AsyncJournal, AsyncTask @@ -45,6 +71,13 @@ __all__ = ["AsyncCollection"] + +def _by_start(entry: TimeEntry) -> tuple[bool, float]: + """Sort key: by start (a floating time read as local); entries without one last, by when they were logged.""" + when = entry.start or entry.logged + return entry.start is None, when.timestamp() if when is not None else 0.0 + + COMPONENT_FEATURE = { Component.EVENT: Feature.COMPONENT_EVENT, Component.TASK: Feature.COMPONENT_TASK, @@ -107,6 +140,10 @@ def __init__( self.components = frozenset(components) self.capabilities = capabilities self.native = native + # Where time entries go and are read from (§3.4): many task lists hold no events. + self.time_log_collection: AsyncCollection | None = None + # Where a day of durations starts, with log_time(spent=..., day=...). + self.workday_start = time(8) def __repr__(self) -> str: return f"<{type(self).__name__} {self.backend_id}/{self.id}>" @@ -230,6 +267,49 @@ async def _delete_collection(self) -> None: def _wrap_native(self, native_item: object) -> StoredObject: raise UnsupportedError(None, f"{type(self).__name__} cannot wrap {type(native_item).__name__}") + # The time log (§3.4). The defaults store entries as VEVENTs in the + # time-log collection; a backend with a time log of its own (a tracker, + # roadmap 2.5) overrides all five. + + async def _time_entries( + self, start: datetime | None, end: datetime | None, *, parent: str | None = None + ) -> list[tuple[str | None, TimeEntry]]: + """``(the UID it was logged against, entry)`` for the entries stored here: in a time range, for a parent.""" + searcher = ( + Searcher(event=True, start=start, end=end) + if start is not None and end is not None + else Searcher(event=True) + ) + searcher.add_property_filter(X_TIME_ENTRY, "TRUE", operator="==") + if parent is not None: + searcher.add_property_filter("RELATED-TO", parent, operator="==") + found = [entry_from_component(i.component) for i in await self.search(searcher) if is_entry(i.component)] + return [pair for pair in found if parent is None or pair[0] == parent] + + async def _time_log(self, item: Item) -> list[TimeEntry]: + return [entry for _, entry in await self._log_target()._time_entries(None, None, parent=item.uid)] + + async def _add_time(self, item: Item, entry: TimeEntry) -> TimeEntry: + stored = await self._log_target().add(Event(_wrap_component(entry_to_component(entry, parent=item)))) + return entry_from_component(stored.component)[1] + + async def _amend_time(self, item: Item, old: TimeEntry, new: TimeEntry) -> TimeEntry: + assert old.id is not None + stored = await self._log_target().get(old.id) + component = stored.component # edited in place: what other clients added stays + apply_entry(component, new) + sequence = int(component.pop("SEQUENCE", 0)) + for name, value in (("SEQUENCE", sequence + 1), ("LAST-MODIFIED", datetime.now(UTC))): + component.pop(name, None) + component.add(name, value) + stored.icalendar.add_missing_timezones() + await stored.save() + return entry_from_component(stored.component)[1] + + async def _delete_time(self, item: Item, entry: TimeEntry) -> None: + assert entry.id is not None + await (await self._log_target().get(entry.id)).delete() + def _plan_losses(self, component: icalendar.Component) -> list[Change]: """Override for mapping rules beyond the capability table's ``values`` and ``map``.""" return plan_losses(component, self.capabilities) @@ -530,7 +610,7 @@ async def relatives(self, item: Item, reltype: str | None = None) -> list[AsyncI except PartialResultError as exc: broken.extend(exc.broken) listed = list(exc.results) - for other in listed: + for other in (i for i in listed if not is_entry(i.component)): # a time entry is no subtask for value in self._related_to(other): kind = str(value.params.get("RELTYPE", "PARENT")).upper() if str(value) == item.uid and kind in back and (wanted is None or back[kind] == wanted): @@ -799,6 +879,162 @@ async def uncomplete(self, task: Task, *, loss: LossPolicy | None = None, verify assert isinstance(result, AsyncTask) return result + # === the time log ================================================== + + def _log_target(self) -> AsyncCollection: + return self.time_log_collection or self + + def _require_time_log(self, *, write: bool = False) -> AsyncCollection: + target = self._log_target() + target._require(Feature.TIME_LOG) + if write: + target._require(Feature.WRITE) + return target + + def _require_entries(self) -> None: + """Amending or deleting needs entries; a backend that keeps only the total has none.""" + if self._log_target().capabilities.details(Feature.TIME_LOG).get("total-only"): + raise UnsupportedError(Feature.TIME_LOG, "the backend keeps only the total time spent") + + async def time_log(self, item: Item, *, now: datetime | None = None) -> list[TimeEntry]: + """The entries logged against ``item``, by start; a running timer last.""" + with self._context(): + self._require_time_log() + entries = sorted(await self._time_log(item), key=_by_start) + timer = running_timer(item, now or datetime.now(UTC)) + return entries + ([timer] if timer is not None else []) + + async def time_spent(self, item: Item, *, now: datetime | None = None) -> timedelta: + """The sum of the time log in person-time, a running timer up to now.""" + return sum((e.person_time for e in await self.time_log(item, now=now)), timedelta(0)) + + async def log_time( + self, + item: Item, + *, + spent: timedelta | None = None, + start: datetime | None = None, + end: datetime | None = None, + day: date | None = None, + users: Iterable[str] = (), + comment: str | None = None, + ) -> TimeEntry: + """Log time against ``item``: two of ``spent``, ``start`` and ``end``, or ``spent`` with a ``day``.""" + with self._context(): + self._require_time_log(write=True) + now = datetime.now(UTC) + if day is not None: + if spent is None or start is not None or end is not None: + raise ValueError("log_time(): day= goes with spent= alone") + start = await self._day_cursor(day, tuple(users)) + begin, finish = resolve(spent=spent, start=start, end=end, now=now) + entry = TimeEntry(finish - begin, begin, finish, tuple(users), comment, logged=now) + return await self._add_time(item, entry) + + async def _day_cursor(self, day: date, users: tuple[str, ...]) -> datetime: + """Where the next entry of these users' ``day`` starts: after their last one, else at the workday's start. + + A day that fills up runs on past midnight: the sum is what counts. + """ + midnight = datetime.combine(day, time()).astimezone() + listed = await self._log_target()._time_entries(midnight, midnight + timedelta(days=1)) + ends = [ + comparable(e.end) + for _, e in listed + if e.start is not None + and e.end is not None + and comparable(e.start).date() == day + and (set(e.users) & set(users) if users else not e.users) + ] + return max(ends, default=datetime.combine(day, self.workday_start).astimezone()) + + async def amend_time(self, item: Item, entry: TimeEntry, **changes: Any) -> TimeEntry: + """Change an entry: ``spent``, ``start``, ``end``, ``users`` or ``comment``; the rest stays.""" + with self._context(): + self._require_time_log(write=True) + self._require_entries() + unknown = set(changes) - {"spent", "start", "end", "users", "comment"} + if unknown: + raise TypeError(f"amend_time(): unknown {sorted(unknown)}") + if entry.id is None: + raise ValueError("amend_time(): the entry was never stored") + spent = changes.get("spent") + start, end = changes.get("start"), changes.get("end") + if spent is None: # the times not given stay + start = start if "start" in changes else entry.start + end = end if "end" in changes else entry.end + elif "start" not in changes and "end" not in changes: # spent alone keeps the start + start = entry.start + if start is None and end is None: # an entry without times stays without: never invent them + duration, begin, finish = spent or entry.duration, None, None + else: + begin, finish = resolve(spent=spent, start=start, end=end, now=datetime.now(UTC)) + duration = finish - begin + new = replace( + entry, + duration=duration, + start=begin, + end=finish, + users=tuple(changes.get("users", entry.users)), + comment=changes.get("comment", entry.comment), + ) + return await self._amend_time(item, entry, new) + + async def delete_time(self, item: Item, entry: TimeEntry) -> None: + with self._context(): + self._require_time_log(write=True) + self._require_entries() + if entry.id is None: + raise ValueError("delete_time(): the entry was never stored") + await self._delete_time(item, entry) + + async def time_entries(self, start: datetime, end: datetime) -> list[tuple[str, TimeEntry]]: + """``(item UID, entry)`` for every entry in the time range, by start: what ``sum_hours`` sums.""" + with self._context(): + target = self._require_time_log() + found = [(uid, e) for uid, e in await target._time_entries(start, end) if uid is not None] + return sorted(found, key=lambda pair: _by_start(pair[1])) + + async def start_timer(self, task: Task, at: datetime | None = None) -> AsyncTask: + """Start the timer on ``task``: ``X-PYCAL-TIMER-START`` until it is stopped.""" + with self._context(): + self._require_writable(task) + self._require_time_log() + if not isinstance(task, Task): + raise TypeError("start_timer(): the timer is for tasks") + if X_TIMER_START in task.component: + raise ValueError("start_timer(): a timer is already running on this task") + data = task.as_data() + _set_datetime(data.component, X_TIMER_START, (at or datetime.now(UTC)).astimezone(UTC)) + result = await self.save(data) + assert isinstance(result, AsyncTask) + return result + + async def stop_timer( + self, task: Task, at: datetime | None = None, *, users: Iterable[str] = (), comment: str | None = None + ) -> tuple[AsyncTask, TimeEntry]: + """Stop the timer: log the entry, then take the timer off the task.""" + with self._context(): + self._require_writable(task) + self._require_time_log(write=True) + now = datetime.now(UTC) + timer = running_timer(task, now) + if timer is None or timer.start is None: + raise ValueError("stop_timer(): no timer is running on this task") + begin, finish = resolve(spent=None, start=timer.start, end=at or now, now=now) + # Named after the task and the timer's start, so stopping it again after a failed save logs it once. + uid = str(uuid.uuid5(uuid.NAMESPACE_URL, f"{task.uid}#timer-{begin.isoformat()}")) + entry = TimeEntry(finish - begin, begin, finish, tuple(users), comment, logged=now, id=uid) + try: + entry = await self._add_time(task, entry) + except AlreadyExistsError: + pass # logged by an earlier stop_timer() that failed afterwards + data = task.as_data() + data.component.pop(X_TIMER_START, None) + result = await self.save(data) + assert isinstance(result, AsyncTask) + return result, entry + @overload async def move( self, item: Task, target: AsyncCollection, *, loss: LossPolicy | None = None, verify: bool | None = None diff --git a/src/calendaring/_async/conformance.py b/src/calendaring/_async/conformance.py index 7a0df50..d135cd5 100644 --- a/src/calendaring/_async/conformance.py +++ b/src/calendaring/_async/conformance.py @@ -56,6 +56,7 @@ async def open_backend(self) -> AsyncBackend: ) from ..items import Event, Item, Journal, Task, TaskStatus from ..loss import ALLOW, WARN, Loss +from ..timetracking import is_entry from ..types import Component, Scope, StoredObject, SyncToken from .backend import AsyncBackend from .collection import AsyncCollection @@ -64,8 +65,11 @@ async def open_backend(self) -> AsyncBackend: __all__ = ["AsyncConformance"] -def at(day: int, hour: int = 12) -> datetime: - return datetime(2026, 11, day, hour, tzinfo=UTC) +def at(day: int, hour: int = 12, minute: int = 0) -> datetime: + return datetime(2026, 11, day, hour, minute, tzinfo=UTC) + + +HOUR = timedelta(hours=1) def key(item: Item) -> tuple[str, str | None]: @@ -142,7 +146,8 @@ def minimal(kind: Component, summary: str, **properties: Any) -> Item: Feature.TASK_DUE: ("DUE", at(3), {}), Feature.TASK_COMPLETED: ("COMPLETED", at(2), {}), Feature.TASK_PLANNED: ("X-PYCAL-PLANNED-START", icalendar.vDDDTypes(at(2)), {}), - Feature.TASK_ESTIMATE: ("ESTIMATED-DURATION", timedelta(hours=2), {}), + # A bare timedelta would be written "2:00:00", which is not iCalendar. + Feature.TASK_ESTIMATE: ("ESTIMATED-DURATION", icalendar.vDuration(timedelta(hours=2)), {}), Feature.TASK_RELATIONS_PARENT: ("RELATED-TO", "conformance-parent", {"RELTYPE": "PARENT"}), Feature.TASK_RELATIONS_DEPENDS_ON: ("RELATED-TO", "conformance-dependency", {"RELTYPE": "DEPENDS-ON"}), } @@ -1123,6 +1128,167 @@ async def test_a_token_from_another_collection_raises( with pytest.raises(SyncTokenError): await collection.changes(token) + # === time tracking ================================================ + + async def logged_against(self, collection: AsyncCollection) -> AsyncItem: + """A stored task (an event where there are no tasks) to log time against, or the test ends here.""" + kind = Component.TASK if holds(collection, Component.TASK) else Component.EVENT + if not holds(collection, kind): + pytest.skip("the collection holds neither tasks nor events") + [item] = await self.seed(collection, [KINDS[kind][0].new(summary="logged against", start=at(1, 8))]) + return item + + async def assert_time_log_unsupported(self, collection: AsyncCollection, item: AsyncItem) -> bool: + """True if the time log is unsupported here, after checking that logging raises before any I/O.""" + if level(collection, Feature.TIME_LOG) is not Support.UNSUPPORTED: + return False + await self.assert_unsupported(collection, Feature.TIME_LOG, lambda: item.log_time(spent=HOUR)) + await self.assert_unsupported(collection, Feature.TIME_LOG, lambda: item.time_log()) + return True + + def lost(self, collection: AsyncCollection) -> set[str]: + return set(collection.capabilities.details(Feature.TIME_LOG).get("lost", ())) + + def total_only(self, collection: AsyncCollection) -> bool: + return bool(collection.capabilities.details(Feature.TIME_LOG).get("total-only")) + + async def test_time_log_round_trips_as_declared(self, collection: AsyncCollection) -> None: + item = await self.logged_against(collection) + if await self.assert_time_log_unsupported(collection, item): + return + assert await item.time_log() == [] + assert await item.time_spent() == timedelta(0) + if not writable(collection): + await self.assert_unsupported(collection, Feature.WRITE, lambda: item.log_time(spent=HOUR)) + return + users = ("mailto:alice@example.com", "mailto:bob@example.com") + logged = await item.log_time(start=at(3, 9), end=at(3, 11), users=users, comment="first draft\nwith figures") + assert logged.duration == 2 * HOUR + assert not any(is_entry(r.component) for r in await item.relatives()), "a time entry is no subtask" + [read] = await item.time_log() + if self.total_only(collection): + assert await item.time_spent() == 4 * HOUR, "two people for two hours, kept as a total" + assert read.start is None, "a total-only backend has no entries to give a start" + return + assert read.duration == 2 * HOUR + if "users" not in self.lost(collection): + assert await item.time_spent() == 4 * HOUR, "person-time: two people for two hours" + lost = self.lost(collection) + expected: dict[str, tuple[object, object]] = { + "start-time": (read.start, at(3, 9)), + "end": (read.end, at(3, 11)), + } + expected |= {"users": (read.users, users), "comment": (read.comment, "first draft\nwith figures")} + for name, (got, sent) in expected.items(): + if name in lost: + assert got in (None, ()), f"{name} is declared lost, yet read back as {got!r}: never invent it" + else: + assert got == sent, f"{name}: sent {sent!r}, read back {got!r}" + assert read.id is not None + + async def test_log_time_takes_two_of_three(self, collection: AsyncCollection) -> None: + item = await self.logged_against(collection) + if await self.assert_time_log_unsupported(collection, item) or not writable(collection): + return + before = await snapshot(collection) + with pytest.raises(ValueError, match="disagree"): + await item.log_time(spent=3 * HOUR, start=at(3, 9), end=at(3, 11)) + with pytest.raises(ValueError, match="two of"): + await item.log_time(start=at(3, 9)) + assert await snapshot(collection) == before, "a refused entry wrote something" + by_start = await item.log_time(spent=HOUR, start=at(3, 9)) + by_end = await item.log_time(spent=HOUR, end=at(4, 11)) + recent = await item.log_time(spent=HOUR) + assert [e.duration for e in (by_start, by_end, recent)] == [HOUR] * 3 + assert await item.time_spent() == 3 * HOUR + if not self.total_only(collection) and "start-time" not in self.lost(collection): + assert (by_start.start, by_start.end) == (at(3, 9), at(3, 10)) + assert (by_end.start, by_end.end) == (at(4, 10), at(4, 11)) + + async def test_a_day_of_durations_lines_up(self, collection: AsyncCollection) -> None: + item = await self.logged_against(collection) + if await self.assert_time_log_unsupported(collection, item) or not writable(collection): + return + tuesday = at(3).date() + first = await item.log_time(spent=3 * HOUR, day=tuesday) + second = await item.log_time(spent=2 * HOUR, day=tuesday) + assert await item.time_spent() == 5 * HOUR + if self.total_only(collection) or "start-time" in self.lost(collection): + return + assert first.start is not None + assert first.start.date() == tuesday + assert second.start == first.end, "the day's entries line up back to back" + + async def test_the_timer(self, collection: AsyncCollection) -> None: + if not holds(collection, Component.TASK): + pytest.skip("the timer is for tasks, and the collection holds none") + [task] = await self.seed(collection, [Task.new(summary="timed")]) + assert isinstance(task, AsyncTask) + if level(collection, Feature.TIME_LOG) is Support.UNSUPPORTED: + await self.assert_unsupported(collection, Feature.TIME_LOG, lambda: task.start_timer()) + return + if not writable(collection): + await self.assert_unsupported(collection, Feature.WRITE, lambda: task.start_timer()) + return + await task.start_timer(at=at(3, 9)) + with pytest.raises(ValueError, match="running"): + await task.start_timer() + reread = await collection.reload(task) + assert isinstance(reread, AsyncTask) + [running] = await reread.time_log() + assert running.running + assert running.start == at(3, 9) + entry = await task.stop_timer(at=at(3, 10, 30), comment="done for now") + assert entry.duration == timedelta(hours=1, minutes=30) + with pytest.raises(ValueError, match="running"): + await task.stop_timer() + [stored] = await (await collection.reload(task)).time_log() + assert not stored.running + assert stored.duration == timedelta(hours=1, minutes=30) + assert "X-PYCAL-TIMER-START" not in (await collection.reload(task)).component, "the timer is temporary" + + async def test_amend_and_delete_time(self, collection: AsyncCollection) -> None: + item = await self.logged_against(collection) + if await self.assert_time_log_unsupported(collection, item) or not writable(collection): + return + entry = await item.log_time(start=at(3, 9), end=at(3, 10), comment="first") + if self.total_only(collection): + [read] = await item.time_log() + await self.assert_unsupported(collection, Feature.TIME_LOG, lambda: item.amend_time(read, comment="x")) + await self.assert_unsupported(collection, Feature.TIME_LOG, lambda: item.delete_time(read)) + return + amended = await item.amend_time(entry, spent=2 * HOUR) + assert amended.duration == 2 * HOUR + assert amended.id == entry.id + [read] = await item.time_log() + assert read.duration == 2 * HOUR + if "comment" not in self.lost(collection): + assert read.comment == "first", "amending one field kept the others" + await item.delete_time(read) + assert await item.time_log() == [] + + async def test_time_spent_in_a_meeting(self, collection: AsyncCollection) -> None: + if not holds(collection, Component.EVENT): + pytest.skip("the collection holds no events") + [meeting] = await self.seed(collection, [Event.new(summary="planning", start=at(5, 9), end=at(5, 10))]) + if await self.assert_time_log_unsupported(collection, meeting) or not writable(collection): + return + await meeting.log_time(start=at(5, 9), end=at(5, 10), users=("mailto:alice@example.com",)) + [entry] = await meeting.time_log() + assert entry.duration == HOUR + assert await collection.reload(meeting) == meeting, "logging time does not touch the meeting itself" + + async def test_time_entries_in_a_range(self, collection: AsyncCollection) -> None: + item = await self.logged_against(collection) + if await self.assert_time_log_unsupported(collection, item) or not writable(collection): + return + if self.total_only(collection) or "start-time" in self.lost(collection): + pytest.skip("entries without a start cannot be searched by time") + inside = await item.log_time(start=at(10, 9), end=at(10, 10)) + await item.log_time(start=at(12, 9), end=at(12, 10)) + found = await collection.time_entries(at(10, 0), at(11, 0)) + assert [(uid, e.id) for uid, e in found] == [(item.uid, inside.id)] + # === moving ======================================================= async def test_move(self, backend: AsyncBackend, collection: AsyncCollection) -> None: diff --git a/src/calendaring/_async/items.py b/src/calendaring/_async/items.py index 905cd21..c5d13c4 100644 --- a/src/calendaring/_async/items.py +++ b/src/calendaring/_async/items.py @@ -8,8 +8,8 @@ from __future__ import annotations -from collections.abc import Sequence -from datetime import datetime +from collections.abc import Iterable, Sequence +from datetime import date, datetime, timedelta from typing import TYPE_CHECKING, Any, Literal, NoReturn, Self import icalendar @@ -17,6 +17,7 @@ from ..errors import UnsupportedError from ..items import Event, Item, Journal, Task from ..loss import LossPolicy +from ..timetracking import TimeEntry from ..types import Scope if TYPE_CHECKING: @@ -62,6 +63,34 @@ async def reload(self) -> None: async def relatives(self, reltype: str | None = None) -> list[AsyncItem]: return await self.collection.relatives(self, reltype) + # --- the time log (§3.4) ------------------------------------------- + + async def time_log(self) -> list[TimeEntry]: + return await self.collection.time_log(self) + + async def time_spent(self) -> timedelta: + return await self.collection.time_spent(self) + + async def log_time( + self, + *, + spent: timedelta | None = None, + start: datetime | None = None, + end: datetime | None = None, + day: date | None = None, + users: Iterable[str] = (), + comment: str | None = None, + ) -> TimeEntry: + return await self.collection.log_time( + self, spent=spent, start=start, end=end, day=day, users=users, comment=comment + ) + + async def amend_time(self, entry: TimeEntry, **changes: Any) -> TimeEntry: + return await self.collection.amend_time(self, entry, **changes) + + async def delete_time(self, entry: TimeEntry) -> None: + await self.collection.delete_time(self, entry) + def __reduce_ex__(self, protocol: Any) -> NoReturn: raise TypeError("a bound item carries a live session; pickle item.as_data() instead") @@ -83,6 +112,16 @@ async def complete( async def uncomplete(self, *, loss: LossPolicy | None = None, verify: bool | None = None) -> None: self._take(await self.collection.uncomplete(self, loss=loss, verify=verify)) + async def start_timer(self, at: datetime | None = None) -> None: + self._take(await self.collection.start_timer(self, at)) + + async def stop_timer( + self, at: datetime | None = None, *, users: Iterable[str] = (), comment: str | None = None + ) -> TimeEntry: + task, entry = await self.collection.stop_timer(self, at, users=users, comment=comment) + self._take(task) + return entry + class AsyncEvent(Event, AsyncItem): pass diff --git a/src/calendaring/_caldav_features.py b/src/calendaring/_caldav_features.py index 3d1610d..0e6cb3a 100644 --- a/src/calendaring/_caldav_features.py +++ b/src/calendaring/_caldav_features.py @@ -97,6 +97,7 @@ def _level(features: Any, name: str) -> Support: Feature.TASK_ESTIMATE, Feature.ALARMS, Feature.DATES, + Feature.TIME_LOG, } ) @@ -152,4 +153,6 @@ def collection_capabilities(features: Any, components: frozenset[Component]) -> table[Feature.SEARCH_TIME_RANGE] = _time_range(features) for kind, (feature, name) in COMPONENT_FROM_SERVER.items(): table[feature] = _level(features, name) if kind in components else U + if Component.EVENT not in components: # time entries are VEVENTs in the item's own collection + table[Feature.TIME_LOG] = {"level": U, "note": "holds no events to log time in; set time_log_collection"} return Capabilities({f: Capability.coerce(v) for f, v in table.items()}) diff --git a/src/calendaring/_memory_store.py b/src/calendaring/_memory_store.py index fe0f9cd..9873d2b 100644 --- a/src/calendaring/_memory_store.py +++ b/src/calendaring/_memory_store.py @@ -93,6 +93,7 @@ def level(self, feature: Feature) -> Support: "map": {2: 1, 3: 1, 4: 5, 6: 5, 7: 9, 8: 9}, }, Feature.TASK_PERCENT_COMPLETE: U, + Feature.TIME_LOG: {"level": U, "note": "holds no events to log time in; a tracker's own log is roadmap 2.5"}, Feature.TASK_START: U, Feature.TASK_PLANNED: U, Feature.TASK_ESTIMATE: U, diff --git a/src/calendaring/_sync/collection.py b/src/calendaring/_sync/collection.py index 88f3178..6024b49 100644 --- a/src/calendaring/_sync/collection.py +++ b/src/calendaring/_sync/collection.py @@ -13,10 +13,12 @@ import binascii import copy import json +import uuid from abc import ABC, abstractmethod from collections.abc import Iterator, Generator, Iterable, Mapping, Sequence from contextlib import contextmanager, suppress -from datetime import UTC, datetime, timedelta +from dataclasses import replace +from datetime import UTC, date, datetime, time, timedelta from typing import TYPE_CHECKING, Any, Literal, overload import icalendar @@ -26,6 +28,7 @@ from .._mapping import Change, differences, plan_calendar_losses, plan_losses from ..capabilities import BACKEND_FEATURES, Capabilities, Feature, Support from ..errors import ( + AlreadyExistsError, CalendaringError, ConflictError, InvalidDataError, @@ -35,8 +38,31 @@ UnsupportedError, VerificationError, ) -from ..items import Event, Item, Journal, Task, _items_of, _parse, split_stored, without_occurrence +from ..items import ( + Event, + Item, + Journal, + Task, + _items_of, + _parse, + _set_datetime, + _wrap_component, + split_stored, + without_occurrence, +) from ..loss import RAISE, LossPolicy +from ..timetracking import ( + X_TIME_ENTRY, + X_TIMER_START, + TimeEntry, + apply_entry, + comparable, + entry_from_component, + entry_to_component, + is_entry, + resolve, + running_timer, +) from ..types import BrokenObject, ChangeSet, Component, Scope, StoredObject, SyncToken from .items import BOUND, SyncEvent, SyncItem, SyncJournal, SyncTask @@ -45,6 +71,13 @@ __all__ = ["Collection"] + +def _by_start(entry: TimeEntry) -> tuple[bool, float]: + """Sort key: by start (a floating time read as local); entries without one last, by when they were logged.""" + when = entry.start or entry.logged + return entry.start is None, when.timestamp() if when is not None else 0.0 + + COMPONENT_FEATURE = { Component.EVENT: Feature.COMPONENT_EVENT, Component.TASK: Feature.COMPONENT_TASK, @@ -107,6 +140,10 @@ def __init__( self.components = frozenset(components) self.capabilities = capabilities self.native = native + # Where time entries go and are read from (§3.4): many task lists hold no events. + self.time_log_collection: Collection | None = None + # Where a day of durations starts, with log_time(spent=..., day=...). + self.workday_start = time(8) def __repr__(self) -> str: return f"<{type(self).__name__} {self.backend_id}/{self.id}>" @@ -230,6 +267,49 @@ def _delete_collection(self) -> None: def _wrap_native(self, native_item: object) -> StoredObject: raise UnsupportedError(None, f"{type(self).__name__} cannot wrap {type(native_item).__name__}") + # The time log (§3.4). The defaults store entries as VEVENTs in the + # time-log collection; a backend with a time log of its own (a tracker, + # roadmap 2.5) overrides all five. + + def _time_entries( + self, start: datetime | None, end: datetime | None, *, parent: str | None = None + ) -> list[tuple[str | None, TimeEntry]]: + """``(the UID it was logged against, entry)`` for the entries stored here: in a time range, for a parent.""" + searcher = ( + Searcher(event=True, start=start, end=end) + if start is not None and end is not None + else Searcher(event=True) + ) + searcher.add_property_filter(X_TIME_ENTRY, "TRUE", operator="==") + if parent is not None: + searcher.add_property_filter("RELATED-TO", parent, operator="==") + found = [entry_from_component(i.component) for i in self.search(searcher) if is_entry(i.component)] + return [pair for pair in found if parent is None or pair[0] == parent] + + def _time_log(self, item: Item) -> list[TimeEntry]: + return [entry for _, entry in self._log_target()._time_entries(None, None, parent=item.uid)] + + def _add_time(self, item: Item, entry: TimeEntry) -> TimeEntry: + stored = self._log_target().add(Event(_wrap_component(entry_to_component(entry, parent=item)))) + return entry_from_component(stored.component)[1] + + def _amend_time(self, item: Item, old: TimeEntry, new: TimeEntry) -> TimeEntry: + assert old.id is not None + stored = self._log_target().get(old.id) + component = stored.component # edited in place: what other clients added stays + apply_entry(component, new) + sequence = int(component.pop("SEQUENCE", 0)) + for name, value in (("SEQUENCE", sequence + 1), ("LAST-MODIFIED", datetime.now(UTC))): + component.pop(name, None) + component.add(name, value) + stored.icalendar.add_missing_timezones() + stored.save() + return entry_from_component(stored.component)[1] + + def _delete_time(self, item: Item, entry: TimeEntry) -> None: + assert entry.id is not None + (self._log_target().get(entry.id)).delete() + def _plan_losses(self, component: icalendar.Component) -> list[Change]: """Override for mapping rules beyond the capability table's ``values`` and ``map``.""" return plan_losses(component, self.capabilities) @@ -530,7 +610,7 @@ def relatives(self, item: Item, reltype: str | None = None) -> list[SyncItem]: except PartialResultError as exc: broken.extend(exc.broken) listed = list(exc.results) - for other in listed: + for other in (i for i in listed if not is_entry(i.component)): # a time entry is no subtask for value in self._related_to(other): kind = str(value.params.get("RELTYPE", "PARENT")).upper() if str(value) == item.uid and kind in back and (wanted is None or back[kind] == wanted): @@ -799,6 +879,162 @@ def uncomplete(self, task: Task, *, loss: LossPolicy | None = None, verify: bool assert isinstance(result, SyncTask) return result + # === the time log ================================================== + + def _log_target(self) -> Collection: + return self.time_log_collection or self + + def _require_time_log(self, *, write: bool = False) -> Collection: + target = self._log_target() + target._require(Feature.TIME_LOG) + if write: + target._require(Feature.WRITE) + return target + + def _require_entries(self) -> None: + """Amending or deleting needs entries; a backend that keeps only the total has none.""" + if self._log_target().capabilities.details(Feature.TIME_LOG).get("total-only"): + raise UnsupportedError(Feature.TIME_LOG, "the backend keeps only the total time spent") + + def time_log(self, item: Item, *, now: datetime | None = None) -> list[TimeEntry]: + """The entries logged against ``item``, by start; a running timer last.""" + with self._context(): + self._require_time_log() + entries = sorted(self._time_log(item), key=_by_start) + timer = running_timer(item, now or datetime.now(UTC)) + return entries + ([timer] if timer is not None else []) + + def time_spent(self, item: Item, *, now: datetime | None = None) -> timedelta: + """The sum of the time log in person-time, a running timer up to now.""" + return sum((e.person_time for e in self.time_log(item, now=now)), timedelta(0)) + + def log_time( + self, + item: Item, + *, + spent: timedelta | None = None, + start: datetime | None = None, + end: datetime | None = None, + day: date | None = None, + users: Iterable[str] = (), + comment: str | None = None, + ) -> TimeEntry: + """Log time against ``item``: two of ``spent``, ``start`` and ``end``, or ``spent`` with a ``day``.""" + with self._context(): + self._require_time_log(write=True) + now = datetime.now(UTC) + if day is not None: + if spent is None or start is not None or end is not None: + raise ValueError("log_time(): day= goes with spent= alone") + start = self._day_cursor(day, tuple(users)) + begin, finish = resolve(spent=spent, start=start, end=end, now=now) + entry = TimeEntry(finish - begin, begin, finish, tuple(users), comment, logged=now) + return self._add_time(item, entry) + + def _day_cursor(self, day: date, users: tuple[str, ...]) -> datetime: + """Where the next entry of these users' ``day`` starts: after their last one, else at the workday's start. + + A day that fills up runs on past midnight: the sum is what counts. + """ + midnight = datetime.combine(day, time()).astimezone() + listed = self._log_target()._time_entries(midnight, midnight + timedelta(days=1)) + ends = [ + comparable(e.end) + for _, e in listed + if e.start is not None + and e.end is not None + and comparable(e.start).date() == day + and (set(e.users) & set(users) if users else not e.users) + ] + return max(ends, default=datetime.combine(day, self.workday_start).astimezone()) + + def amend_time(self, item: Item, entry: TimeEntry, **changes: Any) -> TimeEntry: + """Change an entry: ``spent``, ``start``, ``end``, ``users`` or ``comment``; the rest stays.""" + with self._context(): + self._require_time_log(write=True) + self._require_entries() + unknown = set(changes) - {"spent", "start", "end", "users", "comment"} + if unknown: + raise TypeError(f"amend_time(): unknown {sorted(unknown)}") + if entry.id is None: + raise ValueError("amend_time(): the entry was never stored") + spent = changes.get("spent") + start, end = changes.get("start"), changes.get("end") + if spent is None: # the times not given stay + start = start if "start" in changes else entry.start + end = end if "end" in changes else entry.end + elif "start" not in changes and "end" not in changes: # spent alone keeps the start + start = entry.start + if start is None and end is None: # an entry without times stays without: never invent them + duration, begin, finish = spent or entry.duration, None, None + else: + begin, finish = resolve(spent=spent, start=start, end=end, now=datetime.now(UTC)) + duration = finish - begin + new = replace( + entry, + duration=duration, + start=begin, + end=finish, + users=tuple(changes.get("users", entry.users)), + comment=changes.get("comment", entry.comment), + ) + return self._amend_time(item, entry, new) + + def delete_time(self, item: Item, entry: TimeEntry) -> None: + with self._context(): + self._require_time_log(write=True) + self._require_entries() + if entry.id is None: + raise ValueError("delete_time(): the entry was never stored") + self._delete_time(item, entry) + + def time_entries(self, start: datetime, end: datetime) -> list[tuple[str, TimeEntry]]: + """``(item UID, entry)`` for every entry in the time range, by start: what ``sum_hours`` sums.""" + with self._context(): + target = self._require_time_log() + found = [(uid, e) for uid, e in target._time_entries(start, end) if uid is not None] + return sorted(found, key=lambda pair: _by_start(pair[1])) + + def start_timer(self, task: Task, at: datetime | None = None) -> SyncTask: + """Start the timer on ``task``: ``X-PYCAL-TIMER-START`` until it is stopped.""" + with self._context(): + self._require_writable(task) + self._require_time_log() + if not isinstance(task, Task): + raise TypeError("start_timer(): the timer is for tasks") + if X_TIMER_START in task.component: + raise ValueError("start_timer(): a timer is already running on this task") + data = task.as_data() + _set_datetime(data.component, X_TIMER_START, (at or datetime.now(UTC)).astimezone(UTC)) + result = self.save(data) + assert isinstance(result, SyncTask) + return result + + def stop_timer( + self, task: Task, at: datetime | None = None, *, users: Iterable[str] = (), comment: str | None = None + ) -> tuple[SyncTask, TimeEntry]: + """Stop the timer: log the entry, then take the timer off the task.""" + with self._context(): + self._require_writable(task) + self._require_time_log(write=True) + now = datetime.now(UTC) + timer = running_timer(task, now) + if timer is None or timer.start is None: + raise ValueError("stop_timer(): no timer is running on this task") + begin, finish = resolve(spent=None, start=timer.start, end=at or now, now=now) + # Named after the task and the timer's start, so stopping it again after a failed save logs it once. + uid = str(uuid.uuid5(uuid.NAMESPACE_URL, f"{task.uid}#timer-{begin.isoformat()}")) + entry = TimeEntry(finish - begin, begin, finish, tuple(users), comment, logged=now, id=uid) + try: + entry = self._add_time(task, entry) + except AlreadyExistsError: + pass # logged by an earlier stop_timer() that failed afterwards + data = task.as_data() + data.component.pop(X_TIMER_START, None) + result = self.save(data) + assert isinstance(result, SyncTask) + return result, entry + @overload def move( self, item: Task, target: Collection, *, loss: LossPolicy | None = None, verify: bool | None = None diff --git a/src/calendaring/_sync/conformance.py b/src/calendaring/_sync/conformance.py index 693bbbc..7d95349 100644 --- a/src/calendaring/_sync/conformance.py +++ b/src/calendaring/_sync/conformance.py @@ -56,6 +56,7 @@ async def open_backend(self) -> AsyncBackend: ) from ..items import Event, Item, Journal, Task, TaskStatus from ..loss import ALLOW, WARN, Loss +from ..timetracking import is_entry from ..types import Component, Scope, StoredObject, SyncToken from .backend import Backend from .collection import Collection @@ -64,8 +65,11 @@ async def open_backend(self) -> AsyncBackend: __all__ = ["SyncConformance"] -def at(day: int, hour: int = 12) -> datetime: - return datetime(2026, 11, day, hour, tzinfo=UTC) +def at(day: int, hour: int = 12, minute: int = 0) -> datetime: + return datetime(2026, 11, day, hour, minute, tzinfo=UTC) + + +HOUR = timedelta(hours=1) def key(item: Item) -> tuple[str, str | None]: @@ -142,7 +146,8 @@ def minimal(kind: Component, summary: str, **properties: Any) -> Item: Feature.TASK_DUE: ("DUE", at(3), {}), Feature.TASK_COMPLETED: ("COMPLETED", at(2), {}), Feature.TASK_PLANNED: ("X-PYCAL-PLANNED-START", icalendar.vDDDTypes(at(2)), {}), - Feature.TASK_ESTIMATE: ("ESTIMATED-DURATION", timedelta(hours=2), {}), + # A bare timedelta would be written "2:00:00", which is not iCalendar. + Feature.TASK_ESTIMATE: ("ESTIMATED-DURATION", icalendar.vDuration(timedelta(hours=2)), {}), Feature.TASK_RELATIONS_PARENT: ("RELATED-TO", "conformance-parent", {"RELTYPE": "PARENT"}), Feature.TASK_RELATIONS_DEPENDS_ON: ("RELATED-TO", "conformance-dependency", {"RELTYPE": "DEPENDS-ON"}), } @@ -1123,6 +1128,167 @@ def test_a_token_from_another_collection_raises( with pytest.raises(SyncTokenError): collection.changes(token) + # === time tracking ================================================ + + def logged_against(self, collection: Collection) -> SyncItem: + """A stored task (an event where there are no tasks) to log time against, or the test ends here.""" + kind = Component.TASK if holds(collection, Component.TASK) else Component.EVENT + if not holds(collection, kind): + pytest.skip("the collection holds neither tasks nor events") + [item] = self.seed(collection, [KINDS[kind][0].new(summary="logged against", start=at(1, 8))]) + return item + + def assert_time_log_unsupported(self, collection: Collection, item: SyncItem) -> bool: + """True if the time log is unsupported here, after checking that logging raises before any I/O.""" + if level(collection, Feature.TIME_LOG) is not Support.UNSUPPORTED: + return False + self.assert_unsupported(collection, Feature.TIME_LOG, lambda: item.log_time(spent=HOUR)) + self.assert_unsupported(collection, Feature.TIME_LOG, lambda: item.time_log()) + return True + + def lost(self, collection: Collection) -> set[str]: + return set(collection.capabilities.details(Feature.TIME_LOG).get("lost", ())) + + def total_only(self, collection: Collection) -> bool: + return bool(collection.capabilities.details(Feature.TIME_LOG).get("total-only")) + + def test_time_log_round_trips_as_declared(self, collection: Collection) -> None: + item = self.logged_against(collection) + if self.assert_time_log_unsupported(collection, item): + return + assert item.time_log() == [] + assert item.time_spent() == timedelta(0) + if not writable(collection): + self.assert_unsupported(collection, Feature.WRITE, lambda: item.log_time(spent=HOUR)) + return + users = ("mailto:alice@example.com", "mailto:bob@example.com") + logged = item.log_time(start=at(3, 9), end=at(3, 11), users=users, comment="first draft\nwith figures") + assert logged.duration == 2 * HOUR + assert not any(is_entry(r.component) for r in item.relatives()), "a time entry is no subtask" + [read] = item.time_log() + if self.total_only(collection): + assert item.time_spent() == 4 * HOUR, "two people for two hours, kept as a total" + assert read.start is None, "a total-only backend has no entries to give a start" + return + assert read.duration == 2 * HOUR + if "users" not in self.lost(collection): + assert item.time_spent() == 4 * HOUR, "person-time: two people for two hours" + lost = self.lost(collection) + expected: dict[str, tuple[object, object]] = { + "start-time": (read.start, at(3, 9)), + "end": (read.end, at(3, 11)), + } + expected |= {"users": (read.users, users), "comment": (read.comment, "first draft\nwith figures")} + for name, (got, sent) in expected.items(): + if name in lost: + assert got in (None, ()), f"{name} is declared lost, yet read back as {got!r}: never invent it" + else: + assert got == sent, f"{name}: sent {sent!r}, read back {got!r}" + assert read.id is not None + + def test_log_time_takes_two_of_three(self, collection: Collection) -> None: + item = self.logged_against(collection) + if self.assert_time_log_unsupported(collection, item) or not writable(collection): + return + before = snapshot(collection) + with pytest.raises(ValueError, match="disagree"): + item.log_time(spent=3 * HOUR, start=at(3, 9), end=at(3, 11)) + with pytest.raises(ValueError, match="two of"): + item.log_time(start=at(3, 9)) + assert snapshot(collection) == before, "a refused entry wrote something" + by_start = item.log_time(spent=HOUR, start=at(3, 9)) + by_end = item.log_time(spent=HOUR, end=at(4, 11)) + recent = item.log_time(spent=HOUR) + assert [e.duration for e in (by_start, by_end, recent)] == [HOUR] * 3 + assert item.time_spent() == 3 * HOUR + if not self.total_only(collection) and "start-time" not in self.lost(collection): + assert (by_start.start, by_start.end) == (at(3, 9), at(3, 10)) + assert (by_end.start, by_end.end) == (at(4, 10), at(4, 11)) + + def test_a_day_of_durations_lines_up(self, collection: Collection) -> None: + item = self.logged_against(collection) + if self.assert_time_log_unsupported(collection, item) or not writable(collection): + return + tuesday = at(3).date() + first = item.log_time(spent=3 * HOUR, day=tuesday) + second = item.log_time(spent=2 * HOUR, day=tuesday) + assert item.time_spent() == 5 * HOUR + if self.total_only(collection) or "start-time" in self.lost(collection): + return + assert first.start is not None + assert first.start.date() == tuesday + assert second.start == first.end, "the day's entries line up back to back" + + def test_the_timer(self, collection: Collection) -> None: + if not holds(collection, Component.TASK): + pytest.skip("the timer is for tasks, and the collection holds none") + [task] = self.seed(collection, [Task.new(summary="timed")]) + assert isinstance(task, SyncTask) + if level(collection, Feature.TIME_LOG) is Support.UNSUPPORTED: + self.assert_unsupported(collection, Feature.TIME_LOG, lambda: task.start_timer()) + return + if not writable(collection): + self.assert_unsupported(collection, Feature.WRITE, lambda: task.start_timer()) + return + task.start_timer(at=at(3, 9)) + with pytest.raises(ValueError, match="running"): + task.start_timer() + reread = collection.reload(task) + assert isinstance(reread, SyncTask) + [running] = reread.time_log() + assert running.running + assert running.start == at(3, 9) + entry = task.stop_timer(at=at(3, 10, 30), comment="done for now") + assert entry.duration == timedelta(hours=1, minutes=30) + with pytest.raises(ValueError, match="running"): + task.stop_timer() + [stored] = (collection.reload(task)).time_log() + assert not stored.running + assert stored.duration == timedelta(hours=1, minutes=30) + assert "X-PYCAL-TIMER-START" not in (collection.reload(task)).component, "the timer is temporary" + + def test_amend_and_delete_time(self, collection: Collection) -> None: + item = self.logged_against(collection) + if self.assert_time_log_unsupported(collection, item) or not writable(collection): + return + entry = item.log_time(start=at(3, 9), end=at(3, 10), comment="first") + if self.total_only(collection): + [read] = item.time_log() + self.assert_unsupported(collection, Feature.TIME_LOG, lambda: item.amend_time(read, comment="x")) + self.assert_unsupported(collection, Feature.TIME_LOG, lambda: item.delete_time(read)) + return + amended = item.amend_time(entry, spent=2 * HOUR) + assert amended.duration == 2 * HOUR + assert amended.id == entry.id + [read] = item.time_log() + assert read.duration == 2 * HOUR + if "comment" not in self.lost(collection): + assert read.comment == "first", "amending one field kept the others" + item.delete_time(read) + assert item.time_log() == [] + + def test_time_spent_in_a_meeting(self, collection: Collection) -> None: + if not holds(collection, Component.EVENT): + pytest.skip("the collection holds no events") + [meeting] = self.seed(collection, [Event.new(summary="planning", start=at(5, 9), end=at(5, 10))]) + if self.assert_time_log_unsupported(collection, meeting) or not writable(collection): + return + meeting.log_time(start=at(5, 9), end=at(5, 10), users=("mailto:alice@example.com",)) + [entry] = meeting.time_log() + assert entry.duration == HOUR + assert collection.reload(meeting) == meeting, "logging time does not touch the meeting itself" + + def test_time_entries_in_a_range(self, collection: Collection) -> None: + item = self.logged_against(collection) + if self.assert_time_log_unsupported(collection, item) or not writable(collection): + return + if self.total_only(collection) or "start-time" in self.lost(collection): + pytest.skip("entries without a start cannot be searched by time") + inside = item.log_time(start=at(10, 9), end=at(10, 10)) + item.log_time(start=at(12, 9), end=at(12, 10)) + found = collection.time_entries(at(10, 0), at(11, 0)) + assert [(uid, e.id) for uid, e in found] == [(item.uid, inside.id)] + # === moving ======================================================= def test_move(self, backend: Backend, collection: Collection) -> None: diff --git a/src/calendaring/_sync/items.py b/src/calendaring/_sync/items.py index f6b64fa..cee21cf 100644 --- a/src/calendaring/_sync/items.py +++ b/src/calendaring/_sync/items.py @@ -8,8 +8,8 @@ from __future__ import annotations -from collections.abc import Sequence -from datetime import datetime +from collections.abc import Iterable, Sequence +from datetime import date, datetime, timedelta from typing import TYPE_CHECKING, Any, Literal, NoReturn, Self import icalendar @@ -17,6 +17,7 @@ from ..errors import UnsupportedError from ..items import Event, Item, Journal, Task from ..loss import LossPolicy +from ..timetracking import TimeEntry from ..types import Scope if TYPE_CHECKING: @@ -62,6 +63,34 @@ def reload(self) -> None: def relatives(self, reltype: str | None = None) -> list[SyncItem]: return self.collection.relatives(self, reltype) + # --- the time log (§3.4) ------------------------------------------- + + def time_log(self) -> list[TimeEntry]: + return self.collection.time_log(self) + + def time_spent(self) -> timedelta: + return self.collection.time_spent(self) + + def log_time( + self, + *, + spent: timedelta | None = None, + start: datetime | None = None, + end: datetime | None = None, + day: date | None = None, + users: Iterable[str] = (), + comment: str | None = None, + ) -> TimeEntry: + return self.collection.log_time( + self, spent=spent, start=start, end=end, day=day, users=users, comment=comment + ) + + def amend_time(self, entry: TimeEntry, **changes: Any) -> TimeEntry: + return self.collection.amend_time(self, entry, **changes) + + def delete_time(self, entry: TimeEntry) -> None: + self.collection.delete_time(self, entry) + def __reduce_ex__(self, protocol: Any) -> NoReturn: raise TypeError("a bound item carries a live session; pickle item.as_data() instead") @@ -83,6 +112,16 @@ def complete( def uncomplete(self, *, loss: LossPolicy | None = None, verify: bool | None = None) -> None: self._take(self.collection.uncomplete(self, loss=loss, verify=verify)) + def start_timer(self, at: datetime | None = None) -> None: + self._take(self.collection.start_timer(self, at)) + + def stop_timer( + self, at: datetime | None = None, *, users: Iterable[str] = (), comment: str | None = None + ) -> TimeEntry: + task, entry = self.collection.stop_timer(self, at, users=users, comment=comment) + self._take(task) + return entry + class SyncEvent(Event, SyncItem): pass diff --git a/src/calendaring/capabilities.py b/src/calendaring/capabilities.py index 72de38f..5856a78 100644 --- a/src/calendaring/capabilities.py +++ b/src/calendaring/capabilities.py @@ -60,6 +60,7 @@ class Feature(StrEnum): ALARMS = "alarms" TIMEZONES = "timezones" DATES = "dates" + TIME_LOG = "time-log" # Declared on the backend rather than on a collection (§5.2). diff --git a/src/calendaring/timetracking.py b/src/calendaring/timetracking.py new file mode 100644 index 0000000..314b590 --- /dev/null +++ b/src/calendaring/timetracking.py @@ -0,0 +1,234 @@ +"""The time log, mode-free: entries, their iCalendar form, and aggregation (API_DESIGN.md §3.4, roadmap 1.6). + +Time spent is an append-only log of entries; the total is derived. In +iCalendar an entry is a ``VEVENT`` marked ``X-PYCAL-TIME-ENTRY``, pointing +at the task or event the time was spent on with ``RELATED-TO``, and +naming who did the work with RFC 9073 ``PARTICIPANT`` components, never +with ``ATTENDEE`` (which would make a server send invitations). The I/O +methods are on the collections and bound items; this module does no I/O. +""" + +from __future__ import annotations + +import uuid +from collections.abc import Callable, Hashable, Iterable, Sequence +from dataclasses import dataclass, field +from datetime import UTC, datetime, timedelta +from zoneinfo import ZoneInfo + +import icalendar + +from .items import Item, Task, _decoded_datetime + +__all__ = [ + "X_TIMER_START", + "X_TIME_ENTRY", + "TimeEntry", + "TimeSummary", + "entry_from_component", + "apply_entry", + "comparable", + "entry_to_component", + "is_entry", + "resolve", + "running_timer", + "summarize", +] + +X_TIME_ENTRY = "X-PYCAL-TIME-ENTRY" +X_TIMER_START = "X-PYCAL-TIMER-START" + +_ZERO = timedelta(0) + + +@dataclass(frozen=True) +class TimeEntry: + """Time spent on one task or event, by the ``users`` named (each spent ``duration``).""" + + duration: timedelta + start: datetime | None = None + end: datetime | None = None # None, with start set: the running timer + users: tuple[str, ...] = () # calendar addresses + comment: str | None = None + logged: datetime | None = None # when it was recorded (CREATED) + id: str | None = None # the entry's UID, or the backend's id for it + + @property + def running(self) -> bool: + return self.start is not None and self.end is None + + @property + def person_time(self) -> timedelta: + """The duration once per person named (at least once).""" + return self.duration * max(1, len(self.users)) + + +def running_timer(item: Item, now: datetime) -> TimeEntry | None: + """The running timer on a task, as an entry without an end; ``None`` when none runs.""" + if not isinstance(item, Task) or X_TIMER_START not in item.component: + return None + start = _decoded_datetime(item.component, X_TIMER_START) + return None if start is None else TimeEntry(duration=max(now - start, _ZERO), start=start) + + +def resolve( + *, spent: timedelta | None, start: datetime | None, end: datetime | None, now: datetime +) -> tuple[datetime, datetime]: + """``(start, end)`` from two of ``spent``, ``start`` and ``end``; ``spent`` alone ends now.""" + if start is not None and end is not None: + if (start.tzinfo is None) != (end.tzinfo is None): + raise ValueError("one time is floating and the other has a time zone; give both the same kind") + if end < start: + raise ValueError(f"the entry ends ({end}) before it starts ({start})") + if spent is not None and spent != end - start: + raise ValueError(f"spent ({spent}), start and end disagree ({end - start})") + return start, end + if spent is None: + raise ValueError("give two of spent, start and end (or spent alone)") + if start is not None: + return start, start + spent + end = end if end is not None else now + return end - spent, end + + +def is_entry(component: icalendar.Component) -> bool: + return component.name == "VEVENT" and str(component.get(X_TIME_ENTRY, "")).upper() == "TRUE" + + +def _storable(moment: datetime) -> datetime: + """A time as it can be written: a zone with no IANA name (CEST from ``astimezone()``) becomes UTC.""" + if moment.tzinfo is None or isinstance(moment.tzinfo, ZoneInfo): + return moment + return moment.astimezone(UTC) + + +def entry_to_component(entry: TimeEntry, *, parent: Item, uid: str | None = None) -> icalendar.Event: + """The ``VEVENT`` that stores ``entry``, logged against ``parent``.""" + event = icalendar.Event() + event.add("UID", uid or entry.id or str(uuid.uuid4())) + event.add("DTSTAMP", datetime.now(UTC)) + event.add("CREATED", entry.logged or datetime.now(UTC)) + if "SUMMARY" in parent.component: + event.add("SUMMARY", str(parent.component["SUMMARY"])) + event.add("RELATED-TO", parent.uid, parameters={"RELTYPE": "PARENT"}) + event.add(X_TIME_ENTRY, "TRUE") + apply_entry(event, entry) + return event + + +def apply_entry(event: icalendar.Component, entry: TimeEntry) -> None: + """Write ``entry``'s times, comment and people into a stored entry, leaving its other properties.""" + if not isinstance(entry.start, datetime) or not isinstance(entry.end, datetime): + raise TypeError("an entry is stored with a start and an end time (RFC 5545 has no hours on a date)") + for name in ("DTSTART", "DTEND", "DURATION", "DESCRIPTION"): + event.pop(name, None) + event.add("DTSTART", _storable(entry.start)) + event.add("DTEND", _storable(entry.end)) + if entry.comment is not None: + event.add("DESCRIPTION", entry.comment) + event.subcomponents[:] = [c for c in event.subcomponents if c.name != "PARTICIPANT"] + for user in entry.users: + participant = icalendar.cal.Component() + participant.name = "PARTICIPANT" # type: ignore[misc] # RFC 9073; icalendar 7.3 has no class for it + participant.add("UID", str(uuid.uuid4())) + participant.add("PARTICIPANT-TYPE", "ACTIVE") + participant.add("CALENDAR-ADDRESS", icalendar.vCalAddress(user)) + event.add_component(participant) + + +def entry_from_component(component: icalendar.Component) -> tuple[str | None, TimeEntry]: + """``(the UID it was logged against, the entry)`` from a stored entry ``VEVENT``.""" + start = component.decoded("DTSTART", None) + end = component.decoded("DTEND", None) + duration = component.decoded("DURATION", None) + if not isinstance(start, datetime): # an entry on a date has no time to start at + start = end = None + if end is None and start is not None and duration is not None: + end = start + duration + related = component.get("RELATED-TO") + related = related if isinstance(related, list) else [] if related is None else [related] + parents = [str(r) for r in related if str(r.params.get("RELTYPE", "PARENT")).upper() == "PARENT"] + users = tuple( + str(p["CALENDAR-ADDRESS"]) + for p in component.subcomponents + if p.name == "PARTICIPANT" and "CALENDAR-ADDRESS" in p + ) + entry = TimeEntry( + duration=end - start if start is not None and end is not None else duration or _ZERO, + start=start, + end=end, + users=users, + comment=str(component["DESCRIPTION"]) if "DESCRIPTION" in component else None, + logged=component.decoded("CREATED", None), + id=str(component["UID"]) if "UID" in component else None, + ) + return (parents[0] if parents else None), entry + + +@dataclass(frozen=True) +class TimeSummary: + """Committed time (the estimates of open tasks) and spent time, kept apart.""" + + estimate: timedelta = field(default=_ZERO) + spent: timedelta = field(default=_ZERO) + + def __add__(self, other: TimeSummary) -> TimeSummary: + return TimeSummary(self.estimate + other.estimate, self.spent + other.spent) + + +def _open(item: Item) -> bool: + return isinstance(item, Task) and str(item.component.get("STATUS", "NEEDS-ACTION")) not in ( + "COMPLETED", + "CANCELLED", + ) + + +def _estimate(item: Item) -> timedelta: + value = item.component.get("ESTIMATED-DURATION") # a property icalendar 7.3 does not type + if value is None: + return _ZERO + parsed = value.dt if hasattr(value, "dt") else icalendar.vDuration.from_ical(str(value)) # type: ignore[no-untyped-call] + return parsed if isinstance(parsed, timedelta) else _ZERO + + +def comparable(moment: datetime) -> datetime: + """A time that compares with any other: a floating one read as local time (as the searcher does, §4).""" + return moment.astimezone() if moment.tzinfo is None else moment + + +def _spent(entry: TimeEntry, window: tuple[datetime, datetime] | None) -> timedelta: + """The person-time of an entry, the part inside ``window`` when one is given.""" + if window is None: + return entry.person_time + low, high = comparable(window[0]), comparable(window[1]) + if entry.start is None or entry.end is None: + inside = entry.logged is not None and low <= comparable(entry.logged) < high + return entry.person_time if inside else _ZERO + start, end = comparable(entry.start), comparable(entry.end) + if end <= start: + return entry.person_time if low <= start < high else _ZERO + overlap = min(end, high) - max(start, low) + if overlap <= _ZERO: + return _ZERO + return entry.person_time * (overlap / (end - start)) + + +def summarize( + pairs: Iterable[tuple[Item, Sequence[TimeEntry]]], + *, + group_by: Callable[[Item], Hashable], + window: tuple[datetime, datetime] | None = None, +) -> dict[Hashable, TimeSummary]: + """Per key of ``group_by``: the estimates of the open tasks, and the time logged (inside ``window``). + + An entry that names several people counts once per person. One that + straddles an edge of the window is prorated; one with no start counts + by when it was logged. + """ + out: dict[Hashable, TimeSummary] = {} + for item, entries in pairs: + estimate = _estimate(item) if _open(item) else _ZERO + spent = sum((_spent(e, window) for e in entries), _ZERO) + key = group_by(item) + out[key] = out.get(key, TimeSummary()) + TimeSummary(estimate, spent) + return out diff --git a/tests/_async/test_generic.py b/tests/_async/test_generic.py index 030fb9a..7693ad0 100644 --- a/tests/_async/test_generic.py +++ b/tests/_async/test_generic.py @@ -3,8 +3,12 @@ from __future__ import annotations -from datetime import UTC, datetime, timedelta, timezone +import time as clock +from collections.abc import Iterator +from datetime import UTC, date, datetime, timedelta, timezone +from typing import ClassVar +import icalendar import pytest from icalendar_searcher import Searcher @@ -399,3 +403,131 @@ async def test_get_on_overrides_without_a_master_returns_the_earliest() -> None: found = await collection.get("invited") assert found.is_occurrence assert str(found.component["SUMMARY"]) == "earlier" + + +# --- the time log (§3.4), beyond the conformance suite ------------------- + +HOUR = timedelta(hours=1) + + +@pytest.fixture +def oslo(monkeypatch: pytest.MonkeyPatch) -> Iterator[None]: + """Local time in Oslo, where summer time is CEST: not an IANA name.""" + monkeypatch.setenv("TZ", "Europe/Oslo") + clock.tzset() + yield + monkeypatch.undo() + clock.tzset() + + +async def test_a_day_of_durations_is_valid_icalendar_in_any_time_zone(oslo: None) -> None: + collection = await AsyncMemoryBackend().collection("default") + task = await collection.add_task(summary="x") + summer = date(2026, 7, 3) + first = await task.log_time(spent=3 * HOUR, day=summer) + second = await task.log_time(spent=2 * HOUR, day=summer) + assert first.start is not None + assert first.start.utcoffset() is not None, "an entry's times are never floating" + assert first.start == datetime(2026, 7, 3, 6, tzinfo=UTC), "08:00 in Oslo, summer time" + assert second.start == first.end + for _, entry in await collection.time_entries(datetime(2026, 7, 3, tzinfo=UTC), datetime(2026, 7, 4, tzinfo=UTC)): + assert entry.start is not None + assert entry.start.tzinfo is not None + + +async def test_a_day_lines_up_per_person_and_may_run_past_midnight() -> None: + collection = await AsyncMemoryBackend().collection("default") + task = await collection.add_task(summary="x") + day = date(2026, 11, 3) + alice, bob = ("mailto:alice@example.com",), ("mailto:bob@example.com",) + a1 = await task.log_time(spent=9 * HOUR, day=day, users=alice) + b1 = await task.log_time(spent=HOUR, day=day, users=bob) + assert b1.start == a1.start, "bob's day is not pushed by alice's" + late = await task.log_time(spent=10 * HOUR, day=day, users=alice) + assert late.start == a1.end + assert late.end is not None + assert late.end.date() > day, "a long day keeps its sum, past midnight" + + +async def test_time_entries_are_not_subtasks() -> None: + collection = await AsyncMemoryBackend().collection("default") + task = await collection.add_task(summary="parent") + child = await collection.add_task(summary="child", related_to=task.uid) + await task.log_time(spent=HOUR) + assert [r.uid for r in await task.relatives("CHILD")] == [child.uid] + + +async def test_a_separate_time_log_collection() -> None: + backend = AsyncMemoryBackend() + tasks = await backend.create_collection("tasks", components=[Component.TASK]) + log = await backend.create_collection("time log", components=[Component.EVENT]) + tasks.time_log_collection = log + task = await tasks.add_task(summary="x") + entry = await task.log_time(start=datetime(2026, 11, 3, 9, tzinfo=UTC), end=datetime(2026, 11, 3, 10, tzinfo=UTC)) + assert [e.id for e in await task.time_log()] == [entry.id] + assert [i.uid for i in await log.search()] == [entry.id] + assert await tasks.events() == [] + tasks.time_log_collection = None + with pytest.raises(UnsupportedError): + await task.log_time(spent=HOUR) # the task list itself holds no events + + +async def test_amend_time_edits_the_entry_in_place() -> None: + collection = await AsyncMemoryBackend().collection("default") + task = await collection.add_task(summary="before") + entry = await task.log_time(start=datetime(2026, 11, 3, 9, tzinfo=UTC), end=datetime(2026, 11, 3, 10, tzinfo=UTC)) + assert entry.id is not None + stored = await collection.get(entry.id) + stored.component.add("CATEGORIES", ["billable"]) + stored.component.add("X-OTHER-CLIENT", "kept") + await stored.save() + task.component["SUMMARY"] = "after" + await task.save() + amended = await task.amend_time(entry, spent=2 * HOUR) + again = await collection.get(entry.id) + assert b"CATEGORIES:billable" in again.to_ical() + assert str(again.component["X-OTHER-CLIENT"]) == "kept" + assert str(again.component["SUMMARY"]) == "before", "the summary is the task's when it was logged" + assert int(again.component.get("SEQUENCE", 0)) >= 1 + assert (amended.start, amended.end) == (entry.start, datetime(2026, 11, 3, 11, tzinfo=UTC)) + shortened = await task.amend_time(amended, spent=HOUR, end=datetime(2026, 11, 3, 11, tzinfo=UTC)) + assert shortened.start == datetime(2026, 11, 3, 10, tzinfo=UTC), "spent with end keeps the end" + + +async def test_naive_and_aware_times_do_not_mix() -> None: + collection = await AsyncMemoryBackend().collection("default") + task = await collection.add_task(summary="x") + with pytest.raises(ValueError, match="floating"): + await task.log_time(start=datetime(2026, 11, 3, 9, tzinfo=UTC), end=datetime(2026, 11, 3, 10)) + await task.start_timer(at=datetime(2026, 11, 3, 9, tzinfo=UTC)) + with pytest.raises(ValueError, match="floating"): + await task.stop_timer(at=datetime(2026, 11, 3, 10)) + + +class FailsTheNextSave(AsyncMemoryCollection): + """The next update fails, as on a network error or another device's edit.""" + + fail: ClassVar[bool] = False + + async def _update( + self, native_id: str, calendar: icalendar.Calendar, *, etag: str | None, wire: bytes | None + ) -> StoredObject: + if FailsTheNextSave.fail: + FailsTheNextSave.fail = False + raise ConflictError("another device saved it meanwhile") + return await super()._update(native_id, calendar, etag=etag, wire=wire) + + +async def test_stopping_a_timer_again_logs_it_once() -> None: + class Backend(AsyncMemoryBackend): + collection_class = FailsTheNextSave + + collection = await Backend().collection("default") + task = await collection.add_task(summary="x") + await task.start_timer(at=datetime(2026, 11, 3, 9, tzinfo=UTC)) + FailsTheNextSave.fail = True + with pytest.raises(ConflictError): + await task.stop_timer(at=datetime(2026, 11, 3, 10, tzinfo=UTC)) + await task.stop_timer(at=datetime(2026, 11, 3, 10, tzinfo=UTC)) + [entry] = await task.time_log() + assert entry.duration == HOUR diff --git a/tests/_async/test_memory_conformance.py b/tests/_async/test_memory_conformance.py index 46dbedc..a7d3af8 100644 --- a/tests/_async/test_memory_conformance.py +++ b/tests/_async/test_memory_conformance.py @@ -19,7 +19,8 @@ import copy import inspect from collections.abc import AsyncIterator, Mapping, Sequence -from datetime import UTC, date, datetime, time +from dataclasses import replace +from datetime import UTC, date, datetime, time, timedelta from typing import Any, ClassVar import icalendar @@ -57,6 +58,7 @@ Profile, ) from calendaring.testing import AsyncConformance +from calendaring.timetracking import TimeEntry from calendaring.types import StoredObject @@ -173,6 +175,88 @@ async def open_backend(self) -> AsyncBackend: return AsyncMemoryBackend(profile=TASK_FEED) +class OwnTimeLog(AsyncMemoryCollection): + """A tracker's own time log, as Request Tracker keeps it: who, how long and a comment, not when.""" + + def _log(self) -> list[tuple[str, TimeEntry]]: + if not hasattr(self.store, "time_log"): + self.store.time_log = [] # type: ignore[attr-defined] # kept with the calendar, as the tracker would + return self.store.time_log # type: ignore[attr-defined, no-any-return] + + async def _time_entries( + self, start: datetime | None, end: datetime | None, *, parent: str | None = None + ) -> list[tuple[str | None, TimeEntry]]: + return [ + (uid, e) for uid, e in self._log() if start is None or end is None or (e.logged and start <= e.logged < end) + ] + + async def _time_log(self, item: Item) -> list[TimeEntry]: + return [e for uid, e in self._log() if uid == item.uid] + + async def _add_time(self, item: Item, entry: TimeEntry) -> TimeEntry: + kept = replace(entry, start=None, end=None, id=f"log-{len(self._log()) + 1}") + self._log().append((item.uid, kept)) + return kept + + async def _amend_time(self, item: Item, old: TimeEntry, new: TimeEntry) -> TimeEntry: + kept = replace(new, start=None, end=None) + self._log()[:] = [(uid, kept if e.id == old.id else e) for uid, e in self._log()] + return kept + + async def _delete_time(self, item: Item, entry: TimeEntry) -> None: + self._log()[:] = [(uid, e) for uid, e in self._log() if e.id != entry.id] + + +class TotalOnly(AsyncMemoryCollection): + """A tracker that keeps only the total time spent on a task.""" + + def _totals(self) -> dict[str, timedelta]: + if not hasattr(self.store, "totals"): + self.store.totals = {} # type: ignore[attr-defined] # kept with the calendar, as the tracker would + return self.store.totals # type: ignore[attr-defined, no-any-return] + + async def _time_entries( + self, start: datetime | None, end: datetime | None, *, parent: str | None = None + ) -> list[tuple[str | None, TimeEntry]]: + return [(uid, TimeEntry(total)) for uid, total in self._totals().items()] + + async def _time_log(self, item: Item) -> list[TimeEntry]: + total = self._totals().get(item.uid) + return [] if total is None else [TimeEntry(total)] + + async def _add_time(self, item: Item, entry: TimeEntry) -> TimeEntry: + self._totals()[item.uid] = self._totals().get(item.uid, timedelta(0)) + entry.person_time + return TimeEntry(entry.duration) + + +def time_log_profile(name: str, time_log: dict[str, Any]) -> Profile: + return Profile(name, {**TRACKER.capabilities, Feature.TIME_LOG: time_log}, TRACKER.components) + + +OWN_TIME_LOG = time_log_profile("own-time-log", {"level": Support.LOSSY, "lost": ["start-time", "end"]}) +TOTAL_ONLY = time_log_profile("total-only", {"level": Support.LOSSY, "total-only": True}) + + +class TestOwnTimeLogProfile(AsyncConformance): + """A tracker whose time log drops when the work was done.""" + + async def open_backend(self) -> AsyncBackend: + class Backend(AsyncMemoryBackend): + collection_class = OwnTimeLog + + return Backend(profile=OWN_TIME_LOG) + + +class TestTotalOnlyProfile(AsyncConformance): + """A tracker that keeps only a total.""" + + async def open_backend(self) -> AsyncBackend: + class Backend(AsyncMemoryBackend): + collection_class = TotalOnly + + return Backend(profile=TOTAL_ONLY) + + def sharing(profile: Profile, store: MemoryStore | None = None) -> AsyncMemoryBackend: """A backend on ``store``, like a second connection to a server that keeps its data.""" backend = AsyncMemoryBackend(profile=profile) @@ -460,6 +544,22 @@ async def _complete_recurring( return self.bind(task) +class InventsStartTimes(OwnTimeLog): + """Declares the start time lost, then makes one up on reading.""" + + async def _time_log(self, item: Item) -> list[TimeEntry]: + return [replace(e, start=e.logged) for e in await super()._time_log(item)] + + +class IgnoresTheRange(AsyncMemoryCollection): + """Answers a time-range search for entries with every entry.""" + + async def _time_entries( + self, start: datetime | None, end: datetime | None, *, parent: str | None = None + ) -> list[tuple[str | None, TimeEntry]]: + return await super()._time_entries(None, None, parent=parent) + + # What the suite raises when it catches a defect: its own assertion, or a # pytest.raises that did not raise. The round trip catches through verify=True. ASSERTS = (AssertionError, pytest.fail.Exception) @@ -494,6 +594,8 @@ async def _complete_recurring( (StartsInRange, "test_search_gives_the_client_side_answer", {"name": "events-in-range"}, ASSERTS), (StartsInRange, "test_search_gives_the_client_side_answer", {"name": "tasks-in-range"}, ASSERTS), (CompletesNothing, "test_complete_a_recurring_task", {}, ASSERTS), + (InventsStartTimes, "test_time_log_round_trips_as_declared", {}, ASSERTS), + (IgnoresTheRange, "test_time_entries_in_a_range", {}, ASSERTS), ] @@ -512,6 +614,8 @@ class Backend(AsyncMemoryBackend): NoDeletionsFeed.seen.clear() FrozenFeed.first = None profile, suite = (FEED, TestFeedProfile()) if broken in (FrozenFeed, NoDeletionsFeed) else (FULL, TestFullProfile()) + if issubclass(broken, OwnTimeLog): + profile = OWN_TIME_LOG suite._made, suite._borrowed = [], [] backend = Backend(profile=profile) collection = await suite.open_collection(backend) diff --git a/tests/_sync/test_generic.py b/tests/_sync/test_generic.py index 791cab4..e58bcfd 100644 --- a/tests/_sync/test_generic.py +++ b/tests/_sync/test_generic.py @@ -3,8 +3,12 @@ from __future__ import annotations -from datetime import UTC, datetime, timedelta, timezone +import time as clock +from collections.abc import Iterator +from datetime import UTC, date, datetime, timedelta, timezone +from typing import ClassVar +import icalendar import pytest from icalendar_searcher import Searcher @@ -399,3 +403,131 @@ def test_get_on_overrides_without_a_master_returns_the_earliest() -> None: found = collection.get("invited") assert found.is_occurrence assert str(found.component["SUMMARY"]) == "earlier" + + +# --- the time log (§3.4), beyond the conformance suite ------------------- + +HOUR = timedelta(hours=1) + + +@pytest.fixture +def oslo(monkeypatch: pytest.MonkeyPatch) -> Iterator[None]: + """Local time in Oslo, where summer time is CEST: not an IANA name.""" + monkeypatch.setenv("TZ", "Europe/Oslo") + clock.tzset() + yield + monkeypatch.undo() + clock.tzset() + + +def test_a_day_of_durations_is_valid_icalendar_in_any_time_zone(oslo: None) -> None: + collection = MemoryBackend().collection("default") + task = collection.add_task(summary="x") + summer = date(2026, 7, 3) + first = task.log_time(spent=3 * HOUR, day=summer) + second = task.log_time(spent=2 * HOUR, day=summer) + assert first.start is not None + assert first.start.utcoffset() is not None, "an entry's times are never floating" + assert first.start == datetime(2026, 7, 3, 6, tzinfo=UTC), "08:00 in Oslo, summer time" + assert second.start == first.end + for _, entry in collection.time_entries(datetime(2026, 7, 3, tzinfo=UTC), datetime(2026, 7, 4, tzinfo=UTC)): + assert entry.start is not None + assert entry.start.tzinfo is not None + + +def test_a_day_lines_up_per_person_and_may_run_past_midnight() -> None: + collection = MemoryBackend().collection("default") + task = collection.add_task(summary="x") + day = date(2026, 11, 3) + alice, bob = ("mailto:alice@example.com",), ("mailto:bob@example.com",) + a1 = task.log_time(spent=9 * HOUR, day=day, users=alice) + b1 = task.log_time(spent=HOUR, day=day, users=bob) + assert b1.start == a1.start, "bob's day is not pushed by alice's" + late = task.log_time(spent=10 * HOUR, day=day, users=alice) + assert late.start == a1.end + assert late.end is not None + assert late.end.date() > day, "a long day keeps its sum, past midnight" + + +def test_time_entries_are_not_subtasks() -> None: + collection = MemoryBackend().collection("default") + task = collection.add_task(summary="parent") + child = collection.add_task(summary="child", related_to=task.uid) + task.log_time(spent=HOUR) + assert [r.uid for r in task.relatives("CHILD")] == [child.uid] + + +def test_a_separate_time_log_collection() -> None: + backend = MemoryBackend() + tasks = backend.create_collection("tasks", components=[Component.TASK]) + log = backend.create_collection("time log", components=[Component.EVENT]) + tasks.time_log_collection = log + task = tasks.add_task(summary="x") + entry = task.log_time(start=datetime(2026, 11, 3, 9, tzinfo=UTC), end=datetime(2026, 11, 3, 10, tzinfo=UTC)) + assert [e.id for e in task.time_log()] == [entry.id] + assert [i.uid for i in log.search()] == [entry.id] + assert tasks.events() == [] + tasks.time_log_collection = None + with pytest.raises(UnsupportedError): + task.log_time(spent=HOUR) # the task list itself holds no events + + +def test_amend_time_edits_the_entry_in_place() -> None: + collection = MemoryBackend().collection("default") + task = collection.add_task(summary="before") + entry = task.log_time(start=datetime(2026, 11, 3, 9, tzinfo=UTC), end=datetime(2026, 11, 3, 10, tzinfo=UTC)) + assert entry.id is not None + stored = collection.get(entry.id) + stored.component.add("CATEGORIES", ["billable"]) + stored.component.add("X-OTHER-CLIENT", "kept") + stored.save() + task.component["SUMMARY"] = "after" + task.save() + amended = task.amend_time(entry, spent=2 * HOUR) + again = collection.get(entry.id) + assert b"CATEGORIES:billable" in again.to_ical() + assert str(again.component["X-OTHER-CLIENT"]) == "kept" + assert str(again.component["SUMMARY"]) == "before", "the summary is the task's when it was logged" + assert int(again.component.get("SEQUENCE", 0)) >= 1 + assert (amended.start, amended.end) == (entry.start, datetime(2026, 11, 3, 11, tzinfo=UTC)) + shortened = task.amend_time(amended, spent=HOUR, end=datetime(2026, 11, 3, 11, tzinfo=UTC)) + assert shortened.start == datetime(2026, 11, 3, 10, tzinfo=UTC), "spent with end keeps the end" + + +def test_naive_and_aware_times_do_not_mix() -> None: + collection = MemoryBackend().collection("default") + task = collection.add_task(summary="x") + with pytest.raises(ValueError, match="floating"): + task.log_time(start=datetime(2026, 11, 3, 9, tzinfo=UTC), end=datetime(2026, 11, 3, 10)) + task.start_timer(at=datetime(2026, 11, 3, 9, tzinfo=UTC)) + with pytest.raises(ValueError, match="floating"): + task.stop_timer(at=datetime(2026, 11, 3, 10)) + + +class FailsTheNextSave(MemoryCollection): + """The next update fails, as on a network error or another device's edit.""" + + fail: ClassVar[bool] = False + + def _update( + self, native_id: str, calendar: icalendar.Calendar, *, etag: str | None, wire: bytes | None + ) -> StoredObject: + if FailsTheNextSave.fail: + FailsTheNextSave.fail = False + raise ConflictError("another device saved it meanwhile") + return super()._update(native_id, calendar, etag=etag, wire=wire) + + +def test_stopping_a_timer_again_logs_it_once() -> None: + class Backend(MemoryBackend): + collection_class = FailsTheNextSave + + collection = Backend().collection("default") + task = collection.add_task(summary="x") + task.start_timer(at=datetime(2026, 11, 3, 9, tzinfo=UTC)) + FailsTheNextSave.fail = True + with pytest.raises(ConflictError): + task.stop_timer(at=datetime(2026, 11, 3, 10, tzinfo=UTC)) + task.stop_timer(at=datetime(2026, 11, 3, 10, tzinfo=UTC)) + [entry] = task.time_log() + assert entry.duration == HOUR diff --git a/tests/_sync/test_memory_conformance.py b/tests/_sync/test_memory_conformance.py index 373c4c5..8b0d355 100644 --- a/tests/_sync/test_memory_conformance.py +++ b/tests/_sync/test_memory_conformance.py @@ -19,7 +19,8 @@ import copy import inspect from collections.abc import Iterator, Mapping, Sequence -from datetime import UTC, date, datetime, time +from dataclasses import replace +from datetime import UTC, date, datetime, time, timedelta from typing import Any, ClassVar import icalendar @@ -57,6 +58,7 @@ Profile, ) from calendaring.testing import SyncConformance +from calendaring.timetracking import TimeEntry from calendaring.types import StoredObject @@ -173,6 +175,88 @@ def open_backend(self) -> Backend: return MemoryBackend(profile=TASK_FEED) +class OwnTimeLog(MemoryCollection): + """A tracker's own time log, as Request Tracker keeps it: who, how long and a comment, not when.""" + + def _log(self) -> list[tuple[str, TimeEntry]]: + if not hasattr(self.store, "time_log"): + self.store.time_log = [] # type: ignore[attr-defined] # kept with the calendar, as the tracker would + return self.store.time_log # type: ignore[attr-defined, no-any-return] + + def _time_entries( + self, start: datetime | None, end: datetime | None, *, parent: str | None = None + ) -> list[tuple[str | None, TimeEntry]]: + return [ + (uid, e) for uid, e in self._log() if start is None or end is None or (e.logged and start <= e.logged < end) + ] + + def _time_log(self, item: Item) -> list[TimeEntry]: + return [e for uid, e in self._log() if uid == item.uid] + + def _add_time(self, item: Item, entry: TimeEntry) -> TimeEntry: + kept = replace(entry, start=None, end=None, id=f"log-{len(self._log()) + 1}") + self._log().append((item.uid, kept)) + return kept + + def _amend_time(self, item: Item, old: TimeEntry, new: TimeEntry) -> TimeEntry: + kept = replace(new, start=None, end=None) + self._log()[:] = [(uid, kept if e.id == old.id else e) for uid, e in self._log()] + return kept + + def _delete_time(self, item: Item, entry: TimeEntry) -> None: + self._log()[:] = [(uid, e) for uid, e in self._log() if e.id != entry.id] + + +class TotalOnly(MemoryCollection): + """A tracker that keeps only the total time spent on a task.""" + + def _totals(self) -> dict[str, timedelta]: + if not hasattr(self.store, "totals"): + self.store.totals = {} # type: ignore[attr-defined] # kept with the calendar, as the tracker would + return self.store.totals # type: ignore[attr-defined, no-any-return] + + def _time_entries( + self, start: datetime | None, end: datetime | None, *, parent: str | None = None + ) -> list[tuple[str | None, TimeEntry]]: + return [(uid, TimeEntry(total)) for uid, total in self._totals().items()] + + def _time_log(self, item: Item) -> list[TimeEntry]: + total = self._totals().get(item.uid) + return [] if total is None else [TimeEntry(total)] + + def _add_time(self, item: Item, entry: TimeEntry) -> TimeEntry: + self._totals()[item.uid] = self._totals().get(item.uid, timedelta(0)) + entry.person_time + return TimeEntry(entry.duration) + + +def time_log_profile(name: str, time_log: dict[str, Any]) -> Profile: + return Profile(name, {**TRACKER.capabilities, Feature.TIME_LOG: time_log}, TRACKER.components) + + +OWN_TIME_LOG = time_log_profile("own-time-log", {"level": Support.LOSSY, "lost": ["start-time", "end"]}) +TOTAL_ONLY = time_log_profile("total-only", {"level": Support.LOSSY, "total-only": True}) + + +class TestOwnTimeLogProfile(SyncConformance): + """A tracker whose time log drops when the work was done.""" + + def open_backend(self) -> Backend: + class Backend(MemoryBackend): + collection_class = OwnTimeLog + + return Backend(profile=OWN_TIME_LOG) + + +class TestTotalOnlyProfile(SyncConformance): + """A tracker that keeps only a total.""" + + def open_backend(self) -> Backend: + class Backend(MemoryBackend): + collection_class = TotalOnly + + return Backend(profile=TOTAL_ONLY) + + def sharing(profile: Profile, store: MemoryStore | None = None) -> MemoryBackend: """A backend on ``store``, like a second connection to a server that keeps its data.""" backend = MemoryBackend(profile=profile) @@ -460,6 +544,22 @@ def _complete_recurring( return self.bind(task) +class InventsStartTimes(OwnTimeLog): + """Declares the start time lost, then makes one up on reading.""" + + def _time_log(self, item: Item) -> list[TimeEntry]: + return [replace(e, start=e.logged) for e in super()._time_log(item)] + + +class IgnoresTheRange(MemoryCollection): + """Answers a time-range search for entries with every entry.""" + + def _time_entries( + self, start: datetime | None, end: datetime | None, *, parent: str | None = None + ) -> list[tuple[str | None, TimeEntry]]: + return super()._time_entries(None, None, parent=parent) + + # What the suite raises when it catches a defect: its own assertion, or a # pytest.raises that did not raise. The round trip catches through verify=True. ASSERTS = (AssertionError, pytest.fail.Exception) @@ -494,6 +594,8 @@ def _complete_recurring( (StartsInRange, "test_search_gives_the_client_side_answer", {"name": "events-in-range"}, ASSERTS), (StartsInRange, "test_search_gives_the_client_side_answer", {"name": "tasks-in-range"}, ASSERTS), (CompletesNothing, "test_complete_a_recurring_task", {}, ASSERTS), + (InventsStartTimes, "test_time_log_round_trips_as_declared", {}, ASSERTS), + (IgnoresTheRange, "test_time_entries_in_a_range", {}, ASSERTS), ] @@ -512,6 +614,8 @@ class Backend(MemoryBackend): NoDeletionsFeed.seen.clear() FrozenFeed.first = None profile, suite = (FEED, TestFeedProfile()) if broken in (FrozenFeed, NoDeletionsFeed) else (FULL, TestFullProfile()) + if issubclass(broken, OwnTimeLog): + profile = OWN_TIME_LOG suite._made, suite._borrowed = [], [] backend = Backend(profile=profile) collection = suite.open_collection(backend) diff --git a/tests/test_caldav_features.py b/tests/test_caldav_features.py index 4f9194b..918fd33 100644 --- a/tests/test_caldav_features.py +++ b/tests/test_caldav_features.py @@ -44,6 +44,7 @@ def test_what_the_server_stores_is_unknown_without_hints() -> None: Feature.ALARMS, Feature.DATES, Feature.TIMEZONES, + Feature.TIME_LOG, ): assert unhinted.level(feature) is Support.UNKNOWN, feature assert known.level(feature) is Support.FULL, feature @@ -84,6 +85,14 @@ def test_a_calendar_without_a_component_cannot_hold_it() -> None: assert table.level(Feature.COMPONENT_EVENT) is Support.UNSUPPORTED +def test_a_calendar_without_events_has_nowhere_to_log_time() -> None: + tasks = collection_capabilities(hinted(save_load="full"), frozenset({Component.TASK})) + assert tasks.level(Feature.TIME_LOG) is Support.UNSUPPORTED + assert "time_log_collection" in (tasks[Feature.TIME_LOG].note or "") + both = collection_capabilities(hinted(save_load="full"), frozenset({Component.TASK, Component.EVENT})) + assert both.level(Feature.TIME_LOG) is Support.FULL + + def test_sync_token_is_used_only_where_deletions_are_reported_too() -> None: assert collection_capabilities(hinted(sync_token="full"), ALL).details(Feature.CHANGES) == {"method": "sync-token"} broken_delete = hinted(**{"sync-token": "full", "sync-token.delete": "unsupported"}) diff --git a/tests/test_timetracking.py b/tests/test_timetracking.py new file mode 100644 index 0000000..0e2ee53 --- /dev/null +++ b/tests/test_timetracking.py @@ -0,0 +1,142 @@ +"""The time-tracking model, mode-free: entries, their iCalendar form, and aggregation (API_DESIGN.md §3.4).""" + +from __future__ import annotations + +from datetime import UTC, date, datetime, timedelta + +import icalendar +import pytest + +from calendaring import Event, Task +from calendaring.timetracking import ( + X_TIME_ENTRY, + X_TIMER_START, + TimeEntry, + TimeSummary, + entry_from_component, + entry_to_component, + is_entry, + resolve, + running_timer, + summarize, +) + +H = timedelta(hours=1) + + +def at(day: int, hour: int = 12, minute: int = 0) -> datetime: + return datetime(2026, 11, day, hour, minute, tzinfo=UTC) + + +def test_two_of_three() -> None: + now = at(3, 17) + assert resolve(spent=2 * H, start=at(3, 9), end=None, now=now) == (at(3, 9), at(3, 11)) + assert resolve(spent=2 * H, start=None, end=at(3, 11), now=now) == (at(3, 9), at(3, 11)) + assert resolve(spent=None, start=at(3, 9), end=at(3, 11), now=now) == (at(3, 9), at(3, 11)) + assert resolve(spent=2 * H, start=None, end=None, now=now) == (at(3, 15), now), "spent alone ends now" + assert resolve(spent=2 * H, start=at(3, 9), end=at(3, 11), now=now) == (at(3, 9), at(3, 11)) + with pytest.raises(ValueError, match="disagree"): + resolve(spent=3 * H, start=at(3, 9), end=at(3, 11), now=now) + with pytest.raises(ValueError, match="two of"): + resolve(spent=None, start=at(3, 9), end=None, now=now) + with pytest.raises(ValueError, match="before"): + resolve(spent=None, start=at(3, 11), end=at(3, 9), now=now) + + +def test_an_entry_round_trips_through_its_vevent() -> None: + task = Task.new(summary="write the report") + entry = TimeEntry( + duration=2 * H, + start=at(3, 9), + end=at(3, 11), + users=("mailto:alice@example.com", "mailto:bob@example.com"), + comment="first draft\nand figures", + logged=at(3, 11, 5), + ) + component = entry_to_component(entry, parent=task) + assert is_entry(component) + assert str(component[X_TIME_ENTRY]) == "TRUE" + assert str(component["SUMMARY"]) == "write the report" + assert str(component["RELATED-TO"]) == task.uid + assert component["RELATED-TO"].params["RELTYPE"] == "PARENT" + assert "ATTENDEE" not in component, "an ATTENDEE would make a server send invitations" + assert [p.name for p in component.subcomponents] == ["PARTICIPANT", "PARTICIPANT"] + calendar = icalendar.Calendar() + calendar.add_component(component) + parent, back = entry_from_component(Event.from_ical(calendar.to_ical()).component) + assert parent == task.uid + assert back == TimeEntry( + duration=2 * H, + start=at(3, 9), + end=at(3, 11), + users=entry.users, + comment=entry.comment, + logged=at(3, 11, 5), + id=str(component["UID"]), + ) + + +def test_an_ordinary_sub_event_is_no_entry() -> None: + meeting = Event.new(summary="planning", start=at(3, 9), end=at(3, 10)) + meeting.component.add("RELATED-TO", "some-task", parameters={"RELTYPE": "PARENT"}) + assert not is_entry(meeting.component) + + +def test_summarize_keeps_estimate_and_spent_apart() -> None: + a = Task.new(summary="a") + a.component.add("ESTIMATED-DURATION", icalendar.vDuration(5 * H)) + b = Task.new(summary="b", status="COMPLETED") + b.component.add("ESTIMATED-DURATION", icalendar.vDuration(3 * H)) + c = Task.new(summary="c") + pairs = [ + (a, [TimeEntry(duration=2 * H, start=at(2, 9), end=at(2, 11))]), + (b, [TimeEntry(duration=H, start=at(3, 9), end=at(3, 10))]), + (c, [TimeEntry(duration=4 * H, logged=at(3, 18))]), # no start, and no window: all of it counts + ] + customer = {a.uid: "customer-1", b.uid: "customer-1", c.uid: "customer-2"} + by_customer = summarize(pairs, group_by=lambda item: customer[item.uid]) + assert by_customer == { + "customer-1": TimeSummary(estimate=5 * H, spent=3 * H), # b is done: its estimate is no longer committed + "customer-2": TimeSummary(estimate=timedelta(0), spent=4 * H), + } + assert sum(by_customer.values(), TimeSummary()) == TimeSummary(estimate=5 * H, spent=7 * H) + + +def test_summarize_clips_to_the_window() -> None: + task = Task.new(summary="x") + entries = [ + TimeEntry(duration=4 * H, start=at(2, 22), end=at(3, 2)), # straddles midnight: half inside + TimeEntry(duration=H, start=at(3, 9), end=at(3, 10)), + TimeEntry(duration=H, start=at(4, 9), end=at(4, 10)), # after the window + TimeEntry(duration=2 * H, logged=at(3, 15)), + ] + window = (at(3, 0), at(4, 0)) + [summary] = summarize([(task, entries)], group_by=lambda item: None, window=window).values() + assert summary.spent == 5 * H + + +def test_a_day_has_no_hours_in_icalendar() -> None: + """RFC 5545 §3.8.2.5: a DURATION after a DATE DTSTART is whole days, so a date alone is refused.""" + with pytest.raises(TypeError): + entry_to_component(TimeEntry(duration=H, start=date(2026, 11, 3)), parent=Task.new(summary="x")) # type: ignore[arg-type] + + +def test_a_timer_started_in_the_future_has_run_for_nothing() -> None: + task = Task.new(summary="x") + task.component.add(X_TIMER_START, icalendar.vDDDTypes(at(3, 12))) + timer = running_timer(task, at(3, 11)) + assert timer is not None + assert timer.duration == timedelta(0) + + +def test_an_entry_on_a_date_has_no_start() -> None: + event = icalendar.Event() + event.add("UID", "e") + event.add("DTSTART", date(2026, 11, 3)) + event.add(X_TIME_ENTRY, "TRUE") + assert entry_from_component(event)[1].start is None + + +def test_mixed_floating_and_zoned_times_are_refused() -> None: + with pytest.raises(ValueError, match="floating"): + resolve(spent=None, start=at(3, 9), end=datetime(2026, 11, 3, 10), now=at(3, 17))