diff --git a/extensions/toggl-track/CHANGELOG.md b/extensions/toggl-track/CHANGELOG.md
index 555656ef8ef..9a934204e8a 100644
--- a/extensions/toggl-track/CHANGELOG.md
+++ b/extensions/toggl-track/CHANGELOG.md
@@ -1,5 +1,10 @@
# Toggl Track Changelog
+## [New Feature] - 2024-12-09
+
+- Add "Update Time Entry" command to update time entries
+- Add ability to update time entry action from the list
+
## [New Feature] - 2024-10-07
- Add ability to create task for time entry form
diff --git a/extensions/toggl-track/package.json b/extensions/toggl-track/package.json
index 310b08e2aee..647a1e032ac 100644
--- a/extensions/toggl-track/package.json
+++ b/extensions/toggl-track/package.json
@@ -15,7 +15,8 @@
"bkeys818",
"michaelfaisst",
"teziovsky",
- "lukebars"
+ "lukebars",
+ "AlanHuang"
],
"license": "MIT",
"commands": [
@@ -25,6 +26,12 @@
"description": "Start a new time entry or end the running time entry.",
"mode": "view"
},
+ {
+ "name": "updateTimeEntry",
+ "title": "Update Time Entry",
+ "description": "Update the description, project, tags of recent time entries.",
+ "mode": "view"
+ },
{
"name": "manageWorkspaces",
"title": "View Workspaces",
@@ -129,4 +136,4 @@
"publish": "npx @raycast/api@latest publish",
"pull-contributions": "npx @raycast/api@latest pull-contributions"
}
-}
+}
\ No newline at end of file
diff --git a/extensions/toggl-track/src/api/index.ts b/extensions/toggl-track/src/api/index.ts
index 8063e9f778a..8c2b9973d84 100644
--- a/extensions/toggl-track/src/api/index.ts
+++ b/extensions/toggl-track/src/api/index.ts
@@ -25,6 +25,9 @@ export {
createTimeEntry,
stopTimeEntry,
getRunningTimeEntry,
+ updateTimeEntry,
+ removeTimeEntry,
type TimeEntry,
type TimeEntryMetaData,
+ type UpdateTimeEntryParams,
} from "@/api/timeEntries";
diff --git a/extensions/toggl-track/src/api/timeEntries.ts b/extensions/toggl-track/src/api/timeEntries.ts
index f1953f5878c..2858c87568a 100644
--- a/extensions/toggl-track/src/api/timeEntries.ts
+++ b/extensions/toggl-track/src/api/timeEntries.ts
@@ -1,4 +1,4 @@
-import { get, post, patch, remove } from "@/api/togglClient";
+import { get, post, patch, put, remove } from "@/api/togglClient";
import type { ToggleItem } from "@/api/types";
export async function getMyTimeEntries({
@@ -59,6 +59,25 @@ export function removeTimeEntry(workspaceId: number, timeEntryId: number) {
return remove(`/workspaces/${workspaceId}/time_entries/${timeEntryId}`);
}
+export interface UpdateTimeEntryParams {
+ billable?: boolean;
+ created_with?: string;
+ description?: string;
+ duration?: number;
+ duronly?: boolean;
+ project_id?: number;
+ start?: string;
+ stop?: string;
+ tag_ids?: number[];
+ tags?: string[];
+ task_id?: number;
+ workspace_id?: number;
+}
+
+export function updateTimeEntry(workspaceId: number, timeEntryId: number, params: UpdateTimeEntryParams) {
+ return put(`/workspaces/${workspaceId}/time_entries/${timeEntryId}`, params);
+}
+
// https://developers.track.toggl.com/docs/api/time_entries#response
export interface TimeEntry extends ToggleItem {
billable: boolean;
diff --git a/extensions/toggl-track/src/components/TimeEntriesList.tsx b/extensions/toggl-track/src/components/TimeEntriesList.tsx
new file mode 100644
index 00000000000..89a28ffa22b
--- /dev/null
+++ b/extensions/toggl-track/src/components/TimeEntriesList.tsx
@@ -0,0 +1,46 @@
+import { ActionPanel, Icon, List } from "@raycast/api";
+
+import { TimeEntry, TimeEntryMetaData } from "@/api";
+import { formatSeconds } from "@/helpers/formatSeconds";
+
+interface TimeEntriesListProps {
+ isLoading: boolean;
+ timeEntries: (TimeEntry & TimeEntryMetaData)[];
+ navigationTitle?: string;
+ sectionTitle?: string;
+ renderActions: (timeEntry: TimeEntry & TimeEntryMetaData) => JSX.Element;
+}
+
+export function TimeEntriesList({
+ isLoading,
+ timeEntries,
+ navigationTitle,
+ sectionTitle = "Recent time entries",
+ renderActions,
+}: TimeEntriesListProps) {
+ return (
+
+ {timeEntries.length > 0 && (
+
+ {timeEntries.map((timeEntry) => (
+ ({ tag })),
+ timeEntry.billable ? { tag: { value: "$" } } : {},
+ ]}
+ icon={{ source: Icon.Circle, tintColor: timeEntry.project_color }}
+ actions={{renderActions(timeEntry)}}
+ />
+ ))}
+
+ )}
+
+ );
+}
+
+export default TimeEntriesList;
diff --git a/extensions/toggl-track/src/components/TimeEntriesListView.tsx b/extensions/toggl-track/src/components/TimeEntriesListView.tsx
new file mode 100644
index 00000000000..907df4b9862
--- /dev/null
+++ b/extensions/toggl-track/src/components/TimeEntriesListView.tsx
@@ -0,0 +1,92 @@
+import { Action, ActionPanel, Alert, Color, Icon, List, confirmAlert } from "@raycast/api";
+
+import { removeTimeEntry } from "@/api";
+import { ExtensionContextProvider } from "@/context/ExtensionContext";
+import { formatSeconds } from "@/helpers/formatSeconds";
+import { Verb, withToast } from "@/helpers/withToast";
+import { useProcessedTimeEntries } from "@/hooks/useProcessedTimeEntries";
+import { useTotalDurationToday } from "@/hooks/useTotalDurationToday";
+
+import UpdateTimeEntryForm from "./UpdateTimeEntryForm";
+
+export function TimeEntriesListView() {
+ const {
+ isLoading,
+ mutateTimeEntries,
+ timeEntries,
+ timeEntriesWithUniqueProjectAndDescription,
+ revalidateTimeEntries,
+ } = useProcessedTimeEntries();
+
+ const totalDurationToday = useTotalDurationToday(timeEntries);
+
+ return (
+
+ {timeEntriesWithUniqueProjectAndDescription.length > 0 && (
+
+ {timeEntriesWithUniqueProjectAndDescription.map((timeEntry) => (
+ ({ tag })),
+ timeEntry.billable ? { tag: { value: "$" } } : {},
+ ]}
+ icon={{ source: Icon.Circle, tintColor: timeEntry.project_color }}
+ actions={
+
+
+
+
+ }
+ />
+
+ {
+ await confirmAlert({
+ title: "Delete Time Entry",
+ message: "Are you sure you want to delete this time entry?",
+ icon: { source: Icon.Trash, tintColor: Color.Red },
+ primaryAction: {
+ title: "Delete",
+ style: Alert.ActionStyle.Destructive,
+ onAction: () => {
+ withToast({
+ noun: "Time Entry",
+ verb: Verb.Delete,
+ action: async () => {
+ await mutateTimeEntries(removeTimeEntry(timeEntry.workspace_id, timeEntry.id));
+ },
+ });
+ },
+ },
+ });
+ }}
+ />
+
+
+ }
+ />
+ ))}
+
+ )}
+
+ );
+}
+
+export default TimeEntriesListView;
diff --git a/extensions/toggl-track/src/components/UpdateTimeEntryForm.tsx b/extensions/toggl-track/src/components/UpdateTimeEntryForm.tsx
new file mode 100644
index 00000000000..58ea56bb3e1
--- /dev/null
+++ b/extensions/toggl-track/src/components/UpdateTimeEntryForm.tsx
@@ -0,0 +1,206 @@
+import { Action, ActionPanel, Form, Icon, Toast, showToast, useNavigation, confirmAlert } from "@raycast/api";
+import { useCachedState, showFailureToast } from "@raycast/utils";
+import { useMemo, useState } from "react";
+
+import { Client, Project, Task, TimeEntry, TimeEntryMetaData, updateTimeEntry, createTask } from "@/api";
+import { useClients, useMe, useProjects, useTags, useTasks, useWorkspaces } from "@/hooks";
+
+interface UpdateTimeEntryFormProps {
+ timeEntry: TimeEntry & TimeEntryMetaData;
+ revalidateTimeEntries: () => void;
+}
+
+function UpdateTimeEntryForm({ timeEntry, revalidateTimeEntries }: UpdateTimeEntryFormProps) {
+ const navigation = useNavigation();
+ const { isLoadingMe } = useMe();
+ const { workspaces, isLoadingWorkspaces } = useWorkspaces();
+ const { clients, isLoadingClients } = useClients();
+ const { projects, isLoadingProjects } = useProjects();
+ const { tasks, isLoadingTasks, revalidateTasks } = useTasks();
+ const { tags, isLoadingTags } = useTags();
+
+ const [selectedWorkspace] = useCachedState("defaultWorkspace", timeEntry.workspace_id);
+ const [selectedClient, setSelectedClient] = useState(() => {
+ return clients.find((client) => client.name === timeEntry.client_name);
+ });
+ const [selectedProject, setSelectedProject] = useState(() => {
+ return projects.find((project) => project.id === timeEntry.project_id);
+ });
+ const [selectedTask, setSelectedTask] = useState(() => {
+ return tasks.find((task) => task.id === timeEntry.task_id);
+ });
+ const [selectedTags, setSelectedTags] = useState(timeEntry.tags);
+ const [billable, setBillable] = useState(timeEntry.billable);
+
+ const [taskSearch, setTaskSearch] = useState("");
+
+ async function handleSubmit(values: { description: string; billable?: boolean }) {
+ try {
+ await showToast(Toast.Style.Animated, "Updating time entry...");
+
+ await updateTimeEntry(timeEntry.workspace_id, timeEntry.id, {
+ description: values.description,
+ billable: values.billable,
+ project_id: selectedProject?.id,
+ task_id: selectedTask?.id,
+ tags: selectedTags,
+ });
+
+ await showToast(Toast.Style.Success, "Updated time entry");
+ navigation.pop();
+ revalidateTimeEntries();
+ } catch (e) {
+ await showToast(Toast.Style.Failure, "Failed to update time entry");
+ }
+ }
+
+ const isWorkspacePremium = useMemo(() => {
+ return workspaces.find((workspace) => workspace.id === selectedWorkspace)?.premium;
+ }, [workspaces, selectedWorkspace]);
+
+ const filteredClients = useMemo(() => {
+ if (selectedProject) return clients.filter((client) => !client.archived && client.id === selectedProject.client_id);
+ else
+ return clients.filter((client) => !client.archived && (client.wid === selectedWorkspace || !selectedWorkspace));
+ }, [projects, selectedWorkspace, selectedProject]);
+
+ const filteredProjects = useMemo(() => {
+ if (selectedClient)
+ return projects.filter((project) => project.client_id === selectedClient.id && project.status != "archived");
+ else
+ return projects.filter(
+ (project) => (project.workspace_id === selectedWorkspace || !selectedWorkspace) && project.status != "archived",
+ );
+ }, [projects, selectedWorkspace, selectedClient]);
+
+ const filteredTasks = useMemo(() => {
+ if (selectedProject) return tasks.filter((task) => task.project_id === selectedProject.id);
+ else if (selectedClient)
+ return tasks.filter(
+ (task) => task.project_id === projects.find((project) => project.client_id === selectedClient.id)?.id,
+ );
+ else return tasks.filter((task) => task.workspace_id === selectedWorkspace || !selectedWorkspace);
+ }, [tasks, selectedWorkspace, selectedClient, selectedProject]);
+
+ const onProjectChange = (projectId: string) => {
+ const project = projects.find((project) => project.id === parseInt(projectId));
+ if (project) setSelectedProject(project);
+ };
+
+ const onTaskChange = async (taskId: string) => {
+ if (taskId == "new_task") {
+ const newTaskName = taskSearch;
+ setTaskSearch("");
+ if (await confirmAlert({ title: "Create new task?", message: "Task name: " + newTaskName })) {
+ const toast = await showToast(Toast.Style.Animated, "Creating task...");
+ try {
+ if (!selectedWorkspace) throw Error("Workspace ID is undefined.");
+ if (!selectedProject) throw Error("Workspace ID is undefined.");
+ const newTask = await createTask(selectedWorkspace, selectedProject.id, newTaskName);
+ revalidateTasks();
+ setSelectedTask(newTask);
+ toast.style = Toast.Style.Success;
+ toast.title = "Created task";
+ } catch (error) {
+ await toast.hide();
+ await showFailureToast(error);
+ }
+ } else {
+ setSelectedTask(undefined);
+ }
+ } else {
+ const task = tasks.find((task) => task.id === parseInt(taskId));
+ setSelectedTask(task);
+ }
+ };
+
+ return (
+
+
+
+ setSelectedClient(clientId === "-1" ? undefined : clients.find((client) => client.id === parseInt(clientId)))
+ }
+ >
+ {!isLoadingClients && (
+ <>
+
+ {filteredClients.map((client) => (
+
+ ))}
+ >
+ )}
+
+
+
+ {!isLoadingProjects && (
+ <>
+
+ {filteredProjects.map((project) => (
+
+ ))}
+ >
+ )}
+
+
+ {selectedProject && (
+ setTaskSearch("")}
+ >
+ {!isLoadingTasks && (
+ <>
+
+ {filteredTasks.map((task) => (
+
+ ))}
+ {taskSearch !== "" && }
+ >
+ )}
+
+ )}
+
+ {selectedProject?.billable && }
+
+
+ {tags
+ .filter((tag) => tag.workspace_id === selectedWorkspace)
+ .map((tag) => (
+
+ ))}
+
+
+ {isWorkspacePremium && }
+
+ );
+}
+
+export default UpdateTimeEntryForm;
diff --git a/extensions/toggl-track/src/index.tsx b/extensions/toggl-track/src/index.tsx
index 3dc174792c6..05cfdff7423 100644
--- a/extensions/toggl-track/src/index.tsx
+++ b/extensions/toggl-track/src/index.tsx
@@ -5,6 +5,7 @@ import duration from "dayjs/plugin/duration";
import { removeTimeEntry } from "@/api/timeEntries";
import TimeEntryForm from "@/components/CreateTimeEntryForm";
import RunningTimeEntry from "@/components/RunningTimeEntry";
+import UpdateTimeEntryForm from "@/components/UpdateTimeEntryForm";
import { ExtensionContextProvider } from "@/context/ExtensionContext";
import { formatSeconds } from "@/helpers/formatSeconds";
import { Verb, withToast } from "@/helpers/withToast";
@@ -72,6 +73,7 @@ function ListView() {
title={timeEntry.description || "No description"}
subtitle={(timeEntry.client_name ? timeEntry.client_name + " | " : "") + (timeEntry.project_name ?? "")}
accessories={[
+ { text: formatSeconds(timeEntry.duration) },
...timeEntry.tags.map((tag) => ({ tag })),
timeEntry.billable ? { tag: { value: "$" } } : {},
]}
@@ -83,6 +85,15 @@ function ListView() {
onSubmit={() => resumeTimeEntry(timeEntry)}
icon={{ source: Icon.Clock }}
/>
+
+
+
+ }
+ />
+
+
+ );
+}