loop-task 1.3.0 → 1.4.1

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.
Files changed (43) hide show
  1. package/README.md +279 -195
  2. package/dist/board/App.js +225 -87
  3. package/dist/board/components/ActionButtons.js +28 -8
  4. package/dist/board/components/CreateForm.js +230 -70
  5. package/dist/board/components/CreateProjectModal.js +104 -0
  6. package/dist/board/components/DeleteProjectConfirm.js +53 -0
  7. package/dist/board/components/EditProjectModal.js +106 -0
  8. package/dist/board/components/FilterBar.js +5 -4
  9. package/dist/board/components/Footer.js +19 -6
  10. package/dist/board/components/Header.js +33 -3
  11. package/dist/board/components/HelpModal.js +11 -10
  12. package/dist/board/components/Inspector.js +1 -1
  13. package/dist/board/components/LogModal.js +39 -8
  14. package/dist/board/components/Navigator.js +30 -14
  15. package/dist/board/components/ProjectsModal.js +70 -0
  16. package/dist/board/components/ProjectsPage.js +105 -0
  17. package/dist/board/components/RunHistory.js +48 -9
  18. package/dist/board/components/TaskBrowser.js +96 -0
  19. package/dist/board/components/TaskFilterBar.js +6 -0
  20. package/dist/board/components/TaskForm.js +175 -0
  21. package/dist/board/daemon.js +56 -0
  22. package/dist/board/format.js +19 -12
  23. package/dist/board/hooks/useBoardKeybindings.js +264 -156
  24. package/dist/board/hooks/useTaskKeybindings.js +130 -0
  25. package/dist/board/router.js +16 -0
  26. package/dist/board/state.js +18 -14
  27. package/dist/cli.js +61 -6
  28. package/dist/client/commands.js +157 -1
  29. package/dist/config/constants.js +15 -0
  30. package/dist/config/paths.js +6 -0
  31. package/dist/core/command-runner.js +2 -0
  32. package/dist/core/foreground-loop.js +4 -1
  33. package/dist/core/loop-controller.js +210 -32
  34. package/dist/daemon/index.js +5 -2
  35. package/dist/daemon/manager.js +113 -34
  36. package/dist/daemon/projects.js +104 -0
  37. package/dist/daemon/server.js +177 -6
  38. package/dist/daemon/state.js +32 -1
  39. package/dist/daemon/task-manager.js +58 -0
  40. package/dist/i18n/en.json +140 -21
  41. package/dist/loop-config.js +13 -4
  42. package/dist/shared/clipboard.js +19 -0
  43. package/package.json +13 -3
@@ -0,0 +1,96 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "@opentui/react/jsx-runtime";
2
+ import { useEffect, useRef } from "react";
3
+ import { useTerminalDimensions } from "@opentui/react";
4
+ import { t } from "../../i18n/index.js";
5
+ import { commandLine, truncate } from "../format.js";
6
+ import { useHoverState } from "../hooks/useHoverState.js";
7
+ import { HOVER_BG, ENTITY_COLORS } from "../../config/constants.js";
8
+ function fit(text, width) {
9
+ if (width <= 0)
10
+ return "";
11
+ if (text.length <= width)
12
+ return text.padEnd(width);
13
+ return truncate(text, width);
14
+ }
15
+ const TASK_PANEL_ORDER = ["search", "new", "tasks", "actions"];
16
+ const TASK_ACTIONS = ["select", "edit", "delete"];
17
+ export const TASK_ACTION_COUNT = TASK_ACTIONS.length;
18
+ export const TASK_ACTION_KEYS = [...TASK_ACTIONS];
19
+ export function nextTaskPanel(current, direction) {
20
+ const idx = TASK_PANEL_ORDER.indexOf(current);
21
+ return TASK_PANEL_ORDER[(idx + (direction === "right" ? 1 : TASK_PANEL_ORDER.length - 1)) % TASK_PANEL_ORDER.length];
22
+ }
23
+ export function TaskNavigator(props) {
24
+ const { visible, total, selectedIndex, focused, query, onSelect, onActivate } = props;
25
+ const { width, height } = useTerminalDimensions();
26
+ const scrollRef = useRef(null);
27
+ const panelWidth = width * 0.55 - 4;
28
+ const panelHeight = height - 7;
29
+ const nameW = 20;
30
+ const chainsW = 14;
31
+ const fixedOverhead = 2 + nameW + 2 + chainsW;
32
+ const cmdW = Math.max(10, panelWidth - fixedOverhead);
33
+ const header = " " +
34
+ fit(t("board.taskHeaderName"), nameW) +
35
+ " " +
36
+ fit(t("board.taskHeaderCommand"), cmdW) +
37
+ " " +
38
+ t("board.taskHeaderChains");
39
+ useEffect(() => {
40
+ const id = `task-row-${selectedIndex}`;
41
+ scrollRef.current?.scrollChildIntoView(id);
42
+ }, [selectedIndex]);
43
+ return (_jsxs("box", { title: t("board.taskBrowserTitle", { visible: visible.length, total }), border: true, borderColor: focused ? ENTITY_COLORS.task : "#2e2545", style: { width: "55%", flexShrink: 0, flexDirection: "column", backgroundColor: "#0b0b0b", overflow: "hidden" }, children: [_jsx("text", { fg: "#6b7280", children: header }), visible.length === 0 ? (_jsx("text", { fg: "#9ca3af", children: t("board.taskBrowserEmpty") })) : (_jsx("scrollbox", { ref: scrollRef, style: { flexGrow: 1, maxHeight: panelHeight, backgroundColor: "#0b0b0b" }, children: visible.map((task, index) => (_jsx(TaskNavRow, { id: `task-row-${index}`, task: task, index: index, isSelected: index === selectedIndex, focused: focused, nameW: nameW, cmdW: cmdW, chainsW: chainsW, onSelect: onSelect, onActivate: onActivate }, task.id))) }))] }));
44
+ }
45
+ function TaskNavRow(props) {
46
+ const { id, task, index, isSelected, focused, nameW, cmdW, chainsW, onSelect, onActivate } = props;
47
+ const { isHovered, hoverProps } = useHoverState();
48
+ const bg = isSelected ? (focused ? "#1e3a8a" : "#1e2a4a") : isHovered ? HOVER_BG : undefined;
49
+ const fg = isSelected ? "#ffffff" : "#e5e7eb";
50
+ const lastClickRef = useRef(0);
51
+ function handleClick() {
52
+ const now = Date.now();
53
+ if (now - lastClickRef.current < 400) {
54
+ onActivate(index);
55
+ }
56
+ else {
57
+ onSelect(index);
58
+ }
59
+ lastClickRef.current = now;
60
+ }
61
+ const cmd = commandLine(task.command, task.commandArgs);
62
+ const chains = task.onSuccessTaskId || task.onFailureTaskId
63
+ ? t("board.taskChainsFormat", { success: task.onSuccessTaskId ?? "-", failure: task.onFailureTaskId ?? "-" })
64
+ : t("board.taskChainsNone");
65
+ const name = task.name.length > nameW ? task.name.slice(0, nameW - 3) + "..." : task.name;
66
+ const truncCmd = cmd.length > cmdW ? cmd.slice(0, cmdW - 3) + "..." : cmd;
67
+ return (_jsx("box", { id: id, onMouseDown: handleClick, backgroundColor: bg, ...hoverProps, children: _jsxs("text", { fg: fg, children: [isSelected ? "›" : " ", " ", fit(name, nameW), " ", fit(truncCmd, cmdW), " ", fit(chains, chainsW)] }) }));
68
+ }
69
+ export function TaskInspector(props) {
70
+ const { task } = props;
71
+ if (!task) {
72
+ return (_jsx("box", { title: t("board.inspectorTitle"), border: true, style: { backgroundColor: "#0b0b0b" }, children: _jsx("text", { fg: "#9ca3af", children: t("board.inspectorEmpty") }) }));
73
+ }
74
+ const cmd = commandLine(task.command, task.commandArgs);
75
+ return (_jsxs("box", { title: t("board.inspectorTitle"), border: true, style: { flexDirection: "column", backgroundColor: "#0b0b0b" }, children: [_jsxs("text", { children: [_jsx("strong", { children: t("board.fieldId") }), " ", task.id] }), _jsxs("text", { children: [_jsx("strong", { children: t("board.taskLabelName") }), " ", task.name] }), _jsxs("text", { children: [_jsx("strong", { children: t("board.fieldCommand") }), " ", cmd] }), _jsxs("text", { children: [_jsx("strong", { children: t("board.taskLabelOnSuccess") }), " ", task.onSuccessTaskId ?? t("board.taskNone")] }), _jsxs("text", { children: [_jsx("strong", { children: t("board.taskLabelOnFailure") }), " ", task.onFailureTaskId ?? t("board.taskNone")] })] }));
76
+ }
77
+ export function TaskActionButtons(props) {
78
+ const { task, focused, selectedAction, selectable = true, onAction } = props;
79
+ if (!task) {
80
+ return (_jsx("box", { border: true, borderColor: focused ? "#38bdf8" : undefined, style: { flexDirection: "row", height: 3, flexShrink: 0, backgroundColor: "#0b0b0b", justifyContent: "center", alignItems: "center" }, children: _jsx("text", { fg: "#9ca3af", children: t("board.noActions") }) }));
81
+ }
82
+ const allActions = [
83
+ { key: "select", label: t("board.taskActionSelect"), hotkey: "s" },
84
+ { key: "edit", label: t("board.taskActionEdit"), hotkey: "e" },
85
+ { key: "delete", label: t("board.taskActionDelete"), hotkey: "d" },
86
+ ];
87
+ const actions = selectable ? allActions : allActions.filter((a) => a.key !== "select");
88
+ return (_jsx("box", { border: true, borderColor: focused ? "#38bdf8" : undefined, style: { flexDirection: "row", height: 3, flexShrink: 0, paddingLeft: 1, backgroundColor: "#0b0b0b", alignItems: "center" }, children: actions.map((action, i) => (_jsx(TaskActionButton, { label: action.label, hotkey: action.hotkey, selected: focused && selectedAction === i, onMouseDown: () => onAction(action.key) }, action.key))) }));
89
+ }
90
+ function TaskActionButton(props) {
91
+ const { isHovered, hoverProps } = useHoverState();
92
+ const bg = props.selected ? "#1e3a8a" : isHovered ? HOVER_BG : undefined;
93
+ const fg = props.selected ? "#ffffff" : isHovered ? "#e5e7eb" : "#9ca3af";
94
+ const keycapFg = props.selected ? "#93c5fd" : "#6b7280";
95
+ return (_jsxs("box", { onMouseDown: props.onMouseDown, style: { backgroundColor: bg, paddingLeft: 1, paddingRight: 1, marginRight: 1, flexDirection: "row", alignItems: "center" }, ...hoverProps, children: [_jsxs("text", { fg: keycapFg, children: ["[", props.hotkey, "] "] }), _jsx("text", { fg: fg, children: _jsx("strong", { children: props.label }) })] }));
96
+ }
@@ -0,0 +1,6 @@
1
+ import { jsxs as _jsxs, jsx as _jsx } from "@opentui/react/jsx-runtime";
2
+ import { t } from "../../i18n/index.js";
3
+ export function TaskFilterBar(props) {
4
+ const { query, searchActive, focusedPanel } = props;
5
+ return (_jsx("box", { style: { flexDirection: "row", height: 3, paddingLeft: 1, paddingRight: 1, backgroundColor: "#0b0b0b" }, children: _jsx("box", { title: t("board.searchTitle"), border: true, borderColor: focusedPanel === "search" ? "#38bdf8" : undefined, style: { flexGrow: 1, height: 3, paddingLeft: 1, backgroundColor: focusedPanel === "search" ? "#1e2a4a" : "#0b0b0b" }, children: searchActive ? (_jsxs("text", { fg: query ? "#e5e7eb" : "#6b7280", children: [query || t("board.searchPlaceholder"), "\u258E"] })) : (_jsx("text", { fg: query ? "#e5e7eb" : "#6b7280", children: query || t("board.searchEmpty") })) }) }));
6
+ }
@@ -0,0 +1,175 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "@opentui/react/jsx-runtime";
2
+ import { useRef, useState } from "react";
3
+ import crypto from "node:crypto";
4
+ import { useKeyboard, useTerminalDimensions } from "@opentui/react";
5
+ import { t } from "../../i18n/index.js";
6
+ import { createTask, updateTask, listTasks } from "../daemon.js";
7
+ import { useHoverState } from "../hooks/useHoverState.js";
8
+ import { HOVER_BG } from "../../config/constants.js";
9
+ const taskFields = ["name", "command", "onSuccessTaskId", "onFailureTaskId"];
10
+ function taskInitialValues(task) {
11
+ if (!task) {
12
+ return { name: "", command: "", onSuccessTaskId: "", onFailureTaskId: "" };
13
+ }
14
+ return {
15
+ name: task.name,
16
+ command: [task.command, ...task.commandArgs].join(" "),
17
+ onSuccessTaskId: task.onSuccessTaskId ?? "",
18
+ onFailureTaskId: task.onFailureTaskId ?? "",
19
+ };
20
+ }
21
+ export function TaskForm(props) {
22
+ const [values, setValues] = useState(taskInitialValues(props.editTask));
23
+ const valuesRef = useRef(values);
24
+ const [focusIndex, setFocusIndex] = useState(0);
25
+ const [error, setError] = useState("");
26
+ const [isSubmitting, setIsSubmitting] = useState(false);
27
+ const [allTasks, setAllTasks] = useState([]);
28
+ const { width: termWidth } = useTerminalDimensions();
29
+ const btnWidth = Math.max(10, Math.min(14, Math.floor(termWidth / 6)));
30
+ const saveIndex = taskFields.length;
31
+ const cancelIndex = taskFields.length + 1;
32
+ function updateValues(next) {
33
+ valuesRef.current = next;
34
+ setValues(next);
35
+ }
36
+ useState(() => {
37
+ void listTasks().then(setAllTasks).catch(() => { });
38
+ });
39
+ useKeyboard((key) => {
40
+ if (key.name === "tab") {
41
+ setFocusIndex((i) => {
42
+ const next = key.shift ? i - 1 : i + 1;
43
+ return Math.max(0, Math.min(cancelIndex, next));
44
+ });
45
+ return;
46
+ }
47
+ if (key.name === "left" || key.name === "right") {
48
+ if (focusIndex === saveIndex) {
49
+ setFocusIndex(key.name === "right" ? cancelIndex : taskFields.length - 1);
50
+ return;
51
+ }
52
+ if (focusIndex === cancelIndex) {
53
+ setFocusIndex(key.name === "right" ? 0 : saveIndex);
54
+ return;
55
+ }
56
+ if (key.name === "right") {
57
+ if (focusIndex + 1 < taskFields.length) {
58
+ setFocusIndex((i) => i + 1);
59
+ }
60
+ else {
61
+ setFocusIndex(saveIndex);
62
+ }
63
+ return;
64
+ }
65
+ if (key.name === "left") {
66
+ if (focusIndex > 0) {
67
+ setFocusIndex((i) => i - 1);
68
+ }
69
+ else {
70
+ setFocusIndex(cancelIndex);
71
+ }
72
+ return;
73
+ }
74
+ }
75
+ if (key.name === "return" || key.name === "enter") {
76
+ if (focusIndex === saveIndex) {
77
+ void submit(valuesRef.current);
78
+ }
79
+ else if (focusIndex === cancelIndex) {
80
+ props.onCancel();
81
+ }
82
+ }
83
+ });
84
+ async function submit(current) {
85
+ if (isSubmitting)
86
+ return;
87
+ try {
88
+ setIsSubmitting(true);
89
+ setError("");
90
+ const cmdLine = current.command.trim();
91
+ if (!cmdLine) {
92
+ setError(t("errors.commandEmpty"));
93
+ return;
94
+ }
95
+ const tokens = cmdLine.split(/\s+/);
96
+ const command = tokens[0] ?? "";
97
+ const commandArgs = tokens.slice(1);
98
+ const onSuccessTaskId = current.onSuccessTaskId || null;
99
+ const onFailureTaskId = current.onFailureTaskId || null;
100
+ if (props.mode === "edit" && props.editTask) {
101
+ await updateTask(props.editTask.id, {
102
+ name: current.name.trim() || command,
103
+ command,
104
+ commandArgs,
105
+ onSuccessTaskId,
106
+ onFailureTaskId,
107
+ });
108
+ props.onDone(true, props.editTask.id);
109
+ }
110
+ else {
111
+ const id = crypto.randomUUID().slice(0, 8);
112
+ await createTask({
113
+ id,
114
+ name: current.name.trim() || command,
115
+ command,
116
+ commandArgs,
117
+ onSuccessTaskId,
118
+ onFailureTaskId,
119
+ });
120
+ props.onDone(false, id);
121
+ }
122
+ }
123
+ catch (e) {
124
+ setError(e instanceof Error ? e.message : String(e));
125
+ }
126
+ finally {
127
+ setIsSubmitting(false);
128
+ }
129
+ }
130
+ const labels = {
131
+ name: t("board.taskLabelName"),
132
+ command: t("board.taskLabelCommand"),
133
+ onSuccessTaskId: t("board.taskLabelOnSuccess"),
134
+ onFailureTaskId: t("board.taskLabelOnFailure"),
135
+ };
136
+ const hints = {
137
+ name: t("board.taskHintName"),
138
+ command: t("board.hintCommand"),
139
+ onSuccessTaskId: t("board.taskHintOnSuccess"),
140
+ onFailureTaskId: t("board.taskHintOnFailure"),
141
+ };
142
+ const chainOptions = [
143
+ { name: t("board.taskNone"), description: "", value: "" },
144
+ ...allTasks
145
+ .filter((t) => t.id !== props.editTask?.id)
146
+ .map((t) => ({ name: `${t.id} ${t.name}`, description: "", value: t.id })),
147
+ ];
148
+ const title = props.mode === "edit" ? t("board.taskEditTitle") : t("board.taskCreateTitle");
149
+ return (_jsxs("box", { title: title, border: true, style: { flexDirection: "column", flexGrow: 1, padding: 1, backgroundColor: "#0b0b0b" }, children: [_jsx("box", { style: { flexDirection: "column", flexGrow: 1 }, children: Array.from({ length: Math.ceil(taskFields.length / 2) }, (_, row) => {
150
+ const leftField = taskFields[row * 2];
151
+ const rightField = row * 2 + 1 < taskFields.length ? taskFields[row * 2 + 1] : null;
152
+ return (_jsxs("box", { style: { flexDirection: "row", marginBottom: 1 }, children: [_jsx(TaskFormRow, { field: leftField, index: row * 2, focusIndex: focusIndex, values: values, valuesRef: valuesRef, updateValues: updateValues, setFocusIndex: setFocusIndex, submit: submit, labels: labels, hints: hints, chainOptions: chainOptions, style: { width: "50%", paddingRight: 1 } }), rightField ? (_jsx(TaskFormRow, { field: rightField, index: row * 2 + 1, focusIndex: focusIndex, values: values, valuesRef: valuesRef, updateValues: updateValues, setFocusIndex: setFocusIndex, submit: submit, labels: labels, hints: hints, chainOptions: chainOptions, style: { width: "50%" } })) : (_jsx("box", { style: { width: "50%" } }))] }, row));
153
+ }) }), _jsxs("box", { style: { flexDirection: "row", height: 3, marginBottom: 1, backgroundColor: "#0b0b0b" }, children: [_jsx(HoverButton, { label: isSubmitting ? t("board.saving") : props.mode === "edit" ? t("board.save") : t("board.create"), onMouseDown: () => void submit(valuesRef.current), selected: focusIndex === saveIndex, width: btnWidth, marginRight: 1 }), _jsx(HoverButton, { label: t("board.cancel"), onMouseDown: props.onCancel, selected: focusIndex === cancelIndex, width: btnWidth })] }), _jsx("text", { fg: "#9ca3af", children: t("board.formNav") }), error ? _jsx("text", { fg: "#f87171", children: error }) : null] }));
154
+ }
155
+ function TaskFormRow(props) {
156
+ const { field, index, focusIndex, values, valuesRef, updateValues, setFocusIndex, submit, labels, hints, chainOptions, style } = props;
157
+ const isFocused = focusIndex === index;
158
+ const isSelect = field === "onSuccessTaskId" || field === "onFailureTaskId";
159
+ const selectOpts = isSelect ? chainOptions : [];
160
+ const selectedIdx = isSelect ? selectOpts.findIndex((o) => o.value === values[field]) : 0;
161
+ return (_jsxs("box", { style: { flexDirection: "column", ...style }, children: [_jsx("text", { fg: isFocused ? "#38bdf8" : "#e5e7eb", children: labels[field] }), _jsx("text", { fg: "#6b7280", children: hints[field] }), isSelect ? (_jsx("box", { border: true, borderColor: isFocused ? "#38bdf8" : undefined, style: { height: Math.min(selectOpts.length + 2, 6), backgroundColor: "#0b0b0b" }, children: _jsx("select", { focused: isFocused, options: selectOpts, selectedIndex: Math.max(0, selectedIdx), showDescription: false, style: { flexGrow: 1 }, onChange: (_i, option) => updateValues({ ...valuesRef.current, [field]: option?.value ?? "" }) }) })) : (_jsx("box", { border: true, borderColor: isFocused ? "#38bdf8" : undefined, style: { height: 3, backgroundColor: "#0b0b0b" }, children: _jsx("input", { focused: isFocused, value: values[field], placeholder: field === "command" ? t("board.exampleCommand") : "", onInput: (value) => updateValues({ ...valuesRef.current, [field]: value }), onSubmit: () => {
162
+ if (index < taskFields.length - 1) {
163
+ setFocusIndex(index + 1);
164
+ }
165
+ else {
166
+ void submit(valuesRef.current);
167
+ }
168
+ } }) }))] }));
169
+ }
170
+ function HoverButton(props) {
171
+ const { isHovered, hoverProps } = useHoverState();
172
+ const bg = props.selected ? "#1e3a8a" : isHovered ? HOVER_BG : undefined;
173
+ const borderColor = props.selected ? "#38bdf8" : undefined;
174
+ return (_jsx("box", { border: true, onMouseDown: props.onMouseDown, borderColor: borderColor, style: { width: props.width, justifyContent: "center", alignItems: "center", marginRight: props.marginRight, backgroundColor: bg }, ...hoverProps, children: _jsx("text", { fg: "#e5e7eb", children: _jsx("strong", { children: props.label }) }) }));
175
+ }
@@ -21,6 +21,14 @@ export async function resumeLoop(id) {
21
21
  const response = await sendRequest({ type: "resume", payload: { id } });
22
22
  expectOk(response.message ?? t("errors.resumeFailed"), response.type);
23
23
  }
24
+ export async function stopLoop(id) {
25
+ const response = await sendRequest({ type: "stop-loop", payload: { id } });
26
+ expectOk(response.message ?? "Stop failed", response.type);
27
+ }
28
+ export async function playLoop(id) {
29
+ const response = await sendRequest({ type: "play-loop", payload: { id } });
30
+ expectOk(response.message ?? "Play failed", response.type);
31
+ }
24
32
  export async function triggerLoop(id) {
25
33
  const response = await sendRequest({ type: "trigger", payload: { id } });
26
34
  expectOk(response.message ?? t("errors.forceRunFailed"), response.type);
@@ -59,3 +67,51 @@ export async function fetchRunLog(id, runNumber) {
59
67
  }
60
68
  return response.data ?? "";
61
69
  }
70
+ export function streamRunLog(id, runNumber, onLine, onEnd, onError) {
71
+ return streamRequest({ type: "run-log-stream", payload: { id, runNumber } }, onLine, onEnd, onError);
72
+ }
73
+ export async function listTasks() {
74
+ const response = await sendRequest({ type: "task-list" });
75
+ if (response.type !== "ok") {
76
+ throw new Error(response.message);
77
+ }
78
+ return response.data;
79
+ }
80
+ export async function createTask(payload) {
81
+ const response = await sendRequest({ type: "task-create", payload });
82
+ if (response.type !== "ok") {
83
+ throw new Error(response.message);
84
+ }
85
+ return response.data;
86
+ }
87
+ export async function updateTask(id, payload) {
88
+ const response = await sendRequest({ type: "task-update", payload: { id, ...payload } });
89
+ if (response.type !== "ok") {
90
+ throw new Error(response.message);
91
+ }
92
+ return response.data;
93
+ }
94
+ export async function deleteTask(id) {
95
+ const response = await sendRequest({ type: "task-delete", payload: { id } });
96
+ expectOk(response.message ?? "Task delete failed", response.type);
97
+ }
98
+ export async function listProjects() {
99
+ const response = await sendRequest({ type: "project-list" });
100
+ if (response.type !== "ok")
101
+ throw new Error(response.message);
102
+ return response.data;
103
+ }
104
+ export async function createProject(name, color) {
105
+ const response = await sendRequest({ type: "project-create", payload: { name, color } });
106
+ if (response.type !== "ok")
107
+ throw new Error(response.message);
108
+ return response.data;
109
+ }
110
+ export async function updateProject(id, name, color) {
111
+ const response = await sendRequest({ type: "project-update", payload: { id, name, color } });
112
+ expectOk(response.message ?? t("project.error.updateFailed"), response.type);
113
+ }
114
+ export async function deleteProject(id) {
115
+ const response = await sendRequest({ type: "project-delete", payload: { id } });
116
+ expectOk(response.message ?? t("project.error.deleteFailed"), response.type);
117
+ }
@@ -49,23 +49,21 @@ export function timeUntil(iso) {
49
49
  return t("format.hrsAhead", { hrs });
50
50
  return t("format.daysAhead", { days: Math.floor(hrs / 24) });
51
51
  }
52
+ const STATUS_COLORS = {
53
+ running: "#4ade80",
54
+ waiting: "#38bdf8",
55
+ paused: "#facc15",
56
+ idle: "#fb923c",
57
+ stopped: "#f87171",
58
+ };
52
59
  export function statusColor(status) {
53
- switch (status) {
54
- case "running":
55
- return "#4ade80";
56
- case "paused":
57
- return "#facc15";
58
- case "waiting":
59
- return "#38bdf8";
60
- case "stopped":
61
- return "#f87171";
62
- default:
63
- return "#ffffff";
64
- }
60
+ return STATUS_COLORS[status] ?? "#ffffff";
65
61
  }
66
62
  export function timingLabel(loop) {
67
63
  if (loop.status === "paused")
68
64
  return t("format.timingPaused");
65
+ if (loop.status === "idle")
66
+ return t("format.timingIdle");
69
67
  if (loop.nextRunAt)
70
68
  return t("format.timingNext", { timeAgo: timeUntil(loop.nextRunAt) });
71
69
  if (loop.lastRunAt)
@@ -93,3 +91,12 @@ export function formatRunTime(iso) {
93
91
  const d = new Date(iso);
94
92
  return d.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit", hour12: false });
95
93
  }
94
+ export function sinceLabel(loop) {
95
+ const ts = loop.sessionStartedAt ?? loop.createdAt;
96
+ if (!ts)
97
+ return t("format.dash");
98
+ const d = new Date(ts);
99
+ const date = d.toLocaleDateString("en-US", { month: "short", day: "numeric" });
100
+ const time = d.toLocaleTimeString("en-US", { hour: "2-digit", minute: "2-digit", hour12: false });
101
+ return `${date} ${time}`;
102
+ }