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 }} /> + + + + } + /> + + + ); +}