Skip to content
Merged
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
48 changes: 13 additions & 35 deletions ui/src/components/EditEntryDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,7 @@ import { api } from "~/api";
import { fromLocalHHMM, toLocalHHMM } from "~/lib/entryFormat";
import type { Entry } from "~/types";
import Button from "./ui/Button";
import ProjectPicker from "./ui/ProjectPicker";
import TaskPicker from "./ui/TaskPicker";
import ProjectTaskPicker from "./ui/ProjectTaskPicker";
import Toggle from "./ui/Toggle";

export default function EditEntryDialog(props: {
Expand All @@ -30,14 +29,11 @@ export default function EditEntryDialog(props: {
const [projects] = createResource(() => api.listProjects(), {
initialValue: [],
});
// Tasks for the currently-selected project. Re-fetches when projectId
// flips. An empty projectId resolves to an empty list and the TaskPicker
// stays disabled — no point hitting the IPC.
const [tasks] = createResource(
() => projectId(),
async (pid) => (pid ? await api.listTasks(pid) : []),
{ initialValue: [] },
);
// All tasks across all projects, eager-loaded once. The combined picker
// groups by project_id itself.
const [tasks] = createResource(() => api.listTasks(null), {
initialValue: [],
});

const isCompleted = createMemo(() => Boolean(props.entry.end_at));

Expand Down Expand Up @@ -116,36 +112,18 @@ export default function EditEntryDialog(props: {

<div>
<label class="block text-[10px] font-semibold uppercase tracking-[0.08em] text-zinc-400 dark:text-zinc-500">
Project
Project / task
</label>
<div class="mt-1">
<ProjectPicker
value={projectId()}
onChange={(id) => {
// Tasks scope to projects — changing project must discard
// the staged task selection so Save doesn't send a
// task_id that doesn't belong to the new project.
setTaskId(null);
setProjectId(id);
<ProjectTaskPicker
value={{ projectId: projectId(), taskId: taskId() }}
onChange={(v) => {
setProjectId(v.projectId);
setTaskId(v.taskId);
}}
projects={projects() ?? []}
placeholder="No project"
size="sm"
/>
</div>
</div>

<div>
<label class="block text-[10px] font-semibold uppercase tracking-[0.08em] text-zinc-400 dark:text-zinc-500">
Task
</label>
<div class="mt-1">
<TaskPicker
value={taskId()}
onChange={setTaskId}
tasks={tasks() ?? []}
projectSelected={Boolean(projectId())}
placeholder="No task"
placeholder="No project"
size="sm"
/>
</div>
Expand Down
112 changes: 48 additions & 64 deletions ui/src/components/TimerCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,17 +3,16 @@ import { api } from "~/api";
import Duration from "./Duration";
import StartAtPicker, { type StartAtValue } from "./StartAtPicker";
import Button from "./ui/Button";
import ProjectPicker from "./ui/ProjectPicker";
import ProjectTaskPicker from "./ui/ProjectTaskPicker";
import SectionLabel from "./ui/SectionLabel";
import StatusDot from "./ui/StatusDot";
import TaskPicker from "./ui/TaskPicker";
import Toggle from "./ui/Toggle";
import { useTimerStore } from "~/stores/timer";

export default function TimerCard() {
const timer = useTimerStore();
const [description, setDescription] = createSignal("");
const [projectId, setProjectId] = createSignal<string>("");
const [projectId, setProjectId] = createSignal<string | null>(null);
const [taskId, setTaskId] = createSignal<string | null>(null);
const [billable, setBillable] = createSignal(false);
const [startAt, setStartAt] = createSignal<StartAtValue>(null);
Expand All @@ -22,24 +21,30 @@ export default function TimerCard() {
});
const projectList = () => projects() ?? [];

// Tasks for the *start form's* selected project. Re-fetched whenever the
// project changes; an empty project resolves to an empty list and the
// TaskPicker stays disabled (no point hitting the IPC).
const [startFormTasks] = createResource(
() => projectId() || null,
async (pid) => (pid ? await api.listTasks(pid) : []),
{ initialValue: [] },
);
// All tasks across all projects, eager-loaded once. The combined picker
// groups by project_id itself, so we don't have to refetch per selection.
const [tasks] = createResource(() => api.listTasks(null), {
initialValue: [],
});

// Tasks for the *running entry's* project. Same shape, different source —
// the running entry's project_id might differ from the start form's
// (e.g. when the user is editing the live entry's project inline).
const runningProjectId = () => timer.running()?.project_id ?? null;
const [runningTasks] = createResource(
runningProjectId,
async (pid) => (pid ? await api.listTasks(pid) : []),
{ initialValue: [] },
);
/// Apply a project+task change to a running entry. Always clears the
/// task first so a queued patch can't carry a task_id from the old
/// project, then sets the new project, then sets the new task.
async function applyLiveChange(args: {
localUuid: string;
hadTask: boolean;
nextProjectId: string | null;
nextTaskId: string | null;
}) {
if (args.hadTask) {
await api.setEntryTask(args.localUuid, null);
}
await api.setEntryProject(args.localUuid, args.nextProjectId);
if (args.nextTaskId) {
await api.setEntryTask(args.localUuid, args.nextTaskId);
}
await timer.refresh();
}

return (
<div class="rounded-2xl border border-black/[0.06] bg-white p-5 shadow-sm dark:border-white/[0.06] dark:bg-zinc-900">
Expand All @@ -55,7 +60,7 @@ export default function TimerCard() {
timer
.start(
d,
projectId() || undefined,
projectId() ?? undefined,
taskId() ?? undefined,
billable(),
startAt() ?? undefined,
Expand All @@ -65,6 +70,7 @@ export default function TimerCard() {
setBillable(false);
setStartAt(null);
setTaskId(null);
setProjectId(null);
});
}}
>
Expand All @@ -78,26 +84,15 @@ export default function TimerCard() {
<StartAtPicker value={startAt()} onChange={setStartAt} />
<div class="flex items-center gap-2">
<div class="min-w-0 flex-1">
<ProjectPicker
value={projectId() || null}
onChange={(id) => {
// Tasks scope to projects — changing project must
// discard the old task selection or we'd send a
// task_id that doesn't belong to the new project.
setTaskId(null);
setProjectId(id ?? "");
<ProjectTaskPicker
value={{ projectId: projectId(), taskId: taskId() }}
onChange={(v) => {
setProjectId(v.projectId);
setTaskId(v.taskId);
}}
projects={projectList()}
placeholder="No project"
/>
</div>
<div class="min-w-0 flex-1">
<TaskPicker
value={taskId()}
onChange={setTaskId}
tasks={startFormTasks() ?? []}
projectSelected={Boolean(projectId())}
placeholder="No task"
tasks={tasks() ?? []}
placeholder="Project / task"
/>
</div>
<Toggle label="Billable" checked={billable()} onChange={setBillable} />
Expand Down Expand Up @@ -126,33 +121,22 @@ export default function TimerCard() {

<div class="mt-4 flex items-center gap-2">
<div class="min-w-0 flex-1">
<ProjectPicker
value={t().project_id}
onChange={async (id) => {
// Switching project on a live entry: clear the task
// first so the queued update doesn't carry a stale
// task_id from the old project.
if (t().task_id) {
await api.setEntryTask(t().local_uuid, null);
}
await api.setEntryProject(t().local_uuid, id);
await timer.refresh();
<ProjectTaskPicker
value={{
projectId: t().project_id,
taskId: t().task_id,
}}
onChange={(v) =>
applyLiveChange({
localUuid: t().local_uuid,
hadTask: Boolean(t().task_id),
nextProjectId: v.projectId,
nextTaskId: v.taskId,
})
}
projects={projectList()}
placeholder="No project"
size="sm"
/>
</div>
<div class="min-w-0 flex-1">
<TaskPicker
value={t().task_id}
onChange={async (id) => {
await api.setEntryTask(t().local_uuid, id);
await timer.refresh();
}}
tasks={runningTasks() ?? []}
projectSelected={Boolean(t().project_id)}
placeholder="No task"
tasks={tasks() ?? []}
placeholder="Project / task"
size="sm"
/>
</div>
Expand Down
Loading
Loading