Skip to main content

hey_sdk/services/
time_tracks.rs

1//! Starting and stopping the running time track, keeping the categories tracks are filed
2//! under, and reading the lot back out as a file.
3//!
4//! HEY serves no JSON endpoint for a category write or for the export, so those are browser
5//! form posts and a file read.
6
7use std::borrow::Cow;
8
9use bytes::Bytes;
10use chrono::Utc;
11
12use crate::error::Error;
13use crate::generated::routes;
14use crate::generated::types::{Recording, UpdateTimeTrackPayload, UpdateTimeTrackRequestContent};
15use crate::http::Method;
16use crate::observability::OperationInfo;
17use crate::services::write_info;
18
19pub use crate::generated::services::time_tracks::*;
20
21impl TimeTracks<'_> {
22    /// Starts a time track. It takes nothing: HEY ignores the request body here and starts a
23    /// track with defaults. Notes and a category come later, with
24    /// [`TimeTracks::update`] or [`TimeTracks::stop_and_file`], both of which also stop the
25    /// track.
26    ///
27    /// This is [`TimeTracks::start`] with the one refusal it can meet named: a track already
28    /// running answers 409, which arrives as [`crate::ErrorCode::Conflict`] carrying HEY's
29    /// own message, so a caller can branch on it rather than read a generic API error.
30    pub async fn start_tracking(&self) -> Result<Recording, Error> {
31        match self.start().await {
32            Ok(track) => Ok(track),
33            Err(error) if error.http_status() == Some(409) => Err(already_running(&error)),
34            Err(error) => Err(error),
35        }
36    }
37
38    /// Stops the running time track by setting its end to now.
39    pub async fn stop(&self, time_track_id: i64) -> Result<(), Error> {
40        self.stop_and_file(time_track_id, None).await
41    }
42
43    /// Stops a time track and files it under a category in the one request, creating the
44    /// category if HEY has none by that name. No category stops the track without filing it,
45    /// which is what [`TimeTracks::stop`] does.
46    ///
47    /// Filing is only ever part of stopping: HEY completes a track on every update, so there
48    /// is no such thing as setting a category on a track that keeps running.
49    ///
50    /// It sends the same PUT [`TimeTracks::update`] does, but announces itself to the
51    /// client's hooks as `StopTimeTrack` rather than `UpdateTimeTrack`, so a gating policy
52    /// can allow one without the other.
53    pub async fn stop_and_file(
54        &self,
55        time_track_id: i64,
56        category_title: Option<&str>,
57    ) -> Result<(), Error> {
58        let body = UpdateTimeTrackRequestContent {
59            calendar_time_track: UpdateTimeTrackPayload {
60                ends_at: Some(Utc::now()),
61                // An empty title names no category, and goes out as no category at all
62                // rather than as a category called "".
63                category_title: category_title
64                    .filter(|title| !title.is_empty())
65                    .map(str::to_string),
66                ..UpdateTimeTrackPayload::default()
67            },
68        };
69
70        let mut operation = self
71            .client()
72            .operation(&routes::UPDATE_TIME_TRACK, &[&time_track_id]);
73        operation.operation_name("StopTimeTrack");
74        operation.resource_id(time_track_id);
75        operation.json(&body)?;
76        self.client().send_unit(operation).await
77    }
78
79    /// Adds a category to file tracks under.
80    pub async fn create_category(&self, title: &str) -> Result<(), Error> {
81        let mut operation = self
82            .client()
83            .form(Method::POST, "/calendar/time_tracks/categories")?;
84        operation.info(write_info(
85            "TimeTracks",
86            "CreateTimeTrackCategory",
87            "category",
88            None,
89        ));
90        operation.form(&[("category[title]", title)]);
91        self.client().send_unit(operation).await
92    }
93
94    /// Renames a category.
95    pub async fn update_category(&self, category_id: i64, title: &str) -> Result<(), Error> {
96        let mut operation = self.client().form(
97            Method::PATCH,
98            &format!("/calendar/time_tracks/categories/{category_id}"),
99        )?;
100        operation.info(write_info(
101            "TimeTracks",
102            "UpdateTimeTrackCategory",
103            "category",
104            Some(category_id),
105        ));
106        operation.form(&[("category[title]", title)]);
107        self.client().send_unit(operation).await
108    }
109
110    /// Removes a category. The tracks filed under it stay, uncategorized.
111    pub async fn delete_category(&self, category_id: i64) -> Result<(), Error> {
112        let mut operation = self.client().form(
113            Method::DELETE,
114            &format!("/calendar/time_tracks/categories/{category_id}"),
115        )?;
116        operation.info(write_info(
117            "TimeTracks",
118            "DeleteTimeTrackCategory",
119            "category",
120            Some(category_id),
121        ));
122        self.client().send_unit(operation).await
123    }
124
125    /// Every completed time track as CSV, newest first, under the columns Start, End,
126    /// Duration, Category and Notes. HEY streams this as a file rather than a document.
127    pub async fn export(&self) -> Result<Bytes, Error> {
128        let mut operation = self.client().csv("/calendar/time_tracks/exports")?;
129        operation.info(OperationInfo {
130            service: Cow::Borrowed("TimeTracks"),
131            operation: Cow::Borrowed("ExportTimeTracks"),
132            resource_type: Cow::Borrowed("time_track"),
133            is_mutation: false,
134            resource_id: None,
135        });
136        Ok(self.client().execute(operation).await?.body)
137    }
138}
139
140fn already_running(refusal: &Error) -> Error {
141    match refusal.hint() {
142        Some(message) => Error::conflict(message),
143        None => Error::conflict("a time track is already running"),
144    }
145}