@liebig-technology/clockodo-cli 0.1.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +58 -23
- package/dist/index.js +496 -59
- package/package.json +11 -13
package/README.md
CHANGED
|
@@ -6,28 +6,31 @@ CLI for the [Clockodo](https://www.clockodo.com) time tracking API. Human-friend
|
|
|
6
6
|
Usage: clockodo [options] [command]
|
|
7
7
|
|
|
8
8
|
Options:
|
|
9
|
-
-
|
|
10
|
-
-
|
|
11
|
-
--no
|
|
12
|
-
--no-
|
|
13
|
-
-
|
|
9
|
+
-V, --version output the version number
|
|
10
|
+
-j, --json Output as JSON
|
|
11
|
+
-p, --plain Output as plain text (no colors)
|
|
12
|
+
--no-color Disable colors
|
|
13
|
+
--no-input Disable interactive prompts
|
|
14
|
+
-v, --verbose Verbose output
|
|
14
15
|
|
|
15
16
|
Commands:
|
|
16
|
-
config
|
|
17
|
-
status
|
|
18
|
-
start
|
|
19
|
-
stop
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
17
|
+
config Manage CLI configuration
|
|
18
|
+
status [options] Show running clock and today's summary
|
|
19
|
+
start [options] Start time tracking
|
|
20
|
+
stop Stop time tracking
|
|
21
|
+
edit [options] Edit the running clock entry
|
|
22
|
+
extend <minutes> Extend the running clock by N minutes
|
|
23
|
+
entries Manage time entries
|
|
24
|
+
customers Manage customers
|
|
25
|
+
projects Manage projects
|
|
26
|
+
services Manage services
|
|
27
|
+
users User management
|
|
28
|
+
report Aggregated time reports
|
|
29
|
+
absences Manage absences
|
|
30
|
+
worktimes [options] Show work time intervals
|
|
31
|
+
userreport [options] Show user report (overtime, holidays, absences)
|
|
32
|
+
userreports [options] Show user reports for all users
|
|
33
|
+
schema Output machine-readable CLI structure (for AI agents)
|
|
31
34
|
```
|
|
32
35
|
|
|
33
36
|
Every command supports `--help` for full usage details.
|
|
@@ -54,18 +57,31 @@ Credentials are stored in `~/.config/clockodo-cli/config.json`. Environment vari
|
|
|
54
57
|
```bash
|
|
55
58
|
# Time tracking
|
|
56
59
|
clockodo start --customer 123 --service 456 --text "Working on feature"
|
|
60
|
+
clockodo start --customer 123 --service 456 --billable # explicit billability
|
|
57
61
|
clockodo stop
|
|
58
62
|
clockodo status
|
|
63
|
+
clockodo edit --text "New description" --billable # edit running entry
|
|
64
|
+
clockodo extend 30 # extend running clock by 30 minutes
|
|
59
65
|
|
|
60
66
|
# Entries
|
|
61
67
|
clockodo entries # list today's entries
|
|
62
68
|
clockodo entries --since 2026-01-01 --until 2026-01-31 # date range
|
|
69
|
+
clockodo entries --billable # only billable entries
|
|
63
70
|
clockodo entries create --from "09:00" --to "12:30" --customer 123 --service 456
|
|
71
|
+
clockodo entries update 42 --text "New description" --customer 789 --service 456
|
|
72
|
+
|
|
73
|
+
# Customers, projects, services
|
|
74
|
+
clockodo customers # list all customers
|
|
75
|
+
clockodo customers create --name "Acme Corp" # create a customer
|
|
76
|
+
clockodo customers update 42 --name "Acme Inc" # rename customer 42
|
|
77
|
+
clockodo customers delete 42 --force # delete without confirmation
|
|
78
|
+
clockodo projects create --name "Website" --customer 42
|
|
79
|
+
clockodo services create --name "Development"
|
|
64
80
|
|
|
65
81
|
# Reports
|
|
66
|
-
clockodo report
|
|
67
|
-
clockodo report week
|
|
68
|
-
clockodo report month
|
|
82
|
+
clockodo report # today, grouped by project
|
|
83
|
+
clockodo report week --group customer # this week by customer
|
|
84
|
+
clockodo report month --group text # this month by description
|
|
69
85
|
|
|
70
86
|
# Absences, work times, user reports
|
|
71
87
|
clockodo absences list --year 2026
|
|
@@ -75,6 +91,25 @@ clockodo userreport --year 2026
|
|
|
75
91
|
|
|
76
92
|
When `--customer` or `--service` is not provided, `start` and `entries create` launch an interactive picker (disable with `--no-input`). You can also set defaults via `clockodo config set`.
|
|
77
93
|
|
|
94
|
+
## Shell Prompt Integration
|
|
95
|
+
|
|
96
|
+
`clockodo status --prompt` outputs a single plain-text line for embedding in your terminal prompt:
|
|
97
|
+
|
|
98
|
+
```bash
|
|
99
|
+
clockodo status --prompt # "Working on feature 1h 23m" or empty if idle
|
|
100
|
+
clockodo status --prompt --json # {"data":{"running":true,"text":"...","elapsed":4980,"formatted":"1h 23m"}}
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
Example for [Starship](https://starship.rs/):
|
|
104
|
+
|
|
105
|
+
```toml
|
|
106
|
+
# ~/.config/starship.toml
|
|
107
|
+
[custom.clockodo]
|
|
108
|
+
command = "clockodo status --prompt"
|
|
109
|
+
when = true
|
|
110
|
+
symbol = "⏱ "
|
|
111
|
+
```
|
|
112
|
+
|
|
78
113
|
## AI Agent Integration
|
|
79
114
|
|
|
80
115
|
```bash
|
package/dist/index.js
CHANGED
|
@@ -290,6 +290,9 @@ function printDetail(entries, options) {
|
|
|
290
290
|
function printSuccess(message) {
|
|
291
291
|
console.error(styleText2("green", `\u2713 ${message}`));
|
|
292
292
|
}
|
|
293
|
+
function printInfo(message) {
|
|
294
|
+
console.error(styleText2("cyan", `\u2139 ${message}`));
|
|
295
|
+
}
|
|
293
296
|
|
|
294
297
|
// src/lib/validate.ts
|
|
295
298
|
function parseId(input, label = "ID") {
|
|
@@ -441,10 +444,10 @@ function registerAbsencesCommands(program2) {
|
|
|
441
444
|
const opts = program2.opts();
|
|
442
445
|
const client = getClient();
|
|
443
446
|
if (!cmdOpts.force && process.stdout.isTTY) {
|
|
444
|
-
const
|
|
447
|
+
const confirm6 = await p.confirm({
|
|
445
448
|
message: `Delete absence ${id}?`
|
|
446
449
|
});
|
|
447
|
-
if (!
|
|
450
|
+
if (!confirm6 || p.isCancel(confirm6)) return;
|
|
448
451
|
}
|
|
449
452
|
const absenceId = parseId(id);
|
|
450
453
|
await client.deleteAbsence({ id: absenceId });
|
|
@@ -458,7 +461,7 @@ function registerAbsencesCommands(program2) {
|
|
|
458
461
|
}
|
|
459
462
|
|
|
460
463
|
// src/commands/clock.ts
|
|
461
|
-
import { Billability } from "clockodo";
|
|
464
|
+
import { Billability, getEntryDurationUntilNow } from "clockodo";
|
|
462
465
|
|
|
463
466
|
// src/lib/prompts.ts
|
|
464
467
|
import * as p2 from "@clack/prompts";
|
|
@@ -562,6 +565,18 @@ function formatDecimalHours(seconds) {
|
|
|
562
565
|
function toClockodoDateTime(date) {
|
|
563
566
|
return date.toISOString().replace(/\.\d{3}Z$/, "Z");
|
|
564
567
|
}
|
|
568
|
+
function parseDateTimeUntil(input) {
|
|
569
|
+
const lower = input.toLowerCase();
|
|
570
|
+
if (lower === "today" || lower === "yesterday" || lower === "tomorrow") {
|
|
571
|
+
const now = /* @__PURE__ */ new Date();
|
|
572
|
+
const offset = lower === "yesterday" ? -1 : lower === "tomorrow" ? 1 : 0;
|
|
573
|
+
return toClockodoDateTime(_endOfDay(addDays(now, offset)));
|
|
574
|
+
}
|
|
575
|
+
if (/^\d{4}-\d{2}-\d{2}$/.test(input)) {
|
|
576
|
+
return toClockodoDateTime(_endOfDay(/* @__PURE__ */ new Date(`${input}T00:00:00`)));
|
|
577
|
+
}
|
|
578
|
+
return parseDateTime(input);
|
|
579
|
+
}
|
|
565
580
|
function parseDateTime(input) {
|
|
566
581
|
const now = /* @__PURE__ */ new Date();
|
|
567
582
|
switch (input.toLowerCase()) {
|
|
@@ -595,7 +610,7 @@ function parseDateTime(input) {
|
|
|
595
610
|
|
|
596
611
|
// src/commands/clock.ts
|
|
597
612
|
function registerClockCommands(program2) {
|
|
598
|
-
program2.command("start").description("Start time tracking").option("-c, --customer <id>", "Customer ID", parseIntStrict).option("-p, --project <id>", "Project ID", parseIntStrict).option("-s, --service <id>", "Service ID", parseIntStrict).option("-t, --text <description>", "Entry description").option("-b, --billable", "Mark as billable").action(async (cmdOpts) => {
|
|
613
|
+
program2.command("start").description("Start time tracking").option("-c, --customer <id>", "Customer ID", parseIntStrict).option("-p, --project <id>", "Project ID", parseIntStrict).option("-s, --service <id>", "Service ID", parseIntStrict).option("-t, --text <description>", "Entry description").option("-b, --billable", "Mark as billable").option("--no-billable", "Mark as not billable").action(async (cmdOpts) => {
|
|
599
614
|
const opts = program2.opts();
|
|
600
615
|
const mode = resolveOutputMode(opts);
|
|
601
616
|
const client = getClient();
|
|
@@ -619,9 +634,11 @@ function registerClockCommands(program2) {
|
|
|
619
634
|
const result = await client.startClock({
|
|
620
635
|
customersId,
|
|
621
636
|
servicesId,
|
|
637
|
+
...cmdOpts.billable !== void 0 && {
|
|
638
|
+
billable: cmdOpts.billable ? Billability.Billable : Billability.NotBillable
|
|
639
|
+
},
|
|
622
640
|
...projectsId && { projectsId },
|
|
623
|
-
...cmdOpts.text && { text: cmdOpts.text }
|
|
624
|
-
...cmdOpts.billable && { billable: Billability.Billable }
|
|
641
|
+
...cmdOpts.text && { text: cmdOpts.text }
|
|
625
642
|
});
|
|
626
643
|
if (mode !== "human") {
|
|
627
644
|
printResult({ data: result.running }, opts);
|
|
@@ -652,6 +669,57 @@ function registerClockCommands(program2) {
|
|
|
652
669
|
console.log(` Description: ${result.stopped.text}`);
|
|
653
670
|
}
|
|
654
671
|
});
|
|
672
|
+
program2.command("edit").description("Edit the running clock entry").option("-c, --customer <id>", "Customer ID", parseIntStrict).option("-p, --project <id>", "Project ID", parseIntStrict).option("-s, --service <id>", "Service ID", parseIntStrict).option("-t, --text <description>", "Entry description").option("-b, --billable", "Mark as billable").option("--no-billable", "Mark as not billable").action(async (cmdOpts) => {
|
|
673
|
+
const opts = program2.opts();
|
|
674
|
+
const client = getClient();
|
|
675
|
+
const clock = await client.getClock();
|
|
676
|
+
if (!clock.running) {
|
|
677
|
+
throw new CliError("No clock is currently running.", ExitCode.EMPTY_RESULTS);
|
|
678
|
+
}
|
|
679
|
+
const updates = { id: clock.running.id };
|
|
680
|
+
if (cmdOpts.customer !== void 0) updates.customersId = cmdOpts.customer;
|
|
681
|
+
if (cmdOpts.project !== void 0) updates.projectsId = cmdOpts.project;
|
|
682
|
+
if (cmdOpts.service !== void 0) updates.servicesId = cmdOpts.service;
|
|
683
|
+
if (cmdOpts.text !== void 0) updates.text = cmdOpts.text;
|
|
684
|
+
if (cmdOpts.billable !== void 0)
|
|
685
|
+
updates.billable = cmdOpts.billable ? Billability.Billable : Billability.NotBillable;
|
|
686
|
+
if (Object.keys(updates).length === 1) {
|
|
687
|
+
throw new CliError(
|
|
688
|
+
"No update flags provided.",
|
|
689
|
+
ExitCode.INVALID_ARGS,
|
|
690
|
+
"Use --text, --customer, --project, --service, --billable, or --no-billable"
|
|
691
|
+
);
|
|
692
|
+
}
|
|
693
|
+
const result = await client.editEntry(updates);
|
|
694
|
+
const mode = resolveOutputMode(opts);
|
|
695
|
+
if (mode !== "human") {
|
|
696
|
+
printResult({ data: result.entry }, opts);
|
|
697
|
+
return;
|
|
698
|
+
}
|
|
699
|
+
printSuccess("Running entry updated");
|
|
700
|
+
});
|
|
701
|
+
program2.command("extend").description("Extend the running clock by N minutes").argument("<minutes>", "Minutes to extend by", parseIntStrict).action(async (minutes) => {
|
|
702
|
+
const opts = program2.opts();
|
|
703
|
+
const client = getClient();
|
|
704
|
+
const clock = await client.getClock();
|
|
705
|
+
if (!clock.running) {
|
|
706
|
+
throw new CliError("No clock is currently running.", ExitCode.EMPTY_RESULTS);
|
|
707
|
+
}
|
|
708
|
+
const durationBefore = getEntryDurationUntilNow(clock.running);
|
|
709
|
+
const duration = durationBefore + minutes * 60;
|
|
710
|
+
const result = await client.changeClockDuration({
|
|
711
|
+
entriesId: clock.running.id,
|
|
712
|
+
durationBefore,
|
|
713
|
+
duration
|
|
714
|
+
});
|
|
715
|
+
const mode = resolveOutputMode(opts);
|
|
716
|
+
if (mode !== "human") {
|
|
717
|
+
printResult({ data: result.updated }, opts);
|
|
718
|
+
return;
|
|
719
|
+
}
|
|
720
|
+
printSuccess(`Clock extended by ${minutes} minutes`);
|
|
721
|
+
console.log(` New duration: ${formatDuration(duration)}`);
|
|
722
|
+
});
|
|
655
723
|
}
|
|
656
724
|
|
|
657
725
|
// src/commands/config.ts
|
|
@@ -712,6 +780,8 @@ function registerConfigCommands(program2) {
|
|
|
712
780
|
}
|
|
713
781
|
|
|
714
782
|
// src/commands/customers.ts
|
|
783
|
+
import * as p4 from "@clack/prompts";
|
|
784
|
+
import { Billability as Billability2 } from "clockodo";
|
|
715
785
|
function registerCustomersCommands(program2) {
|
|
716
786
|
const customers = program2.command("customers").description("Manage customers");
|
|
717
787
|
customers.command("list", { isDefault: true }).description("List customers").option("--active", "Show only active customers").option("--search <text>", "Search by name").action(async (cmdOpts) => {
|
|
@@ -725,6 +795,14 @@ function registerCustomersCommands(program2) {
|
|
|
725
795
|
);
|
|
726
796
|
const items = result.data ?? [];
|
|
727
797
|
const mode = resolveOutputMode(opts);
|
|
798
|
+
if (items.length === 0) {
|
|
799
|
+
if (mode !== "human") {
|
|
800
|
+
printResult({ data: [], meta: { count: 0 } }, opts);
|
|
801
|
+
} else {
|
|
802
|
+
printInfo("No customers found.");
|
|
803
|
+
}
|
|
804
|
+
return;
|
|
805
|
+
}
|
|
728
806
|
if (mode !== "human") {
|
|
729
807
|
printResult({ data: items, meta: { count: items.length } }, opts);
|
|
730
808
|
return;
|
|
@@ -759,27 +837,89 @@ function registerCustomersCommands(program2) {
|
|
|
759
837
|
opts
|
|
760
838
|
);
|
|
761
839
|
});
|
|
840
|
+
customers.command("create").description("Create a customer").requiredOption("--name <name>", "Customer name").option("--number <text>", "Customer number").option("--note <text>", "Note").option("--active", "Set as active (default)").option("--no-active", "Set as inactive").option("--billable", "Set as billable by default").option("--no-billable", "Set as not billable by default").action(async (cmdOpts) => {
|
|
841
|
+
const opts = program2.opts();
|
|
842
|
+
const client = getClient();
|
|
843
|
+
const params = {
|
|
844
|
+
name: cmdOpts.name,
|
|
845
|
+
...cmdOpts.number != null && { number: cmdOpts.number },
|
|
846
|
+
...cmdOpts.note != null && { note: cmdOpts.note },
|
|
847
|
+
...cmdOpts.active !== void 0 && { active: cmdOpts.active },
|
|
848
|
+
...cmdOpts.billable !== void 0 && {
|
|
849
|
+
billableDefault: cmdOpts.billable ? Billability2.Billable : Billability2.NotBillable
|
|
850
|
+
}
|
|
851
|
+
};
|
|
852
|
+
const result = await client.addCustomer(params);
|
|
853
|
+
const mode = resolveOutputMode(opts);
|
|
854
|
+
if (mode !== "human") {
|
|
855
|
+
printResult({ data: result.data }, opts);
|
|
856
|
+
return;
|
|
857
|
+
}
|
|
858
|
+
printSuccess(`Customer created (ID: ${result.data.id})`);
|
|
859
|
+
console.log(` ${result.data.name}`);
|
|
860
|
+
});
|
|
861
|
+
customers.command("update <id>").description("Update a customer").option("--name <name>", "New name").option("--number <text>", "New number").option("--note <text>", "New note").option("--active", "Set as active").option("--no-active", "Set as inactive").option("--billable", "Set as billable by default").option("--no-billable", "Set as not billable by default").action(async (id, cmdOpts) => {
|
|
862
|
+
const opts = program2.opts();
|
|
863
|
+
const client = getClient();
|
|
864
|
+
const params = {
|
|
865
|
+
id: parseId(id),
|
|
866
|
+
...cmdOpts.name != null && { name: cmdOpts.name },
|
|
867
|
+
...cmdOpts.number != null && { number: cmdOpts.number },
|
|
868
|
+
...cmdOpts.note != null && { note: cmdOpts.note },
|
|
869
|
+
...cmdOpts.active !== void 0 && { active: cmdOpts.active },
|
|
870
|
+
...cmdOpts.billable !== void 0 && {
|
|
871
|
+
billableDefault: cmdOpts.billable ? Billability2.Billable : Billability2.NotBillable
|
|
872
|
+
}
|
|
873
|
+
};
|
|
874
|
+
const result = await client.editCustomer(params);
|
|
875
|
+
const mode = resolveOutputMode(opts);
|
|
876
|
+
if (mode !== "human") {
|
|
877
|
+
printResult({ data: result.data }, opts);
|
|
878
|
+
return;
|
|
879
|
+
}
|
|
880
|
+
printSuccess(`Customer ${id} updated`);
|
|
881
|
+
});
|
|
882
|
+
customers.command("delete <id>").description("Delete a customer").option("-f, --force", "Skip confirmation").action(async (id, cmdOpts) => {
|
|
883
|
+
const opts = program2.opts();
|
|
884
|
+
const client = getClient();
|
|
885
|
+
if (!cmdOpts.force && process.stdout.isTTY) {
|
|
886
|
+
const confirm6 = await p4.confirm({
|
|
887
|
+
message: `Delete customer ${id}?`
|
|
888
|
+
});
|
|
889
|
+
if (!confirm6 || p4.isCancel(confirm6)) return;
|
|
890
|
+
}
|
|
891
|
+
const customerId = parseId(id);
|
|
892
|
+
await client.deleteCustomer({ id: customerId });
|
|
893
|
+
const mode = resolveOutputMode(opts);
|
|
894
|
+
if (mode !== "human") {
|
|
895
|
+
printResult({ data: { success: true, id: customerId } }, opts);
|
|
896
|
+
return;
|
|
897
|
+
}
|
|
898
|
+
printSuccess(`Customer ${id} deleted`);
|
|
899
|
+
});
|
|
762
900
|
}
|
|
763
901
|
|
|
764
902
|
// src/commands/entries.ts
|
|
765
903
|
import { styleText as styleText4 } from "util";
|
|
766
|
-
import * as
|
|
767
|
-
import { getEntryDurationUntilNow, isTimeEntry } from "clockodo";
|
|
904
|
+
import * as p5 from "@clack/prompts";
|
|
905
|
+
import { Billability as Billability3, getEntryDurationUntilNow as getEntryDurationUntilNow2, isTimeEntry } from "clockodo";
|
|
768
906
|
function registerEntriesCommands(program2) {
|
|
769
907
|
const entries = program2.command("entries").description("Manage time entries");
|
|
770
|
-
entries.command("list", { isDefault: true }).description("List time entries").option("--since <date>", "Start date (default: today)", "today").option("--until <date>", "End date (default: today)").option("--customer <id>", "Filter by customer ID", parseIntStrict).option("--project <id>", "Filter by project ID", parseIntStrict).option("--service <id>", "Filter by service ID", parseIntStrict).option("--text <search>", "Filter by description text").option(
|
|
908
|
+
entries.command("list", { isDefault: true }).description("List time entries").option("--since <date>", "Start date (default: today)", "today").option("--until <date>", "End date (default: today)").option("--customer <id>", "Filter by customer ID", parseIntStrict).option("--project <id>", "Filter by project ID", parseIntStrict).option("--service <id>", "Filter by service ID", parseIntStrict).option("--text <search>", "Filter by description text").option("-b, --billable", "Show only billable entries").option("--no-billable", "Show only non-billable entries").option(
|
|
771
909
|
"-g, --group <field>",
|
|
772
910
|
"Group by: customer, project, service, text (shows summary table instead)"
|
|
773
911
|
).action(async (cmdOpts) => {
|
|
774
912
|
const opts = program2.opts();
|
|
775
913
|
const client = getClient();
|
|
776
914
|
const since = parseDateTime(cmdOpts.since);
|
|
777
|
-
const until = cmdOpts.until ?
|
|
915
|
+
const until = cmdOpts.until ? parseDateTimeUntil(cmdOpts.until) : toClockodoDateTime(endOfDay(/* @__PURE__ */ new Date()));
|
|
778
916
|
const filter = {};
|
|
779
917
|
if (cmdOpts.customer) filter.customersId = cmdOpts.customer;
|
|
780
918
|
if (cmdOpts.project) filter.projectsId = cmdOpts.project;
|
|
781
919
|
if (cmdOpts.service) filter.servicesId = cmdOpts.service;
|
|
782
920
|
if (cmdOpts.text) filter.text = cmdOpts.text;
|
|
921
|
+
if (cmdOpts.billable !== void 0)
|
|
922
|
+
filter.billable = cmdOpts.billable ? Billability3.Billable : Billability3.NotBillable;
|
|
783
923
|
const result = await client.getEntries({
|
|
784
924
|
timeSince: since,
|
|
785
925
|
timeUntil: until,
|
|
@@ -795,7 +935,7 @@ function registerEntriesCommands(program2) {
|
|
|
795
935
|
}
|
|
796
936
|
return;
|
|
797
937
|
}
|
|
798
|
-
const totalSeconds = entryList.reduce((sum, e) => sum +
|
|
938
|
+
const totalSeconds = entryList.reduce((sum, e) => sum + getEntryDurationUntilNow2(e), 0);
|
|
799
939
|
if (cmdOpts.group) {
|
|
800
940
|
const groupKey = resolveGroupKey(cmdOpts.group);
|
|
801
941
|
const groups = groupEntries(entryList, groupKey);
|
|
@@ -841,7 +981,7 @@ function registerEntriesCommands(program2) {
|
|
|
841
981
|
formatDate(new Date(e.timeSince)),
|
|
842
982
|
formatTime(e.timeSince),
|
|
843
983
|
isTimeEntry(e) && !e.timeUntil ? styleText4("green", "running") : formatTime(e.timeUntil ?? e.timeSince),
|
|
844
|
-
formatDuration(
|
|
984
|
+
formatDuration(getEntryDurationUntilNow2(e)),
|
|
845
985
|
e.text || styleText4("dim", "\u2014")
|
|
846
986
|
]);
|
|
847
987
|
printTable(["ID", "Date", "Start", "End", "Duration", "Description"], rows, opts);
|
|
@@ -862,7 +1002,7 @@ function registerEntriesCommands(program2) {
|
|
|
862
1002
|
return;
|
|
863
1003
|
}
|
|
864
1004
|
const timeUntilDisplay = isTimeEntry(e) && !e.timeUntil ? "running" : formatTime(e.timeUntil ?? e.timeSince);
|
|
865
|
-
const duration =
|
|
1005
|
+
const duration = getEntryDurationUntilNow2(e);
|
|
866
1006
|
printDetail(
|
|
867
1007
|
[
|
|
868
1008
|
["ID", e.id],
|
|
@@ -874,12 +1014,12 @@ function registerEntriesCommands(program2) {
|
|
|
874
1014
|
["Customer ID", e.customersId],
|
|
875
1015
|
["Project ID", e.projectsId ?? null],
|
|
876
1016
|
["Service ID", isTimeEntry(e) ? e.servicesId : null],
|
|
877
|
-
["Billable", e.billable
|
|
1017
|
+
["Billable", formatBillable(e.billable)]
|
|
878
1018
|
],
|
|
879
1019
|
opts
|
|
880
1020
|
);
|
|
881
1021
|
});
|
|
882
|
-
entries.command("create").description("Create a time entry").requiredOption("--from <datetime>", "Start time (e.g., '2024-01-15 09:00' or '09:00')").requiredOption("--to <datetime>", "End time (e.g., '2024-01-15 17:00' or '17:00')").option("-c, --customer <id>", "Customer ID", parseIntStrict).option("-p, --project <id>", "Project ID", parseIntStrict).option("-s, --service <id>", "Service ID", parseIntStrict).option("-t, --text <description>", "Entry description").option("-b, --billable", "Mark as billable").action(async (cmdOpts) => {
|
|
1022
|
+
entries.command("create").description("Create a time entry").requiredOption("--from <datetime>", "Start time (e.g., '2024-01-15 09:00' or '09:00')").requiredOption("--to <datetime>", "End time (e.g., '2024-01-15 17:00' or '17:00')").option("-c, --customer <id>", "Customer ID", parseIntStrict).option("-p, --project <id>", "Project ID", parseIntStrict).option("-s, --service <id>", "Service ID", parseIntStrict).option("-t, --text <description>", "Entry description").option("-b, --billable", "Mark as billable").option("--no-billable", "Mark as not billable").action(async (cmdOpts) => {
|
|
883
1023
|
const opts = program2.opts();
|
|
884
1024
|
const mode = resolveOutputMode(opts);
|
|
885
1025
|
const client = getClient();
|
|
@@ -900,10 +1040,20 @@ function registerEntriesCommands(program2) {
|
|
|
900
1040
|
"Use --customer and --service flags, or set defaults via: clockodo config set"
|
|
901
1041
|
);
|
|
902
1042
|
}
|
|
1043
|
+
let billable;
|
|
1044
|
+
if (cmdOpts.billable !== void 0) {
|
|
1045
|
+
billable = cmdOpts.billable ? Billability3.Billable : Billability3.NotBillable;
|
|
1046
|
+
} else if (projectsId) {
|
|
1047
|
+
const { data: project } = await client.getProject({ id: projectsId });
|
|
1048
|
+
billable = project.billableDefault ? Billability3.Billable : Billability3.NotBillable;
|
|
1049
|
+
} else {
|
|
1050
|
+
const { data: customer } = await client.getCustomer({ id: customersId });
|
|
1051
|
+
billable = customer.billableDefault ? Billability3.Billable : Billability3.NotBillable;
|
|
1052
|
+
}
|
|
903
1053
|
const result = await client.addEntry({
|
|
904
1054
|
customersId,
|
|
905
1055
|
servicesId,
|
|
906
|
-
billable
|
|
1056
|
+
billable,
|
|
907
1057
|
timeSince: parseDateTime(cmdOpts.from),
|
|
908
1058
|
timeUntil: parseDateTime(cmdOpts.to),
|
|
909
1059
|
...projectsId && { projectsId },
|
|
@@ -914,20 +1064,24 @@ function registerEntriesCommands(program2) {
|
|
|
914
1064
|
return;
|
|
915
1065
|
}
|
|
916
1066
|
const entry = result.entry;
|
|
917
|
-
const duration =
|
|
1067
|
+
const duration = getEntryDurationUntilNow2(entry);
|
|
918
1068
|
printSuccess(`Entry created (ID: ${entry.id})`);
|
|
919
1069
|
console.log(
|
|
920
1070
|
` ${formatTime(entry.timeSince)} \u2014 ${entry.timeUntil ? formatTime(entry.timeUntil) : "?"} (${formatDuration(duration)})`
|
|
921
1071
|
);
|
|
922
1072
|
});
|
|
923
|
-
entries.command("update <id>").description("Update a time entry").option("--from <datetime>", "New start time").option("--to <datetime>", "New end time").option("-t, --text <description>", "New description").option("-b, --billable", "Mark as billable").option("--no-billable", "Mark as not billable").action(async (id, cmdOpts) => {
|
|
1073
|
+
entries.command("update <id>").description("Update a time entry").option("--from <datetime>", "New start time").option("--to <datetime>", "New end time").option("-t, --text <description>", "New description").option("-c, --customer <id>", "New customer ID", parseIntStrict).option("--project <id>", "New project ID", parseIntStrict).option("-s, --service <id>", "New service ID", parseIntStrict).option("-b, --billable", "Mark as billable").option("--no-billable", "Mark as not billable").action(async (id, cmdOpts) => {
|
|
924
1074
|
const opts = program2.opts();
|
|
925
1075
|
const client = getClient();
|
|
926
1076
|
const updates = { id: parseId(id) };
|
|
927
1077
|
if (cmdOpts.from) updates.timeSince = parseDateTime(cmdOpts.from);
|
|
928
1078
|
if (cmdOpts.to) updates.timeUntil = parseDateTime(cmdOpts.to);
|
|
929
1079
|
if (cmdOpts.text !== void 0) updates.text = cmdOpts.text;
|
|
930
|
-
if (cmdOpts.
|
|
1080
|
+
if (cmdOpts.customer !== void 0) updates.customersId = cmdOpts.customer;
|
|
1081
|
+
if (cmdOpts.project !== void 0) updates.projectsId = cmdOpts.project;
|
|
1082
|
+
if (cmdOpts.service !== void 0) updates.servicesId = cmdOpts.service;
|
|
1083
|
+
if (cmdOpts.billable !== void 0)
|
|
1084
|
+
updates.billable = cmdOpts.billable ? Billability3.Billable : Billability3.NotBillable;
|
|
931
1085
|
const result = await client.editEntry(updates);
|
|
932
1086
|
const mode = resolveOutputMode(opts);
|
|
933
1087
|
if (mode !== "human") {
|
|
@@ -940,10 +1094,10 @@ function registerEntriesCommands(program2) {
|
|
|
940
1094
|
const opts = program2.opts();
|
|
941
1095
|
const client = getClient();
|
|
942
1096
|
if (!cmdOpts.force && process.stdout.isTTY) {
|
|
943
|
-
const
|
|
1097
|
+
const confirm6 = await p5.confirm({
|
|
944
1098
|
message: `Delete entry ${id}?`
|
|
945
1099
|
});
|
|
946
|
-
if (!
|
|
1100
|
+
if (!confirm6 || p5.isCancel(confirm6)) return;
|
|
947
1101
|
}
|
|
948
1102
|
const entryId = parseId(id);
|
|
949
1103
|
await client.deleteEntry({ id: entryId });
|
|
@@ -989,7 +1143,7 @@ function groupEntries(entries, key) {
|
|
|
989
1143
|
groupValue = val != null ? String(val) : "(none)";
|
|
990
1144
|
}
|
|
991
1145
|
const existing = map.get(groupValue);
|
|
992
|
-
const duration =
|
|
1146
|
+
const duration = getEntryDurationUntilNow2(e);
|
|
993
1147
|
if (existing) {
|
|
994
1148
|
existing.count++;
|
|
995
1149
|
existing.seconds += duration;
|
|
@@ -999,8 +1153,19 @@ function groupEntries(entries, key) {
|
|
|
999
1153
|
}
|
|
1000
1154
|
return [...map.entries()].map(([k, v]) => ({ key: k, ...v })).sort((a, b) => b.seconds - a.seconds);
|
|
1001
1155
|
}
|
|
1156
|
+
function formatBillable(value) {
|
|
1157
|
+
switch (value) {
|
|
1158
|
+
case Billability3.Billable:
|
|
1159
|
+
return "Yes";
|
|
1160
|
+
case Billability3.Billed:
|
|
1161
|
+
return "Billed";
|
|
1162
|
+
default:
|
|
1163
|
+
return "No";
|
|
1164
|
+
}
|
|
1165
|
+
}
|
|
1002
1166
|
|
|
1003
1167
|
// src/commands/projects.ts
|
|
1168
|
+
import * as p6 from "@clack/prompts";
|
|
1004
1169
|
function registerProjectsCommands(program2) {
|
|
1005
1170
|
const projects = program2.command("projects").description("Manage projects");
|
|
1006
1171
|
projects.command("list", { isDefault: true }).description("List projects").option("--customer <id>", "Filter by customer ID", parseIntStrict).option("--active", "Show only active projects").option("--search <text>", "Search by name").action(async (cmdOpts) => {
|
|
@@ -1015,17 +1180,25 @@ function registerProjectsCommands(program2) {
|
|
|
1015
1180
|
);
|
|
1016
1181
|
const items = result.data ?? [];
|
|
1017
1182
|
const mode = resolveOutputMode(opts);
|
|
1183
|
+
if (items.length === 0) {
|
|
1184
|
+
if (mode !== "human") {
|
|
1185
|
+
printResult({ data: [], meta: { count: 0 } }, opts);
|
|
1186
|
+
} else {
|
|
1187
|
+
printInfo("No projects found.");
|
|
1188
|
+
}
|
|
1189
|
+
return;
|
|
1190
|
+
}
|
|
1018
1191
|
if (mode !== "human") {
|
|
1019
1192
|
printResult({ data: items, meta: { count: items.length } }, opts);
|
|
1020
1193
|
return;
|
|
1021
1194
|
}
|
|
1022
|
-
const rows = items.map((
|
|
1023
|
-
String(
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
String(
|
|
1027
|
-
|
|
1028
|
-
|
|
1195
|
+
const rows = items.map((proj) => [
|
|
1196
|
+
String(proj.id),
|
|
1197
|
+
proj.name,
|
|
1198
|
+
proj.number ?? "\u2014",
|
|
1199
|
+
String(proj.customersId),
|
|
1200
|
+
proj.active ? "Yes" : "No",
|
|
1201
|
+
proj.completed ? "Yes" : "No"
|
|
1029
1202
|
]);
|
|
1030
1203
|
printTable(["ID", "Name", "Number", "Customer", "Active", "Completed"], rows, opts);
|
|
1031
1204
|
});
|
|
@@ -1033,39 +1206,212 @@ function registerProjectsCommands(program2) {
|
|
|
1033
1206
|
const opts = program2.opts();
|
|
1034
1207
|
const client = getClient();
|
|
1035
1208
|
const result = await client.getProject({ id: parseId(id) });
|
|
1036
|
-
const
|
|
1209
|
+
const proj = result.data;
|
|
1037
1210
|
const mode = resolveOutputMode(opts);
|
|
1038
1211
|
if (mode !== "human") {
|
|
1039
|
-
printResult({ data:
|
|
1212
|
+
printResult({ data: proj }, opts);
|
|
1040
1213
|
return;
|
|
1041
1214
|
}
|
|
1042
1215
|
printDetail(
|
|
1043
1216
|
[
|
|
1044
|
-
["ID",
|
|
1045
|
-
["Name",
|
|
1046
|
-
["Number",
|
|
1047
|
-
["Customer ID",
|
|
1048
|
-
["Active",
|
|
1049
|
-
["Completed",
|
|
1050
|
-
["Budget",
|
|
1051
|
-
["Budget Type",
|
|
1052
|
-
["Note",
|
|
1217
|
+
["ID", proj.id],
|
|
1218
|
+
["Name", proj.name],
|
|
1219
|
+
["Number", proj.number ?? null],
|
|
1220
|
+
["Customer ID", proj.customersId],
|
|
1221
|
+
["Active", proj.active],
|
|
1222
|
+
["Completed", proj.completed],
|
|
1223
|
+
["Budget", proj.budget?.amount ?? null],
|
|
1224
|
+
["Budget Type", proj.budget?.monetary ? "Money" : "Hours"],
|
|
1225
|
+
["Note", proj.note ?? null]
|
|
1053
1226
|
],
|
|
1054
1227
|
opts
|
|
1055
1228
|
);
|
|
1056
1229
|
});
|
|
1230
|
+
projects.command("create").description("Create a project").requiredOption("--name <name>", "Project name").requiredOption("--customer <id>", "Customer ID", parseIntStrict).option("--number <text>", "Project number").option("--note <text>", "Note").option("--active", "Set as active (default)").option("--no-active", "Set as inactive").option("--billable", "Set as billable by default").option("--no-billable", "Set as not billable by default").action(async (cmdOpts) => {
|
|
1231
|
+
const opts = program2.opts();
|
|
1232
|
+
const client = getClient();
|
|
1233
|
+
const params = {
|
|
1234
|
+
name: cmdOpts.name,
|
|
1235
|
+
customersId: cmdOpts.customer,
|
|
1236
|
+
...cmdOpts.number != null && { number: cmdOpts.number },
|
|
1237
|
+
...cmdOpts.note != null && { note: cmdOpts.note },
|
|
1238
|
+
...cmdOpts.active !== void 0 && { active: cmdOpts.active },
|
|
1239
|
+
...cmdOpts.billable !== void 0 && {
|
|
1240
|
+
billableDefault: cmdOpts.billable
|
|
1241
|
+
}
|
|
1242
|
+
};
|
|
1243
|
+
const result = await client.addProject(params);
|
|
1244
|
+
const mode = resolveOutputMode(opts);
|
|
1245
|
+
if (mode !== "human") {
|
|
1246
|
+
printResult({ data: result.data }, opts);
|
|
1247
|
+
return;
|
|
1248
|
+
}
|
|
1249
|
+
printSuccess(`Project created (ID: ${result.data.id})`);
|
|
1250
|
+
console.log(` ${result.data.name}`);
|
|
1251
|
+
});
|
|
1252
|
+
projects.command("update <id>").description("Update a project").option("--name <name>", "New name").option("--customer <id>", "New customer ID", parseIntStrict).option("--number <text>", "New number").option("--note <text>", "New note").option("--active", "Set as active").option("--no-active", "Set as inactive").option("--billable", "Set as billable by default").option("--no-billable", "Set as not billable by default").action(async (id, cmdOpts) => {
|
|
1253
|
+
const opts = program2.opts();
|
|
1254
|
+
const client = getClient();
|
|
1255
|
+
const params = {
|
|
1256
|
+
id: parseId(id),
|
|
1257
|
+
...cmdOpts.name != null && { name: cmdOpts.name },
|
|
1258
|
+
...cmdOpts.customer != null && { customersId: cmdOpts.customer },
|
|
1259
|
+
...cmdOpts.number != null && { number: cmdOpts.number },
|
|
1260
|
+
...cmdOpts.note != null && { note: cmdOpts.note },
|
|
1261
|
+
...cmdOpts.active !== void 0 && { active: cmdOpts.active },
|
|
1262
|
+
...cmdOpts.billable !== void 0 && {
|
|
1263
|
+
billableDefault: cmdOpts.billable
|
|
1264
|
+
}
|
|
1265
|
+
};
|
|
1266
|
+
const result = await client.editProject(params);
|
|
1267
|
+
const mode = resolveOutputMode(opts);
|
|
1268
|
+
if (mode !== "human") {
|
|
1269
|
+
printResult({ data: result.data }, opts);
|
|
1270
|
+
return;
|
|
1271
|
+
}
|
|
1272
|
+
printSuccess(`Project ${id} updated`);
|
|
1273
|
+
});
|
|
1274
|
+
projects.command("delete <id>").description("Delete a project").option("-f, --force", "Skip confirmation").action(async (id, cmdOpts) => {
|
|
1275
|
+
const opts = program2.opts();
|
|
1276
|
+
const client = getClient();
|
|
1277
|
+
if (!cmdOpts.force && process.stdout.isTTY) {
|
|
1278
|
+
const confirm6 = await p6.confirm({
|
|
1279
|
+
message: `Delete project ${id}?`
|
|
1280
|
+
});
|
|
1281
|
+
if (!confirm6 || p6.isCancel(confirm6)) return;
|
|
1282
|
+
}
|
|
1283
|
+
const projectId = parseId(id);
|
|
1284
|
+
await client.deleteProject({ id: projectId });
|
|
1285
|
+
const mode = resolveOutputMode(opts);
|
|
1286
|
+
if (mode !== "human") {
|
|
1287
|
+
printResult({ data: { success: true, id: projectId } }, opts);
|
|
1288
|
+
return;
|
|
1289
|
+
}
|
|
1290
|
+
printSuccess(`Project ${id} deleted`);
|
|
1291
|
+
});
|
|
1057
1292
|
}
|
|
1058
1293
|
|
|
1059
1294
|
// src/commands/report.ts
|
|
1060
1295
|
import { styleText as styleText5 } from "util";
|
|
1296
|
+
import { getEntryDurationUntilNow as getEntryDurationUntilNow3 } from "clockodo";
|
|
1297
|
+
var GROUP_ALIASES = {
|
|
1298
|
+
customer: "customers_id",
|
|
1299
|
+
customers: "customers_id",
|
|
1300
|
+
customers_id: "customers_id",
|
|
1301
|
+
project: "projects_id",
|
|
1302
|
+
projects: "projects_id",
|
|
1303
|
+
projects_id: "projects_id",
|
|
1304
|
+
service: "services_id",
|
|
1305
|
+
services: "services_id",
|
|
1306
|
+
services_id: "services_id",
|
|
1307
|
+
text: "text",
|
|
1308
|
+
description: "text"
|
|
1309
|
+
};
|
|
1310
|
+
function resolveReportGroupKey(input) {
|
|
1311
|
+
const resolved = GROUP_ALIASES[input.toLowerCase()];
|
|
1312
|
+
if (!resolved) {
|
|
1313
|
+
throw new CliError(
|
|
1314
|
+
`Unknown group field: "${input}". Valid options: customer, project, service, text`,
|
|
1315
|
+
ExitCode.INVALID_ARGS
|
|
1316
|
+
);
|
|
1317
|
+
}
|
|
1318
|
+
return resolved;
|
|
1319
|
+
}
|
|
1320
|
+
function groupEntriesByText(entries) {
|
|
1321
|
+
const map = /* @__PURE__ */ new Map();
|
|
1322
|
+
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
1323
|
+
for (const e of entries) {
|
|
1324
|
+
const groupKey = e.text || "(no description)";
|
|
1325
|
+
const duration = getEntryDurationUntilNow3(e);
|
|
1326
|
+
const existing = map.get(groupKey);
|
|
1327
|
+
const timeRange = { since: e.timeSince, until: e.timeUntil ?? now };
|
|
1328
|
+
if (existing) {
|
|
1329
|
+
existing.count++;
|
|
1330
|
+
existing.seconds += duration;
|
|
1331
|
+
existing.timeRanges.push(timeRange);
|
|
1332
|
+
} else {
|
|
1333
|
+
map.set(groupKey, {
|
|
1334
|
+
key: groupKey,
|
|
1335
|
+
count: 1,
|
|
1336
|
+
seconds: duration,
|
|
1337
|
+
timeRanges: [timeRange]
|
|
1338
|
+
});
|
|
1339
|
+
}
|
|
1340
|
+
}
|
|
1341
|
+
return [...map.values()].sort((a, b) => b.seconds - a.seconds);
|
|
1342
|
+
}
|
|
1343
|
+
function buildFilter(cmdOpts) {
|
|
1344
|
+
const filter = {};
|
|
1345
|
+
if (cmdOpts.customer !== void 0) filter.customersId = cmdOpts.customer;
|
|
1346
|
+
if (cmdOpts.project !== void 0) filter.projectsId = cmdOpts.project;
|
|
1347
|
+
if (cmdOpts.service !== void 0) filter.servicesId = cmdOpts.service;
|
|
1348
|
+
if (cmdOpts.text !== void 0) filter.text = cmdOpts.text;
|
|
1349
|
+
if (cmdOpts.user !== void 0) filter.usersId = cmdOpts.user;
|
|
1350
|
+
return Object.keys(filter).length > 0 ? filter : void 0;
|
|
1351
|
+
}
|
|
1352
|
+
async function runTextReport(program2, since, until, cmdOpts) {
|
|
1353
|
+
const opts = program2.opts();
|
|
1354
|
+
const client = getClient();
|
|
1355
|
+
const filter = buildFilter(cmdOpts);
|
|
1356
|
+
const result = await client.getEntries({
|
|
1357
|
+
timeSince: toClockodoDateTime(since),
|
|
1358
|
+
timeUntil: toClockodoDateTime(until),
|
|
1359
|
+
...filter && { filter }
|
|
1360
|
+
});
|
|
1361
|
+
const entryList = result.entries ?? [];
|
|
1362
|
+
const groups = groupEntriesByText(entryList);
|
|
1363
|
+
const totalSeconds = groups.reduce((sum, g) => sum + g.seconds, 0);
|
|
1364
|
+
const mode = resolveOutputMode(opts);
|
|
1365
|
+
if (mode !== "human") {
|
|
1366
|
+
printResult(
|
|
1367
|
+
{
|
|
1368
|
+
data: {
|
|
1369
|
+
period: { since: formatDate(since), until: formatDate(until) },
|
|
1370
|
+
groups,
|
|
1371
|
+
total: { seconds: totalSeconds, formatted: formatDuration(totalSeconds) }
|
|
1372
|
+
}
|
|
1373
|
+
},
|
|
1374
|
+
opts
|
|
1375
|
+
);
|
|
1376
|
+
return;
|
|
1377
|
+
}
|
|
1378
|
+
printReportHeader(since, until);
|
|
1379
|
+
if (groups.length === 0) {
|
|
1380
|
+
console.log(styleText5("dim", " No entries found for this period."));
|
|
1381
|
+
return;
|
|
1382
|
+
}
|
|
1383
|
+
const rows = groups.map((g) => {
|
|
1384
|
+
const ranges = g.timeRanges.map((r) => `${formatTime(r.since)}\u2013${formatTime(r.until)}`).join(", ");
|
|
1385
|
+
return [g.key, formatDuration(g.seconds), formatDecimalHours(g.seconds), ranges];
|
|
1386
|
+
});
|
|
1387
|
+
printTable(["Description", "Duration", "Hours", "Time Ranges"], rows, opts);
|
|
1388
|
+
printReportTotal(totalSeconds);
|
|
1389
|
+
}
|
|
1390
|
+
function printReportHeader(since, until) {
|
|
1391
|
+
console.log();
|
|
1392
|
+
console.log(` ${styleText5("bold", "Report")}: ${formatDate(since)} \u2014 ${formatDate(until)}`);
|
|
1393
|
+
console.log();
|
|
1394
|
+
}
|
|
1395
|
+
function printReportTotal(totalSeconds) {
|
|
1396
|
+
console.log();
|
|
1397
|
+
console.log(
|
|
1398
|
+
` ${styleText5("bold", "Total")}: ${formatDuration(totalSeconds)} (${formatDecimalHours(totalSeconds)})`
|
|
1399
|
+
);
|
|
1400
|
+
console.log();
|
|
1401
|
+
}
|
|
1061
1402
|
async function runReport(program2, since, until, cmdOpts) {
|
|
1403
|
+
const groupField = resolveReportGroupKey(cmdOpts.group ?? "project");
|
|
1404
|
+
if (groupField === "text") {
|
|
1405
|
+
return runTextReport(program2, since, until, cmdOpts);
|
|
1406
|
+
}
|
|
1062
1407
|
const opts = program2.opts();
|
|
1063
1408
|
const client = getClient();
|
|
1064
|
-
const
|
|
1409
|
+
const filter = buildFilter(cmdOpts);
|
|
1065
1410
|
const result = await client.getEntryGroups({
|
|
1066
1411
|
timeSince: toClockodoDateTime(since),
|
|
1067
1412
|
timeUntil: toClockodoDateTime(until),
|
|
1068
|
-
grouping
|
|
1413
|
+
grouping: [groupField],
|
|
1414
|
+
...filter && { filter }
|
|
1069
1415
|
});
|
|
1070
1416
|
const groups = result.groups ?? [];
|
|
1071
1417
|
const totalSeconds = groups.reduce((sum, g) => sum + (g.duration ?? 0), 0);
|
|
@@ -1083,9 +1429,7 @@ async function runReport(program2, since, until, cmdOpts) {
|
|
|
1083
1429
|
);
|
|
1084
1430
|
return;
|
|
1085
1431
|
}
|
|
1086
|
-
|
|
1087
|
-
console.log(` ${styleText5("bold", "Report")}: ${formatDate(since)} \u2014 ${formatDate(until)}`);
|
|
1088
|
-
console.log();
|
|
1432
|
+
printReportHeader(since, until);
|
|
1089
1433
|
if (groups.length === 0) {
|
|
1090
1434
|
console.log(styleText5("dim", " No entries found for this period."));
|
|
1091
1435
|
return;
|
|
@@ -1096,33 +1440,39 @@ async function runReport(program2, since, until, cmdOpts) {
|
|
|
1096
1440
|
formatDecimalHours(g.duration ?? 0)
|
|
1097
1441
|
]);
|
|
1098
1442
|
printTable(["Name", "Duration", "Hours"], rows, opts);
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
);
|
|
1103
|
-
console.log();
|
|
1443
|
+
printReportTotal(totalSeconds);
|
|
1444
|
+
}
|
|
1445
|
+
function addFilterOptions(cmd) {
|
|
1446
|
+
return cmd.option("--customer <id>", "Filter by customer ID", parseIntStrict).option("--project <id>", "Filter by project ID", parseIntStrict).option("--service <id>", "Filter by service ID", parseIntStrict).option("--text <text>", "Filter by description text").option("--user <id>", "Filter by user ID", parseIntStrict);
|
|
1104
1447
|
}
|
|
1105
1448
|
function registerReportCommands(program2) {
|
|
1106
1449
|
const report = program2.command("report").description("Aggregated time reports");
|
|
1107
|
-
|
|
1108
|
-
"-g, --group <field>",
|
|
1109
|
-
"Group by: projects_id, customers_id, services_id",
|
|
1110
|
-
"projects_id"
|
|
1450
|
+
addFilterOptions(
|
|
1451
|
+
report.command("today", { isDefault: true }).description("Today's summary").option("-g, --group <field>", "Group by: customer, project, service, text", "project")
|
|
1111
1452
|
).action(async (cmdOpts) => {
|
|
1112
1453
|
const now = /* @__PURE__ */ new Date();
|
|
1113
1454
|
await runReport(program2, startOfDay(now), endOfDay(now), cmdOpts);
|
|
1114
1455
|
});
|
|
1115
|
-
|
|
1456
|
+
addFilterOptions(
|
|
1457
|
+
report.command("week").description("This week's summary (Mon-Sun)").option("-g, --group <field>", "Group by: customer, project, service, text", "project")
|
|
1458
|
+
).action(async (cmdOpts) => {
|
|
1116
1459
|
const now = /* @__PURE__ */ new Date();
|
|
1117
1460
|
await runReport(program2, startOfWeek(now), endOfWeek(now), cmdOpts);
|
|
1118
1461
|
});
|
|
1119
|
-
|
|
1462
|
+
addFilterOptions(
|
|
1463
|
+
report.command("month").description("This month's summary").option("-g, --group <field>", "Group by: customer, project, service, text", "project")
|
|
1464
|
+
).action(async (cmdOpts) => {
|
|
1120
1465
|
const now = /* @__PURE__ */ new Date();
|
|
1121
1466
|
await runReport(program2, startOfMonth(now), endOfMonth(now), cmdOpts);
|
|
1122
1467
|
});
|
|
1123
|
-
|
|
1468
|
+
addFilterOptions(
|
|
1469
|
+
report.command("custom").description("Custom date range report").requiredOption("--since <date>", "Start date").requiredOption("--until <date>", "End date").option("-g, --group <field>", "Group by: customer, project, service, text", "project")
|
|
1470
|
+
).action(async (cmdOpts) => {
|
|
1124
1471
|
const since = new Date(parseDateTime(cmdOpts.since));
|
|
1125
|
-
const until = new Date(
|
|
1472
|
+
const until = new Date(parseDateTimeUntil(cmdOpts.until));
|
|
1473
|
+
if (since >= until) {
|
|
1474
|
+
throw new CliError("--since must be before --until", ExitCode.INVALID_ARGS);
|
|
1475
|
+
}
|
|
1126
1476
|
await runReport(program2, since, until, cmdOpts);
|
|
1127
1477
|
});
|
|
1128
1478
|
}
|
|
@@ -1159,6 +1509,7 @@ function registerSchemaCommand(program2) {
|
|
|
1159
1509
|
}
|
|
1160
1510
|
|
|
1161
1511
|
// src/commands/services.ts
|
|
1512
|
+
import * as p7 from "@clack/prompts";
|
|
1162
1513
|
function registerServicesCommands(program2) {
|
|
1163
1514
|
const services = program2.command("services").description("Manage services");
|
|
1164
1515
|
services.command("list", { isDefault: true }).description("List services").option("--active", "Show only active services").option("--search <text>", "Search by name").action(async (cmdOpts) => {
|
|
@@ -1172,6 +1523,14 @@ function registerServicesCommands(program2) {
|
|
|
1172
1523
|
);
|
|
1173
1524
|
const items = result.data ?? [];
|
|
1174
1525
|
const mode = resolveOutputMode(opts);
|
|
1526
|
+
if (items.length === 0) {
|
|
1527
|
+
if (mode !== "human") {
|
|
1528
|
+
printResult({ data: [], meta: { count: 0 } }, opts);
|
|
1529
|
+
} else {
|
|
1530
|
+
printInfo("No services found.");
|
|
1531
|
+
}
|
|
1532
|
+
return;
|
|
1533
|
+
}
|
|
1175
1534
|
if (mode !== "human") {
|
|
1176
1535
|
printResult({ data: items, meta: { count: items.length } }, opts);
|
|
1177
1536
|
return;
|
|
@@ -1205,16 +1564,94 @@ function registerServicesCommands(program2) {
|
|
|
1205
1564
|
opts
|
|
1206
1565
|
);
|
|
1207
1566
|
});
|
|
1567
|
+
services.command("create").description("Create a service").requiredOption("--name <name>", "Service name").option("--number <text>", "Service number").option("--note <text>", "Note").option("--active", "Set as active (default)").option("--no-active", "Set as inactive").action(async (cmdOpts) => {
|
|
1568
|
+
const opts = program2.opts();
|
|
1569
|
+
const client = getClient();
|
|
1570
|
+
const params = {
|
|
1571
|
+
name: cmdOpts.name,
|
|
1572
|
+
...cmdOpts.number != null && { number: cmdOpts.number },
|
|
1573
|
+
...cmdOpts.note != null && { note: cmdOpts.note },
|
|
1574
|
+
...cmdOpts.active !== void 0 && { active: cmdOpts.active }
|
|
1575
|
+
};
|
|
1576
|
+
const result = await client.addService(params);
|
|
1577
|
+
const mode = resolveOutputMode(opts);
|
|
1578
|
+
if (mode !== "human") {
|
|
1579
|
+
printResult({ data: result.data }, opts);
|
|
1580
|
+
return;
|
|
1581
|
+
}
|
|
1582
|
+
printSuccess(`Service created (ID: ${result.data.id})`);
|
|
1583
|
+
console.log(` ${result.data.name}`);
|
|
1584
|
+
});
|
|
1585
|
+
services.command("update <id>").description("Update a service").option("--name <name>", "New name").option("--number <text>", "New number").option("--note <text>", "New note").option("--active", "Set as active").option("--no-active", "Set as inactive").action(async (id, cmdOpts) => {
|
|
1586
|
+
const opts = program2.opts();
|
|
1587
|
+
const client = getClient();
|
|
1588
|
+
const params = {
|
|
1589
|
+
id: parseId(id),
|
|
1590
|
+
...cmdOpts.name != null && { name: cmdOpts.name },
|
|
1591
|
+
...cmdOpts.number != null && { number: cmdOpts.number },
|
|
1592
|
+
...cmdOpts.note != null && { note: cmdOpts.note },
|
|
1593
|
+
...cmdOpts.active !== void 0 && { active: cmdOpts.active }
|
|
1594
|
+
};
|
|
1595
|
+
const result = await client.editService(params);
|
|
1596
|
+
const mode = resolveOutputMode(opts);
|
|
1597
|
+
if (mode !== "human") {
|
|
1598
|
+
printResult({ data: result.data }, opts);
|
|
1599
|
+
return;
|
|
1600
|
+
}
|
|
1601
|
+
printSuccess(`Service ${id} updated`);
|
|
1602
|
+
});
|
|
1603
|
+
services.command("delete <id>").description("Delete a service").option("-f, --force", "Skip confirmation").action(async (id, cmdOpts) => {
|
|
1604
|
+
const opts = program2.opts();
|
|
1605
|
+
const client = getClient();
|
|
1606
|
+
if (!cmdOpts.force && process.stdout.isTTY) {
|
|
1607
|
+
const confirm6 = await p7.confirm({
|
|
1608
|
+
message: `Delete service ${id}?`
|
|
1609
|
+
});
|
|
1610
|
+
if (!confirm6 || p7.isCancel(confirm6)) return;
|
|
1611
|
+
}
|
|
1612
|
+
const serviceId = parseId(id);
|
|
1613
|
+
await client.deleteService({ id: serviceId });
|
|
1614
|
+
const mode = resolveOutputMode(opts);
|
|
1615
|
+
if (mode !== "human") {
|
|
1616
|
+
printResult({ data: { success: true, id: serviceId } }, opts);
|
|
1617
|
+
return;
|
|
1618
|
+
}
|
|
1619
|
+
printSuccess(`Service ${id} deleted`);
|
|
1620
|
+
});
|
|
1208
1621
|
}
|
|
1209
1622
|
|
|
1210
1623
|
// src/commands/status.ts
|
|
1211
1624
|
import { styleText as styleText6 } from "util";
|
|
1212
1625
|
import { isTimeEntry as isTimeEntry2 } from "clockodo";
|
|
1213
1626
|
function registerStatusCommand(program2) {
|
|
1214
|
-
program2.command("status").description("Show running clock and today's summary").action(async () => {
|
|
1627
|
+
program2.command("status").description("Show running clock and today's summary").option("--prompt", "Single-line output for shell prompts (p10k, starship)").action(async (cmdOpts) => {
|
|
1215
1628
|
const opts = program2.opts();
|
|
1216
1629
|
const client = getClient();
|
|
1217
1630
|
const mode = resolveOutputMode(opts);
|
|
1631
|
+
if (cmdOpts.prompt) {
|
|
1632
|
+
const clock = await client.getClock();
|
|
1633
|
+
const running2 = clock.running;
|
|
1634
|
+
const elapsed = running2 ? elapsedSince(running2.timeSince) : 0;
|
|
1635
|
+
if (mode !== "human") {
|
|
1636
|
+
printResult(
|
|
1637
|
+
{
|
|
1638
|
+
data: {
|
|
1639
|
+
running: !!running2,
|
|
1640
|
+
text: running2?.text ?? null,
|
|
1641
|
+
elapsed,
|
|
1642
|
+
formatted: formatDuration(elapsed)
|
|
1643
|
+
}
|
|
1644
|
+
},
|
|
1645
|
+
opts
|
|
1646
|
+
);
|
|
1647
|
+
return;
|
|
1648
|
+
}
|
|
1649
|
+
if (running2) {
|
|
1650
|
+
const parts = [running2.text, formatDuration(elapsed)].filter(Boolean);
|
|
1651
|
+
console.log(parts.join(" "));
|
|
1652
|
+
}
|
|
1653
|
+
return;
|
|
1654
|
+
}
|
|
1218
1655
|
const now = /* @__PURE__ */ new Date();
|
|
1219
1656
|
const [clockResult, entriesResult] = await Promise.all([
|
|
1220
1657
|
client.getClock(),
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@liebig-technology/clockodo-cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"description": "AI-friendly CLI for the Clockodo time tracking API",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -13,17 +13,6 @@
|
|
|
13
13
|
"engines": {
|
|
14
14
|
"node": ">=22.0.0"
|
|
15
15
|
},
|
|
16
|
-
"scripts": {
|
|
17
|
-
"build": "tsup",
|
|
18
|
-
"dev": "tsx src/index.ts",
|
|
19
|
-
"typecheck": "tsc --noEmit",
|
|
20
|
-
"lint": "biome check .",
|
|
21
|
-
"lint:fix": "biome check --write .",
|
|
22
|
-
"test": "vitest run",
|
|
23
|
-
"test:watch": "vitest",
|
|
24
|
-
"prepublishOnly": "pnpm build",
|
|
25
|
-
"prepare": "husky"
|
|
26
|
-
},
|
|
27
16
|
"keywords": [
|
|
28
17
|
"clockodo",
|
|
29
18
|
"time-tracking",
|
|
@@ -62,5 +51,14 @@
|
|
|
62
51
|
"*": [
|
|
63
52
|
"biome check --write --no-errors-on-unmatched --files-ignore-unknown=true"
|
|
64
53
|
]
|
|
54
|
+
},
|
|
55
|
+
"scripts": {
|
|
56
|
+
"build": "tsup",
|
|
57
|
+
"dev": "tsx src/index.ts",
|
|
58
|
+
"typecheck": "tsc --noEmit",
|
|
59
|
+
"lint": "biome check .",
|
|
60
|
+
"lint:fix": "biome check --write .",
|
|
61
|
+
"test": "vitest run",
|
|
62
|
+
"test:watch": "vitest"
|
|
65
63
|
}
|
|
66
|
-
}
|
|
64
|
+
}
|