From 5ba21f0f8b13740902e52cea0d2ab992f96220fa Mon Sep 17 00:00:00 2001 From: Tobias Brox Date: Tue, 20 Oct 2026 19:58:04 +0200 Subject: [PATCH 1/2] feat: time tracking (roadmap 1.6) Time spent on a task or in a meeting is an append-only log of entries, stored as VEVENTs marked X-PYCAL-TIME-ENTRY, related to the task or event, with RFC 9073 PARTICIPANTs for who did the work, so no server sends invitations. Bound items get log_time(), time_log(), time_spent(), amend_time() and delete_time(); tasks get a timer (X-PYCAL-TIMER-START, removed when stopped); summarize() sums per budget line. The time-log capability is LOSSY where a tracker drops fields, which fake backends exercise until 2.5 brings real ones. On CalDAV it is FULL where caldav has hints for the server, UNKNOWN elsewhere, and unsupported in a calendar holding no events. Prompt: Deal with [roadmap item 1.6, the time-tracking model and API, postponed earlier; plan in an untracked document] Followup-Prompt: (asked which branch to build on) Stack on PR 24 Followup-Prompt: Do the needful to get PR 25 merged. [rebased onto main after PR 13 was merged] Prompts (comments made inline in the document while reviewing it): > (on plann's earlier design) The design notes in plann can be disregarded. > (on D3, suggestion to use ORGANIZER rather than ATTENDEE for who did the work) The attendee parameter `SCHEDULE-AGENT=CLIENT` or `SCHEDULE-AGENT=NONE` or 'Scheduling: none` or `Schedule-Reply: F` should probably work? though, it's needed to verify this somehow, and it may be difficult in the caldav-server-checker tool. Without METHOD, the server is also not supposed to send scheduling-invites - but I did once experince (sic) Zimbra spamming people with ten year old meeting invites when I tried restoring my calendar from a backup ... hm. (PARTICIPANT is of course the right solution, it was found in the next iteration) > (on D3) Multiple people can have attended an event, if using organizer then it's needed with one event per attendee - or can an event have multiple organizers? > (on D3) Journals is the thing meant for recording after-the-fact, can journals have attendees? Would they get scheduling emails sent? A journal entry can be liked (sic) to the time-tracking event. > (on D3) The tasks draft has put a lot of effort into assigning tasks, please consider if anything from there can be used - but that's probably only related to VTODO, and not a VEVENT? Time should be recorded also when participating in a VENVENT (sic), not only when working on a VTODO. > (on D4, X-PYCAL-PERCENT-COMPLETE) Do we need PERCENT COMPLETE in a time tracking event? It may sound useful, but it doesn't really belong in a VEVENT. One idea I had for plann (and which seems to work out to some extent) is to split a task into subtasks when I've partially completed a task. "clean the pantry" (completed) vs "clean the rest of the boat", and then use the estimated duration to indicate how many percent I believe I've done. But we could leave the PERCENT COMPLETE as a question to be resolved later. > (on D4, X-PYCAL-UNTIMED) I don't like introducing too many X-parameters. They should be listed and explained and justified for the rest of the team. Every X-parameter needs to be very carefully thought through. It's possible to have a rule that DTEND==DTSTAMP means the time is made up. (though, this does not work out very well with punch clock systems). We do have some X-parameters for source system, or the uid will indicate source system? So if the source system does not support anything else than "time spent", then it's safe to assume the DTSTART is made up. Do we need anything more advanced like that in the beginning? > (on D5, where entries go) Some CalDAV servers cannot be used for time tracking, period. The time tracking would eventually need to land in a separate calendar. I'm comfortable with that. > (on D6, scalar-only backends) Importing from the internal icalendar storage to the scalar-only backend works, but is lossy. Import the other way, and it's even more guesswork than with the `log(spent=4h)`. But even if it's just a scalar that is visibly stored, do some research on weather (sic) it's possible to get information on the transactions where the scalar is changed? > (on one PARTICIPANT per entry) If two persons in the same meeting logs different duration, then obviously it's needed with two entries, but it doesn't sound quite right to me to define that it always should be only one PARTICIPANT per event. > (on dropping percent-complete) Are there any systems that would have problems with round-triping if we drop the percent-complete on the time worked entry? > (on untimed entries) When starting to use timewarrior, it got a bit frustrating that I couldn't just log "I've spent 3 hours on this, 2 hours on that and 4 hours on something else after lunch". That's what I need to copy over to the accounting system. Excactly (sic) how many intervals for each customer and their exact start- and end-times is not important. Sometimes I will have spent the whole 9 hour window on multitasking between different tasks. In timew every task has to have a start time and an end time, and AFAIK they have to be given (ideally real-time in a punch clock manner). In the aw-watcher-afk-prompter I allow a time interval to be split into different tasks, and allowing a duration to be set on each of them. It's still recorded with a start time and an end time in the database, and I know it's not entirely correct, but at the end of the day it doesn't matter. > (on untimed entries, on the fact that it's impossible to rely on DTSTAMP==DTEND) There is also the timestamp CREATED that can be set, and it's optional. > (on untimed entries) What date one did the work on is usually of significance. An EVENT can take a date as DTSTART - but can it be combined with a DURATION given in hours without breaking the RFC? Would the servers accept it? > (on X-PYCAL-TIMER-START) The latter is to be a temporary attribute, to be removed after usage. > (on D1) Yes, we keep the marker > (on question 1, the marker) Keep marker > (on question 2, PARTICIPANT) Participant it is, but I think it's fair to allow more than one participant in an event. > (on question 3, the capability table) probably ok Assisted-By: Claude Opus 5.5 (1M context) Reviewed-by: Tobias Brox --- CONTRIBUTING.md | 5 +- docs/design/API_DESIGN.md | 136 ++++++++++++- docs/design/ROADMAP.md | 41 +++- docs/design/TASK_MODEL_SURVEY.md | 2 +- news/+time-log.feature.rst | 1 + src/calendaring/_async/collection.py | 242 +++++++++++++++++++++++- src/calendaring/_async/conformance.py | 169 ++++++++++++++++- src/calendaring/_async/items.py | 43 ++++- src/calendaring/_caldav_features.py | 3 + src/calendaring/_memory_store.py | 1 + src/calendaring/_sync/collection.py | 242 +++++++++++++++++++++++- src/calendaring/_sync/conformance.py | 169 ++++++++++++++++- src/calendaring/_sync/items.py | 43 ++++- src/calendaring/capabilities.py | 1 + src/calendaring/timetracking.py | 234 +++++++++++++++++++++++ tests/_async/test_generic.py | 134 ++++++++++++- tests/_async/test_memory_conformance.py | 106 ++++++++++- tests/_sync/test_generic.py | 134 ++++++++++++- tests/_sync/test_memory_conformance.py | 106 ++++++++++- tests/test_caldav_features.py | 9 + tests/test_timetracking.py | 142 ++++++++++++++ 21 files changed, 1931 insertions(+), 32 deletions(-) create mode 100644 news/+time-log.feature.rst create mode 100644 src/calendaring/timetracking.py create mode 100644 tests/test_timetracking.py 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..5711a8f 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]: @@ -1123,6 +1127,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..4e686e5 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]: @@ -1123,6 +1127,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)) From 25ca8019f9d0ff22c81de95f0bec5d122e5c49b2 Mon Sep 17 00:00:00 2001 From: Tobias Brox Date: Tue, 20 Oct 2026 19:59:31 +0200 Subject: [PATCH 2/2] fix: valid estimate sample in conformance suite The suite stored ESTIMATED-DURATION as a bare timedelta, which icalendar writes as "2:00:00" for a property it does not type: invalid iCalendar that a backend could only pass by storing it untouched. The sample is now a vDuration, written "PT2H". Prompt: Deal with [roadmap item 1.6, the time-tracking model and API; found while summing estimates for it] Assisted-By: Claude Opus 5.5 (1M context) Reviewed-by: Tobias Brox --- src/calendaring/_async/conformance.py | 3 ++- src/calendaring/_sync/conformance.py | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/src/calendaring/_async/conformance.py b/src/calendaring/_async/conformance.py index 5711a8f..d135cd5 100644 --- a/src/calendaring/_async/conformance.py +++ b/src/calendaring/_async/conformance.py @@ -146,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"}), } diff --git a/src/calendaring/_sync/conformance.py b/src/calendaring/_sync/conformance.py index 4e686e5..7d95349 100644 --- a/src/calendaring/_sync/conformance.py +++ b/src/calendaring/_sync/conformance.py @@ -146,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"}), }