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
4 changes: 4 additions & 0 deletions src/app/(app)/actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,10 @@ export async function resumeEntryAction(id: string): Promise<ActionState> {
return mutate((t) => t.timeEntries.startFrom(id));
}

export async function duplicateEntryAction(id: string): Promise<ActionState> {
return mutate((t) => t.timeEntries.duplicate(id));
}

export async function stopTimerAction(): Promise<ActionState> {
return mutate((t) => t.timeEntries.stopTimer());
}
Expand Down
14 changes: 11 additions & 3 deletions src/app/(app)/entry-list.tsx
Original file line number Diff line number Diff line change
@@ -1,13 +1,13 @@
"use client";

import { ChevronDown, ChevronRight, Play, Trash2 } from "lucide-react";
import { ChevronDown, ChevronRight, Copy, Play, Trash2 } from "lucide-react";
import Link from "next/link";
import { useMemo, useState, useTransition } from "react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { formatDayLabel, formatWeekLabel, groupEntries } from "@/lib/entry-groups";
import { formatHms } from "@/lib/time";
import { deleteEntryAction, restoreEntryAction, resumeEntryAction } from "./actions";
import { deleteEntryAction, duplicateEntryAction, restoreEntryAction, resumeEntryAction } from "./actions";
import { EntryDialog } from "./entry-dialog";
import type { CatalogView, EntryView } from "./types";

Expand Down Expand Up @@ -46,6 +46,7 @@ export function EntryList({ entries, catalog, today, currentWeek, loadMoreHref }
run(() => deleteEntryAction(entry.id), "Não foi possível remover.", () => setRemoved({ id: entry.id, label: entry.description || "Registro sem descrição" }));
const undo = (id: string) => run(() => restoreEntryAction(id), "Não foi possível desfazer.", () => setRemoved(null));
const resume = (entry: EntryView) => run(() => resumeEntryAction(entry.id), "Não foi possível iniciar.");
const duplicate = (entry: EntryView) => run(() => duplicateEntryAction(entry.id), "Não foi possível duplicar.");
const toggle = (key: string) =>
setExpanded((current) => {
const next = new Set(current);
Expand Down Expand Up @@ -94,6 +95,13 @@ export function EntryList({ entries, catalog, today, currentWeek, loadMoreHref }
</p>
</div>
<EntryDialog catalog={catalog} entry={entry} today={today} />
{entry.endedAt !== null ? (
<Button variant="ghost" size="icon" aria-label="Duplicar registro" title="Duplicar registro" disabled={pending} onClick={() => duplicate(entry)}>
<Copy aria-hidden />
</Button>
) : (
<span className="size-9 shrink-0" aria-hidden />
)}
<Button variant="ghost" size="icon" aria-label="Remover registro" disabled={pending} onClick={() => remove(entry)}>
<Trash2 aria-hidden />
</Button>
Expand Down Expand Up @@ -168,7 +176,7 @@ export function EntryList({ entries, catalog, today, currentWeek, loadMoreHref }
<p className="font-mono tabular-nums">{formatHms(group.totalSeconds)}</p>
{running && <p className="text-xs text-muted-foreground">+ em andamento</p>}
</div>
<span className="w-[4.5rem] shrink-0" aria-hidden />
<span className="w-[6.75rem] shrink-0" aria-hidden />
</li>
{open && group.entries.map((entry) => entryRow(entry, true))}
</ul>
Expand Down
32 changes: 32 additions & 0 deletions src/server/time-entries.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,38 @@ describe("time entries", () => {
await tA.projects.update(projA.id, { isArchived: false });
});

it("duplicates a finished entry with the same times, project, task, tags and billing flag", async () => {
const source = await tA.timeEntries.createManual({
description: "revisão",
projectId: projA.id,
taskId: taskA.id,
tagIds: [tagA.id],
isBillable: false,
startedAt: "2026-02-27T09:00:00Z",
endedAt: "2026-02-27T10:30:00Z",
});
const copy = await tA.timeEntries.duplicate(source.id);
expect(copy).toMatchObject({
id: expect.not.stringMatching(source.id),
description: "revisão",
projectId: projA.id,
taskId: taskA.id,
tagIds: [tagA.id],
isBillable: false,
startedAt: source.startedAt,
endedAt: source.endedAt,
});
const day = await tA.timeEntries.list({ from: iso("2026-02-27T00:00:00Z"), to: iso("2026-02-28T00:00:00Z") });
expect(day.filter((e) => e.description === "revisão")).toHaveLength(2);
});

it("cannot duplicate a running timer or someone else's entry", async () => {
const running = await tA.timeEntries.startTimer({ description: "em andamento" });
await expect(tA.timeEntries.duplicate(running.id)).rejects.toBeInstanceOf(ValidationError);
const theirs = await tMember.timeEntries.createManual({ startedAt: "2026-02-27T09:00:00Z", endedAt: "2026-02-27T10:00:00Z" });
await expect(tA.timeEntries.duplicate(theirs.id)).rejects.toBeInstanceOf(NotFoundError);
});

it("different users may run timers at the same time", async () => {
await tA.timeEntries.startTimer({});
await expect(tMember.timeEntries.startTimer({})).resolves.toBeDefined();
Expand Down
15 changes: 15 additions & 0 deletions src/server/time-entries.ts
Original file line number Diff line number Diff line change
Expand Up @@ -251,6 +251,21 @@ export function createTimeEntries(db: TenantDb, ctx: TenantContext, deps: Deps)
});
},

/** Copies a finished entry as is (same times, description, project, task, tags and billing flag) as a new entry. */
async duplicate(id: string): Promise<TimeEntry> {
const [source] = await withTags([await getOwn(id)]);
if (!source.endedAt) throw new ValidationError({ form: "Pare o timer antes de duplicar o registro." });
return repo.createManual({
description: source.description,
projectId: source.projectId,
taskId: source.taskId,
tagIds: source.tagIds,
isBillable: source.isBillable,
startedAt: source.startedAt,
endedAt: source.endedAt,
});
},

async stopTimer(): Promise<TimeEntry> {
const { here } = await runningTimer();
if (!here) throw new NotFoundError("Running timer");
Expand Down
Loading