taskchef 7.5.0 → 7.6.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.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "taskchef",
3
- "version": "7.5.0",
3
+ "version": "7.6.0",
4
4
  "description": "Dispatch work from a data-only workspace to visible Codex project tasks.",
5
5
  "author": {
6
6
  "name": "Favo Yang",
package/README.md CHANGED
@@ -176,6 +176,10 @@ taskchef dashboard --port 3211
176
176
  The loopback dashboard watches `tasks.jsonl`, groups current states, and opens
177
177
  linked Codex tasks. List snapshots and SSE events carry only the latest-result
178
178
  projection; opening task details fetches the full newest-first result history.
179
+ Task and result times are relative through 29 days (with minute detail for the
180
+ first six hours), then use a locale-aware calendar date. Each time is a keyboard-
181
+ accessible toggle for its full locale-aware date and time, and one shared
182
+ 30-second timer keeps relative labels current without reloading the page.
179
183
  It does not mutate TaskChef data and prints its local URL.
180
184
  When a compatible foreground dashboard already owns port 3210,
181
185
  `ensure_dashboard` reuses it but does not take ownership. If an unknown,
@@ -0,0 +1,3 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
2
+ <path fill="currentColor" d="M3 1.5h10A1.5 1.5 0 0 1 14.5 3v10a1.5 1.5 0 0 1-1.5 1.5H3A1.5 1.5 0 0 1 1.5 13V3A1.5 1.5 0 0 1 3 1.5Zm0 1a.5.5 0 0 0-.5.5v10a.5.5 0 0 0 .5.5h10a.5.5 0 0 0 .5-.5V3a.5.5 0 0 0-.5-.5H3Zm1.15 3.1.7-.7L7.95 8l-3.1 3.1-.7-.7L6.55 8l-2.4-2.4ZM8 10h4v1H8v-1Z"/>
3
+ </svg>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "taskchef",
3
- "version": "7.5.0",
3
+ "version": "7.6.0",
4
4
  "description": "A non-blocking interactive dispatcher for visible Codex tasks.",
5
5
  "license": "MIT",
6
6
  "author": "Favo Yang",
@@ -8,6 +8,7 @@ import {
8
8
  taskWithinDateFilter,
9
9
  } from "./state.js";
10
10
  import { openTaskFromControl } from "./actions.js";
11
+ import { RelativeTimeController, parsedTimestamp } from "./time.js";
11
12
 
12
13
  const state = {
13
14
  tasks: [],
@@ -18,6 +19,7 @@ const state = {
18
19
  };
19
20
  let dateRefreshTimer = null;
20
21
  let detailRequestGeneration = 0;
22
+ const relativeTimes = new RelativeTimeController();
21
23
 
22
24
  const elements = {
23
25
  clearNotifications: document.querySelector("#clear-notifications"),
@@ -45,12 +47,42 @@ const elements = {
45
47
  toastList: document.querySelector("#toast-list"),
46
48
  };
47
49
 
48
- function formatTime(value) {
49
- if (!value) return "—";
50
- return new Intl.DateTimeFormat(undefined, {
51
- dateStyle: "medium",
52
- timeStyle: "short",
53
- }).format(new Date(value));
50
+ function timestampControl(value, { accessibleName, key, prefix = "" }) {
51
+ if (!parsedTimestamp(value)) {
52
+ const missing = document.createElement("span");
53
+ missing.className = "timestamp-missing";
54
+ missing.textContent = `${prefix}—`;
55
+ return missing;
56
+ }
57
+ const control = document.createElement("button");
58
+ control.type = "button";
59
+ control.className = "timestamp-toggle";
60
+ const time = document.createElement("time");
61
+ control.append(time);
62
+ relativeTimes.register(key, value, ({ exact, iso, label }) => {
63
+ time.dateTime = iso;
64
+ time.textContent = `${prefix}${label}`;
65
+ const action = exact ? "Show relative time" : "Show exact date and time";
66
+ control.title = action;
67
+ control.setAttribute("aria-label", `${accessibleName}: ${label}. ${action}.`);
68
+ }, { isActive: () => control.isConnected });
69
+ control.addEventListener("click", () => relativeTimes.toggle(key));
70
+ return control;
71
+ }
72
+
73
+ function codexIcon() {
74
+ const icon = document.createElement("span");
75
+ icon.className = "codex-icon";
76
+ icon.setAttribute("aria-hidden", "true");
77
+ return icon;
78
+ }
79
+
80
+ function configureOpenTaskControl(control, ariaLabel) {
81
+ control.classList.add("task-action");
82
+ control.setAttribute("aria-label", ariaLabel);
83
+ const label = document.createElement("span");
84
+ label.textContent = "Open task";
85
+ control.replaceChildren(codexIcon(), label);
54
86
  }
55
87
 
56
88
  function setConnection(connected) {
@@ -116,11 +148,12 @@ function detailRow(term, value) {
116
148
  const dt = document.createElement("dt");
117
149
  dt.textContent = term;
118
150
  const dd = document.createElement("dd");
119
- dd.textContent = value ?? "—";
151
+ if (value instanceof Node) dd.append(value);
152
+ else dd.textContent = value ?? "—";
120
153
  return [dt, dd];
121
154
  }
122
155
 
123
- function resultHistory(results) {
156
+ function resultHistory(taskId, results) {
124
157
  if (results.length === 0) {
125
158
  const empty = document.createElement("p");
126
159
  empty.className = "result-history-empty";
@@ -135,9 +168,11 @@ function resultHistory(results) {
135
168
  const status = document.createElement("span");
136
169
  status.className = `status status-${result.status}`;
137
170
  status.textContent = result.status.replaceAll("_", " ");
138
- const timestamp = document.createElement("time");
139
- timestamp.dateTime = result.updatedAt;
140
- timestamp.textContent = formatTime(result.updatedAt);
171
+ const resultKey = result.turnId ?? `${result.status}:${index}`;
172
+ const timestamp = timestampControl(result.updatedAt, {
173
+ accessibleName: `Result updated time for ${result.status.replaceAll("_", " ")}`,
174
+ key: `detail:${taskId}:result:${resultKey}`,
175
+ });
141
176
  header.append(status, timestamp);
142
177
  const summary = document.createElement("p");
143
178
  summary.className = "preserve-lines";
@@ -161,7 +196,7 @@ function renderDialog(task) {
161
196
  state.selectedTask = detailedTask;
162
197
  elements.dialogProject.textContent = task.project.name;
163
198
  elements.dialogTitle.textContent = task.title;
164
- elements.dialogResults.replaceChildren(...resultHistory(detailedTask.results));
199
+ elements.dialogResults.replaceChildren(...resultHistory(task.id, detailedTask.results));
165
200
  elements.dialogInstruction.textContent = task.instruction;
166
201
  elements.copyThreadId.disabled = !task.threadId;
167
202
  elements.dialogMetadata.replaceChildren(
@@ -169,16 +204,27 @@ function renderDialog(task) {
169
204
  ...detailRow("Current turn ID", task.turnId),
170
205
  ...detailRow("Last result status", task.lastResult?.status?.replaceAll("_", " ")),
171
206
  ...detailRow("Last result turn ID", task.lastResult?.turnId),
172
- ...detailRow("Last result updated", formatTime(task.lastResult?.updatedAt)),
207
+ ...detailRow("Last result updated", timestampControl(task.lastResult?.updatedAt, {
208
+ accessibleName: `Last result updated time for ${task.title}`,
209
+ key: `detail:${task.id}:last-result-updated`,
210
+ })),
173
211
  ...detailRow("Task ID", task.id),
174
212
  ...detailRow("Thread ID", task.threadId),
175
213
  ...detailRow("Project path", task.project.path),
176
- ...detailRow("Created", formatTime(task.createdAt)),
177
- ...detailRow("Updated", formatTime(
214
+ ...detailRow("Created", timestampControl(task.createdAt, {
215
+ accessibleName: `Created time for ${task.title}`,
216
+ key: `detail:${task.id}:created`,
217
+ })),
218
+ ...detailRow("Updated", timestampControl(
178
219
  task.meaningfulUpdatedAt ?? task.updatedAt ?? task.createdAt,
220
+ {
221
+ accessibleName: `Updated time for ${task.title}`,
222
+ key: `detail:${task.id}:updated`,
223
+ },
179
224
  )),
180
225
  ...detailRow("Updated by", task.updatedBy),
181
226
  );
227
+ configureOpenTaskControl(elements.openProject, `Open ${task.title} in Codex`);
182
228
  }
183
229
 
184
230
  async function openDialog(task) {
@@ -223,16 +269,20 @@ function taskCard(task) {
223
269
  const summary = document.createElement("p");
224
270
  summary.className = "task-summary";
225
271
  summary.textContent = task.lastResult?.summary ?? "No semantic result reported yet.";
226
- const time = document.createElement("time");
227
- time.dateTime = task.meaningfulUpdatedAt ?? task.updatedAt ?? task.createdAt;
228
- time.textContent = `Updated ${formatTime(time.dateTime)}`;
272
+ const time = timestampControl(
273
+ task.meaningfulUpdatedAt ?? task.updatedAt ?? task.createdAt,
274
+ {
275
+ accessibleName: `Updated time for ${task.title}`,
276
+ key: `list:${task.id}:updated`,
277
+ prefix: "Updated ",
278
+ },
279
+ );
229
280
  const footer = document.createElement("div");
230
281
  footer.className = "task-footer";
231
282
  const openTask = document.createElement("button");
232
283
  openTask.type = "button";
233
284
  openTask.className = "secondary-button task-open";
234
- openTask.textContent = "Open task";
235
- openTask.setAttribute("aria-label", `Open ${task.title} in Codex`);
285
+ configureOpenTaskControl(openTask, `Open ${task.title} in Codex`);
236
286
  openTask.addEventListener("click", (event) => openTaskFromControl(event, task.id, {
237
287
  showMessage,
238
288
  }));
@@ -83,7 +83,10 @@
83
83
  <button id="close-dialog" class="icon-button" type="button" aria-label="Close task details">×</button>
84
84
  </div>
85
85
  <div class="dialog-actions">
86
- <button id="open-codex" class="primary-button" type="button">Open task in Codex</button>
86
+ <button id="open-codex" class="primary-button task-action" type="button" aria-label="Open this task in Codex">
87
+ <span class="codex-icon" aria-hidden="true"></span>
88
+ <span>Open task</span>
89
+ </button>
87
90
  <button id="copy-thread-id" class="secondary-button" type="button">Copy thread ID</button>
88
91
  </div>
89
92
  <section>
@@ -51,7 +51,7 @@ h3 { margin-bottom: 8px; font-size: 0.84rem; letter-spacing: 0.08em; text-transf
51
51
  .dashboard-icon img { display: block; width: 100%; height: 100%; object-fit: contain; }
52
52
 
53
53
  .eyebrow { margin-bottom: 10px; color: var(--accent); font-size: 0.78rem; font-weight: 750; letter-spacing: 0.15em; text-transform: uppercase; }
54
- .subtitle, .task-project, time { color: var(--muted); }
54
+ .subtitle, .task-project, time, .timestamp-missing { color: var(--muted); }
55
55
  .subtitle { margin-bottom: 0; }
56
56
 
57
57
  .connection { display: flex; align-items: center; gap: 8px; color: var(--muted); font-size: 0.9rem; }
@@ -78,9 +78,13 @@ select { min-width: 180px; padding: 9px 34px 9px 11px; border: 1px solid var(--b
78
78
  .task-title:hover { color: var(--accent); text-decoration: underline; text-underline-offset: 3px; }
79
79
  .task-project { margin: 3px 0 12px; font-size: 0.86rem; }
80
80
  .task-summary { max-width: 78ch; margin-bottom: 14px; }
81
- time { font-size: 0.78rem; }
81
+ time, .timestamp-missing { font-size: 0.78rem; }
82
+ .timestamp-toggle { padding: 2px 0; border: 0; background: none; cursor: pointer; text-align: left; }
83
+ .timestamp-toggle:hover time { color: var(--text); text-decoration: underline; text-underline-offset: 3px; }
82
84
  .task-footer { display: flex; align-items: center; justify-content: space-between; gap: 16px; }
83
85
  .task-open { flex: none; }
86
+ .task-action { display: inline-flex; align-items: center; justify-content: center; gap: 6px; min-height: 32px; padding: 5px 9px; font-size: 0.82rem; line-height: 1.2; white-space: nowrap; }
87
+ .codex-icon { display: inline-block; flex: none; width: 16px; height: 16px; background: currentColor; mask: url("/assets/codex.svg") center / contain no-repeat; }
84
88
 
85
89
  .status { flex: none; padding: 4px 9px; border-radius: 999px; background: var(--surface-muted); color: var(--muted); font-size: 0.72rem; font-weight: 750; text-transform: capitalize; }
86
90
  .status-completed { background: var(--accent-soft); color: var(--accent); }
@@ -142,8 +146,8 @@ pre { max-height: 280px; margin: 0; padding: 14px; overflow: auto; border-radius
142
146
  .task-count { margin: 0; }
143
147
  .task-heading { align-items: flex-start; }
144
148
  .task-footer { align-items: flex-start; flex-direction: column; }
145
- .task-open { width: 100%; }
146
- .dialog-actions { align-items: stretch; flex-direction: column; }
149
+ .task-open { align-self: flex-start; }
150
+ .dialog-actions { align-items: center; flex-flow: row wrap; }
147
151
  .metadata { grid-template-columns: 1fr; }
148
152
  .metadata dt { padding-bottom: 0; border-bottom: 0; }
149
153
  }
@@ -0,0 +1,146 @@
1
+ export const RELATIVE_TIME_REFRESH_MS = 30_000;
2
+ export const RELATIVE_DATE_LIMIT_DAYS = 30;
3
+
4
+ const MINUTE_MS = 60_000;
5
+ const HOUR_MS = 60 * MINUTE_MS;
6
+ const DAY_MS = 24 * HOUR_MS;
7
+
8
+ export function parsedTimestamp(value) {
9
+ if (typeof value !== "string" || value.trim() === "") return null;
10
+ const milliseconds = Date.parse(value);
11
+ if (!Number.isFinite(milliseconds)) return null;
12
+ return {
13
+ date: new Date(milliseconds),
14
+ iso: new Date(milliseconds).toISOString(),
15
+ milliseconds,
16
+ };
17
+ }
18
+
19
+ export function formatExactTime(value, { locale, timeZone } = {}) {
20
+ const parsed = parsedTimestamp(value);
21
+ if (!parsed) return "—";
22
+ return new Intl.DateTimeFormat(locale, {
23
+ dateStyle: "medium",
24
+ timeStyle: "short",
25
+ timeZone,
26
+ }).format(parsed.date);
27
+ }
28
+
29
+ function hourPhrase(milliseconds, direction) {
30
+ const hours = Math.floor(milliseconds / HOUR_MS);
31
+ const minutes = Math.floor((milliseconds % HOUR_MS) / MINUTE_MS);
32
+ const hourText = hours === 1 ? "1 hour" : `${hours} hours`;
33
+ const detail = hours < 6 && minutes > 0 ? ` ${minutes} minutes` : "";
34
+ return direction === "future"
35
+ ? `in ${hourText}${detail}`
36
+ : `${hourText}${detail} ago`;
37
+ }
38
+
39
+ export function formatRelativeTime(value, {
40
+ now = Date.now(),
41
+ locale,
42
+ timeZone,
43
+ } = {}) {
44
+ const parsed = parsedTimestamp(value);
45
+ if (!parsed) return "—";
46
+ const difference = now - parsed.milliseconds;
47
+ const future = difference < 0;
48
+ const elapsed = Math.abs(difference);
49
+ if (elapsed < MINUTE_MS) return "just now";
50
+ if (elapsed < 2 * MINUTE_MS) return future ? "in 1 minute" : "1 minute ago";
51
+ if (elapsed < HOUR_MS) {
52
+ const minutes = future
53
+ ? Math.ceil(elapsed / MINUTE_MS)
54
+ : Math.floor(elapsed / MINUTE_MS);
55
+ return future ? `in ${minutes} minutes` : `${minutes} minutes ago`;
56
+ }
57
+ if (elapsed < 2 * HOUR_MS) return future ? "in 1 hour" : "1 hour ago";
58
+ if (elapsed < DAY_MS) return hourPhrase(elapsed, future ? "future" : "past");
59
+ if (elapsed < 2 * DAY_MS) return future ? "in 1 day" : "1 day ago";
60
+ if (elapsed < RELATIVE_DATE_LIMIT_DAYS * DAY_MS) {
61
+ const days = future
62
+ ? Math.ceil(elapsed / DAY_MS)
63
+ : Math.floor(elapsed / DAY_MS);
64
+ return future ? `in ${days} days` : `${days} days ago`;
65
+ }
66
+ return new Intl.DateTimeFormat(locale, {
67
+ dateStyle: "medium",
68
+ timeZone,
69
+ }).format(parsed.date);
70
+ }
71
+
72
+ export function timestampPresentation(value, {
73
+ exact = false,
74
+ now = Date.now(),
75
+ locale,
76
+ timeZone,
77
+ } = {}) {
78
+ const parsed = parsedTimestamp(value);
79
+ if (!parsed) return { valid: false, label: "—", iso: null };
80
+ return {
81
+ exact,
82
+ iso: parsed.iso,
83
+ label: exact
84
+ ? formatExactTime(value, { locale, timeZone })
85
+ : formatRelativeTime(value, { now, locale, timeZone }),
86
+ valid: true,
87
+ };
88
+ }
89
+
90
+ export class RelativeTimeController {
91
+ constructor({
92
+ now = () => Date.now(),
93
+ setIntervalFn = (...args) => globalThis.setInterval(...args),
94
+ clearIntervalFn = (...args) => globalThis.clearInterval(...args),
95
+ refreshEveryMs = RELATIVE_TIME_REFRESH_MS,
96
+ } = {}) {
97
+ this.now = now;
98
+ this.setIntervalFn = setIntervalFn;
99
+ this.clearIntervalFn = clearIntervalFn;
100
+ this.refreshEveryMs = refreshEveryMs;
101
+ this.entries = new Map();
102
+ this.exactKeys = new Set();
103
+ this.timer = null;
104
+ }
105
+
106
+ register(key, value, render, { isActive = () => true } = {}) {
107
+ const token = Symbol(key);
108
+ this.entries.set(token, { isActive, key, render, value });
109
+ this.renderEntry(this.entries.get(token));
110
+ if (this.timer === null) {
111
+ this.timer = this.setIntervalFn(() => this.refresh(), this.refreshEveryMs);
112
+ }
113
+ return () => this.entries.delete(token);
114
+ }
115
+
116
+ renderEntry(entry) {
117
+ entry.render(timestampPresentation(entry.value, {
118
+ exact: this.exactKeys.has(entry.key),
119
+ now: this.now(),
120
+ }));
121
+ }
122
+
123
+ toggle(key) {
124
+ if (this.exactKeys.has(key)) this.exactKeys.delete(key);
125
+ else this.exactKeys.add(key);
126
+ for (const entry of this.entries.values()) {
127
+ if (entry.key === key) this.renderEntry(entry);
128
+ }
129
+ }
130
+
131
+ refresh() {
132
+ for (const [token, entry] of this.entries) {
133
+ if (!entry.isActive()) {
134
+ this.entries.delete(token);
135
+ continue;
136
+ }
137
+ if (!this.exactKeys.has(entry.key)) this.renderEntry(entry);
138
+ }
139
+ }
140
+
141
+ stop() {
142
+ if (this.timer !== null) this.clearIntervalFn(this.timer);
143
+ this.timer = null;
144
+ this.entries.clear();
145
+ }
146
+ }
package/src/dashboard.js CHANGED
@@ -43,10 +43,12 @@ const STATIC_FILES = new Map([
43
43
  ["/", [path.join(STATIC_ROOT, "index.html"), "text/html; charset=utf-8"]],
44
44
  ["/actions.js", [path.join(STATIC_ROOT, "actions.js"), "text/javascript; charset=utf-8"]],
45
45
  ["/app.js", [path.join(STATIC_ROOT, "app.js"), "text/javascript; charset=utf-8"]],
46
+ ["/time.js", [path.join(STATIC_ROOT, "time.js"), "text/javascript; charset=utf-8"]],
46
47
  ["/state.js", [path.join(STATIC_ROOT, "state.js"), "text/javascript; charset=utf-8"]],
47
48
  ["/styles.css", [path.join(STATIC_ROOT, "styles.css"), "text/css; charset=utf-8"]],
48
49
  ["/assets/taskchef-dark.svg", [path.join(ASSET_ROOT, "taskchef-dark.svg"), "image/svg+xml"]],
49
50
  ["/assets/taskchef.svg", [path.join(ASSET_ROOT, "taskchef.svg"), "image/svg+xml"]],
51
+ ["/assets/codex.svg", [path.join(ASSET_ROOT, "codex.svg"), "image/svg+xml"]],
50
52
  ]);
51
53
 
52
54
  export function dashboardAuthority(host, port) {