Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions app/src/components/cmd-k.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,8 @@ import {
rememberLastProjectAtom,
buildRememberedTimerParams,
} from "@/lib/time-tracking-preferences";
import dayjs from "dayjs";
import { getSaveTimerErrorDescription } from "@/lib/api/error";

export function CmdK() {
const [open, setOpen] = React.useState(false);
Expand Down Expand Up @@ -131,6 +133,11 @@ function ActionsCommandGroup(props: { close: () => void }) {
}),
});
},
onError: (error) => {
void getSaveTimerErrorDescription(error).then((description) => {
toast.error("Could not save timer", { description });
});
},
});

const saveTimerDisabled = !timer?.activityName || !timer?.projectName;
Expand Down Expand Up @@ -165,6 +172,7 @@ function ActionsCommandGroup(props: { close: () => void }) {
onSelect={() => {
saveTimer({
userNote: timer?.note ?? "",
regDay: dayjs().format("YYYY-MM-DD"),
});
props.close();
}}
Expand Down
11 changes: 11 additions & 0 deletions app/src/components/floating-timer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ import {
lastProjectAtom,
rememberLastProjectAtom,
} from "@/lib/time-tracking-preferences";
import { getSaveTimerErrorDescription } from "@/lib/api/error";

export const FloatingTimer = () => {
const queryClient = useQueryClient();
Expand Down Expand Up @@ -199,6 +200,7 @@ export const FloatingTimer = () => {
saveTimer(
{
userNote: userNote ?? "",
regDay: dayjs().format("YYYY-MM-DD"),
},
{
onSuccess: () => {
Expand Down Expand Up @@ -241,6 +243,15 @@ export const FloatingTimer = () => {
});
}
},
onError: (error) => {
void getSaveTimerErrorDescription(error).then(
(description) => {
toast.error("Could not save timer", {
description,
});
},
);
},
},
);
}}
Expand Down
46 changes: 46 additions & 0 deletions app/src/lib/api/error.ts
Copy link
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should be able to use zod here in order to make it cleaner.

Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import { HTTPError } from "ky";

type ApiErrorBody = {
error?: string;
code?: string;
};

export const TIME_TRACKING_PERIOD_LOCKED_CODE = "TIME_TRACKING_PERIOD_LOCKED";
const LOCKED_PERIOD_SAVE_TIMER_DESCRIPTION =
"The selected day is in a locked period in Milltime. Unlock it or save to a different date.";

export async function getApiErrorDetails(error: unknown): Promise<{
message: string;
code?: string;
}> {
if (error instanceof HTTPError) {
const body = await error.response
.clone()
.json()
.then((value) => value as ApiErrorBody)
.catch(() => null);

if (body?.error) {
return { message: body.error, code: body.code };
}

return {
message: `${error.response.status} ${error.response.statusText}`.trim(),
};
}

if (error instanceof Error) {
return { message: error.message };
}

return { message: "Unknown error" };
}

export async function getSaveTimerErrorDescription(error: unknown): Promise<string> {
const { message, code } = await getApiErrorDetails(error);
if (code === TIME_TRACKING_PERIOD_LOCKED_CODE) {
return LOCKED_PERIOD_SAVE_TIMER_DESCRIPTION;
}

return message;
}
2 changes: 2 additions & 0 deletions app/src/lib/api/mutations/time-tracking.ts
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,7 @@ function useSaveTimer(options?: DefaultMutationOptions<SaveTimerPayload>) {
api.put("time-tracking/timer", {
json: {
userNote: body.userNote,
regDay: body.regDay,
},
}),
...options,
Expand Down Expand Up @@ -237,6 +238,7 @@ export type StartTimerMutationAsync = MutationFnAsync<typeof useStartTimer>;

export type SaveTimerPayload = {
userNote?: string;
regDay?: string;
};

export type EditTimerPayload = {
Expand Down
25 changes: 19 additions & 6 deletions milltime/src/client.rs
Original file line number Diff line number Diff line change
Expand Up @@ -47,12 +47,19 @@ impl MilltimeClient {
&self,
resp: reqwest::Response,
) -> Result<T, MilltimeFetchError> {
if resp.status().is_client_error() {
return match resp.status() {
let status = resp.status();
if status.is_client_error() {
return match status {
reqwest::StatusCode::UNAUTHORIZED | reqwest::StatusCode::FORBIDDEN => {
Err(MilltimeFetchError::Unauthorized)
}
_ => Err(MilltimeFetchError::ResponseError(resp.status().to_string())),
_ => {
let body = resp.text().await.unwrap_or_default();
Err(MilltimeFetchError::ResponseError {
status: Some(status),
body,
})
}
};
}

Expand Down Expand Up @@ -378,8 +385,11 @@ impl MilltimeClient {
pub enum MilltimeFetchError {
#[error("Unauthorized")]
Unauthorized,
#[error("ResponseError: {0}")]
ResponseError(String),
#[error("ResponseError: status={status:?}, body={body}")]
ResponseError {
status: Option<reqwest::StatusCode>,
body: String,
},
#[error("ParsingError: {0}")]
ParsingError(String),
#[error("Other: {0}")]
Expand All @@ -388,7 +398,10 @@ pub enum MilltimeFetchError {

impl From<reqwest::Error> for MilltimeFetchError {
fn from(e: reqwest::Error) -> Self {
Self::ResponseError(e.to_string())
Self::ResponseError {
status: e.status(),
body: e.to_string(),
}
}
}

Expand Down
78 changes: 74 additions & 4 deletions toki-api/src/adapters/outbound/milltime/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -156,7 +156,7 @@ impl TimeTrackingClient for MilltimeAdapter {
.client
.new_project_registration(&payload)
.await
.map_err(map_milltime_error)?;
.map_err(map_milltime_registration_write_error)?;
Ok(TimerId::new(response.project_registration_id))
}

Expand Down Expand Up @@ -207,7 +207,7 @@ impl TimeTrackingClient for MilltimeAdapter {
.client
.new_project_registration(&new_payload)
.await
.map_err(map_milltime_error)?;
.map_err(map_milltime_registration_write_error)?;

// Delete old registration
self.client
Expand All @@ -234,7 +234,7 @@ impl TimeTrackingClient for MilltimeAdapter {
self.client
.edit_project_registration(&payload)
.await
.map_err(map_milltime_error)?;
.map_err(map_milltime_registration_write_error)?;

Ok(TimerId::new(request.registration_id.clone()))
}
Expand All @@ -256,8 +256,78 @@ fn map_milltime_error(e: milltime::MilltimeFetchError) -> TimeTrackingError {
{
TimeTrackingError::TimerNotFound
}
milltime::MilltimeFetchError::ResponseError(msg) => TimeTrackingError::unknown(msg),
milltime::MilltimeFetchError::ResponseError { status, body } => {
TimeTrackingError::unknown(format!("status={status:?}, body={body}"))
}
milltime::MilltimeFetchError::ParsingError(msg) => TimeTrackingError::unknown(msg),
milltime::MilltimeFetchError::Other(msg) => TimeTrackingError::unknown(msg),
}
}

fn map_milltime_registration_write_error(e: milltime::MilltimeFetchError) -> TimeTrackingError {
if is_locked_period_error(&e) {
TimeTrackingError::PeriodLocked
} else {
map_milltime_error(e)
}
}

fn is_locked_period_error(e: &milltime::MilltimeFetchError) -> bool {
match e {
milltime::MilltimeFetchError::ResponseError {
status: Some(reqwest::StatusCode::NOT_FOUND),
body,
} => {
let body_lower = body.to_lowercase();
body_lower.contains("timer not found")
Copy link
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This should be checked against Milltime, what the real response is.

|| body_lower.contains("locked")
|| body_lower.contains("attest")
|| body_lower.contains("attester")
}
_ => false,
}
}

#[cfg(test)]
mod tests {
use super::*;

#[test]
fn registration_write_maps_locked_period_error() {
let err = milltime::MilltimeFetchError::ResponseError {
status: Some(reqwest::StatusCode::NOT_FOUND),
body: "timer not found".to_string(),
};

assert!(matches!(
map_milltime_registration_write_error(err),
TimeTrackingError::PeriodLocked
));
}

#[test]
fn registration_write_keeps_non_lock_errors_as_unknown() {
let err = milltime::MilltimeFetchError::ResponseError {
status: Some(reqwest::StatusCode::BAD_REQUEST),
body: "validation failed".to_string(),
};

assert!(matches!(
map_milltime_registration_write_error(err),
TimeTrackingError::Unknown(_)
));
}

#[test]
fn registration_write_keeps_blank_404_errors_as_unknown() {
let err = milltime::MilltimeFetchError::ResponseError {
status: Some(reqwest::StatusCode::NOT_FOUND),
body: String::new(),
};

assert!(matches!(
map_milltime_registration_write_error(err),
TimeTrackingError::Unknown(_)
));
}
}
4 changes: 3 additions & 1 deletion toki-api/src/domain/error.rs
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
use thiserror::Error;

/// Errors that can occur during time tracking operations.
#[derive(Debug, Error)]
#[derive(Debug, Error, PartialEq, Eq)]
pub enum TimeTrackingError {
#[error("timer not found")]
TimerNotFound,
Expand All @@ -19,6 +19,8 @@ pub enum TimeTrackingError {
ActivityNotFound(String),
#[error("invalid date range")]
InvalidDateRange,
#[error("time period is locked")]
PeriodLocked,
#[error("{0}")]
Unknown(String),
}
Expand Down
1 change: 1 addition & 0 deletions toki-api/src/domain/ports/inbound/time_tracking.rs
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ pub trait TimeTrackingService: Send + Sync + 'static {
&self,
user_id: &UserId,
note: Option<String>,
reg_day: Option<String>,
) -> Result<TimerId, TimeTrackingError>;

/// Edit the active timer for a user.
Expand Down
Loading
Loading