@chrysb/alphaclaw 0.6.2-beta.5 → 0.6.2-beta.6

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,7 +1,12 @@
1
1
  import { h } from "https://esm.sh/preact";
2
- import { useCallback, useEffect, useMemo, useState } from "https://esm.sh/preact/hooks";
2
+ import {
3
+ useCallback,
4
+ useEffect,
5
+ useMemo,
6
+ useState,
7
+ } from "https://esm.sh/preact/hooks";
3
8
  import htm from "https://esm.sh/htm";
4
- import { ActionButton } from "../action-button.js";
9
+ import { SegmentedControl } from "../segmented-control.js";
5
10
  import { Tooltip } from "../tooltip.js";
6
11
  import { formatCost, formatTokenCount } from "./cron-helpers.js";
7
12
  import { formatCronScheduleLabel } from "./cron-helpers.js";
@@ -24,7 +29,8 @@ const formatHourLabel = (hourOfDay) => {
24
29
  });
25
30
  };
26
31
 
27
- const buildCellKey = (dayKey, hourOfDay) => `${String(dayKey || "")}:${hourOfDay}`;
32
+ const buildCellKey = (dayKey, hourOfDay) =>
33
+ `${String(dayKey || "")}:${hourOfDay}`;
28
34
  const toLocalDayKey = (valueMs) => {
29
35
  const dateValue = new Date(valueMs);
30
36
  const year = dateValue.getFullYear();
@@ -49,31 +55,53 @@ const slotStateClassName = ({
49
55
  const tierClassName = tierClassNameByKey[tokenTier] || tierClassNameByKey.low;
50
56
  if (!isPast) return `${tierClassName} cron-calendar-slot-upcoming`;
51
57
  if (mappedStatus === "ok") return `${tierClassName} cron-calendar-slot-ok`;
52
- if (mappedStatus === "error") return `${tierClassName} cron-calendar-slot-error`;
53
- if (mappedStatus === "skipped") return `${tierClassName} cron-calendar-slot-skipped`;
58
+ if (mappedStatus === "error")
59
+ return `${tierClassName} cron-calendar-slot-error`;
60
+ if (mappedStatus === "skipped")
61
+ return `${tierClassName} cron-calendar-slot-skipped`;
54
62
  return `${tierClassName} cron-calendar-slot-past`;
55
63
  };
56
64
 
57
65
  const renderLegend = () => html`
58
66
  <div class="cron-calendar-legend">
59
67
  <span class="cron-calendar-legend-label">Token intensity</span>
60
- <span class="cron-calendar-legend-pill cron-calendar-slot-tier-unknown">No usage</span>
61
- <span class="cron-calendar-legend-pill cron-calendar-slot-tier-low">Low</span>
62
- <span class="cron-calendar-legend-pill cron-calendar-slot-tier-medium">Medium</span>
63
- <span class="cron-calendar-legend-pill cron-calendar-slot-tier-high">High</span>
64
- <span class="cron-calendar-legend-pill cron-calendar-slot-tier-very-high">Very high</span>
68
+ <span class="cron-calendar-legend-pill cron-calendar-slot-tier-unknown"
69
+ >No usage</span
70
+ >
71
+ <span class="cron-calendar-legend-pill cron-calendar-slot-tier-low"
72
+ >Low</span
73
+ >
74
+ <span class="cron-calendar-legend-pill cron-calendar-slot-tier-medium"
75
+ >Medium</span
76
+ >
77
+ <span class="cron-calendar-legend-pill cron-calendar-slot-tier-high"
78
+ >High</span
79
+ >
80
+ <span class="cron-calendar-legend-pill cron-calendar-slot-tier-very-high"
81
+ >Very high</span
82
+ >
65
83
  </div>
66
84
  `;
67
85
 
68
86
  const kNowRefreshMs = 60 * 1000;
69
87
  const kCalendarExpandedUiSettingKey = "cronCalendarExpanded";
88
+ const kCalendarViewUiSettingKey = "cronCalendarView";
89
+ const kCalendarViewUpcoming = "upcoming";
90
+ const kCalendarViewCalendar = "calendar";
91
+ const kCalendarViewOptions = [
92
+ { label: "Up next", value: kCalendarViewUpcoming },
93
+ { label: "Calendar", value: kCalendarViewCalendar },
94
+ ];
70
95
  const kRunWindow7dMs = 7 * 24 * 60 * 60 * 1000;
71
96
  const kSlotRunToleranceMs = 45 * 60 * 1000;
72
97
  const kUnknownTier = "unknown";
73
98
 
74
99
  const formatUpcomingTime = (timestampMs) => {
75
100
  const dateValue = new Date(timestampMs);
76
- return dateValue.toLocaleTimeString([], { hour: "numeric", minute: "2-digit" });
101
+ return dateValue.toLocaleTimeString([], {
102
+ hour: "numeric",
103
+ minute: "2-digit",
104
+ });
77
105
  };
78
106
 
79
107
  const getRunTotalTokens = (entry = {}) => {
@@ -110,40 +138,58 @@ const getRunEstimatedCost = (entry = {}) => {
110
138
  return null;
111
139
  };
112
140
 
113
- const buildRunSummaryByJobId = ({ runsByJobId = {}, nowMs = Date.now() } = {}) => {
141
+ const buildRunSummaryByJobId = ({
142
+ runsByJobId = {},
143
+ nowMs = Date.now(),
144
+ } = {}) => {
114
145
  const cutoffMs = Number(nowMs || Date.now()) - kRunWindow7dMs;
115
- return Object.entries(runsByJobId || {}).reduce((accumulator, [jobId, runResult]) => {
116
- const entries = Array.isArray(runResult?.entries) ? runResult.entries : [];
117
- const recentEntries = entries.filter((entry) => {
118
- const timestampMs = Number(entry?.ts || 0);
119
- return Number.isFinite(timestampMs) && timestampMs >= cutoffMs && timestampMs <= nowMs;
120
- });
121
- const runCount = recentEntries.length;
122
- const totalTokens = recentEntries.reduce(
123
- (sum, entry) => sum + Number(getRunTotalTokens(entry) || 0),
124
- 0,
125
- );
126
- const totalCost = recentEntries.reduce((sum, entry) => {
127
- const cost = getRunEstimatedCost(entry);
128
- return sum + Number(cost == null ? 0 : cost);
129
- }, 0);
130
- accumulator[String(jobId || "")] = {
131
- runCount,
132
- totalTokens,
133
- totalCost,
134
- avgTokensPerRun: runCount > 0 ? Math.round(totalTokens / runCount) : 0,
135
- avgCostPerRun: runCount > 0 ? totalCost / runCount : 0,
136
- };
137
- return accumulator;
138
- }, {});
146
+ return Object.entries(runsByJobId || {}).reduce(
147
+ (accumulator, [jobId, runResult]) => {
148
+ const entries = Array.isArray(runResult?.entries)
149
+ ? runResult.entries
150
+ : [];
151
+ const recentEntries = entries.filter((entry) => {
152
+ const timestampMs = Number(entry?.ts || 0);
153
+ return (
154
+ Number.isFinite(timestampMs) &&
155
+ timestampMs >= cutoffMs &&
156
+ timestampMs <= nowMs
157
+ );
158
+ });
159
+ const runCount = recentEntries.length;
160
+ const totalTokens = recentEntries.reduce(
161
+ (sum, entry) => sum + Number(getRunTotalTokens(entry) || 0),
162
+ 0,
163
+ );
164
+ const totalCost = recentEntries.reduce((sum, entry) => {
165
+ const cost = getRunEstimatedCost(entry);
166
+ return sum + Number(cost == null ? 0 : cost);
167
+ }, 0);
168
+ accumulator[String(jobId || "")] = {
169
+ runCount,
170
+ totalTokens,
171
+ totalCost,
172
+ avgTokensPerRun: runCount > 0 ? Math.round(totalTokens / runCount) : 0,
173
+ avgCostPerRun: runCount > 0 ? totalCost / runCount : 0,
174
+ };
175
+ return accumulator;
176
+ },
177
+ {},
178
+ );
139
179
  };
140
180
 
141
- const mapRunsToSlots = ({ slots = [], runsByJobId = {}, nowMs = Date.now() } = {}) => {
181
+ const mapRunsToSlots = ({
182
+ slots = [],
183
+ runsByJobId = {},
184
+ nowMs = Date.now(),
185
+ } = {}) => {
142
186
  const runsBySlotKey = {};
143
187
  const consumedRunTimestampsByJobId = {};
144
188
  const runEntriesByJobId = Object.entries(runsByJobId || {}).reduce(
145
189
  (accumulator, [jobId, runResult]) => {
146
- const entries = Array.isArray(runResult?.entries) ? runResult.entries : [];
190
+ const entries = Array.isArray(runResult?.entries)
191
+ ? runResult.entries
192
+ : [];
147
193
  const normalizedEntries = entries
148
194
  .map((entry) => ({ ...entry, ts: Number(entry?.ts || 0) }))
149
195
  .filter((entry) => Number.isFinite(entry.ts) && entry.ts > 0)
@@ -205,14 +251,14 @@ const classifyTokenTier = ({
205
251
  } = {}) => {
206
252
  if (!enabled) return "disabled";
207
253
  const safeValue = Number(tokenValue || 0);
208
- if (!Number.isFinite(safeValue) || safeValue <= 0 || !thresholds) return kUnknownTier;
254
+ if (!Number.isFinite(safeValue) || safeValue <= 0 || !thresholds)
255
+ return kUnknownTier;
209
256
  if (safeValue <= thresholds.q1) return "low";
210
257
  if (safeValue <= thresholds.q2) return "medium";
211
258
  if (safeValue <= thresholds.p90) return "high";
212
259
  return "very-high";
213
260
  };
214
261
 
215
-
216
262
  const buildJobTooltipText = ({
217
263
  jobName = "",
218
264
  job = null,
@@ -223,18 +269,25 @@ const buildJobTooltipText = ({
223
269
  scheduledStatus = "",
224
270
  nowMs = Date.now(),
225
271
  } = {}) => {
226
- const isPastSlot = Number(scheduledAtMs || 0) > 0 && Number(scheduledAtMs || 0) <= nowMs;
272
+ const isPastSlot =
273
+ Number(scheduledAtMs || 0) > 0 && Number(scheduledAtMs || 0) <= nowMs;
227
274
  const runCount7d = Number(runSummary7d?.runCount || 0);
228
275
  const avgTokensPerRun7d = Number(runSummary7d?.avgTokensPerRun || 0);
229
276
  const avgCostPerRun7d = Number(runSummary7d?.avgCostPerRun || 0);
230
277
  const slotRunTokens = getRunTotalTokens(slotRun || {});
231
278
  const slotRunCost = getRunEstimatedCost(slotRun || {});
232
- const slotRunStatus = String(slotRun?.status || "").trim().toLowerCase();
279
+ const slotRunStatus = String(slotRun?.status || "")
280
+ .trim()
281
+ .toLowerCase();
233
282
 
234
283
  const lines = [String(jobName || "Job")];
235
284
  if (isPastSlot) {
236
- lines.push(`Run tokens: ${slotRun ? formatTokenCount(slotRunTokens) : "—"}`);
237
- lines.push(`Run cost: ${slotRunCost == null ? "—" : formatCost(slotRunCost)}`);
285
+ lines.push(
286
+ `Run tokens: ${slotRun ? formatTokenCount(slotRunTokens) : "—"}`,
287
+ );
288
+ lines.push(
289
+ `Run cost: ${slotRunCost == null ? "—" : formatCost(slotRunCost)}`,
290
+ );
238
291
  lines.push(`Run status: ${slotRunStatus || scheduledStatus || "unknown"}`);
239
292
  if (slotRun?.ts) {
240
293
  lines.push(
@@ -248,7 +301,9 @@ const buildJobTooltipText = ({
248
301
  lines.push(
249
302
  `Avg cost/run (last 7d): ${runCount7d > 0 ? formatCost(avgCostPerRun7d) : "—"}`,
250
303
  );
251
- lines.push(`Runs (last 7d): ${runCount7d > 0 ? formatTokenCount(runCount7d) : "none"}`);
304
+ lines.push(
305
+ `Runs (last 7d): ${runCount7d > 0 ? formatTokenCount(runCount7d) : "none"}`,
306
+ );
252
307
  }
253
308
 
254
309
  if (!isPastSlot && latestRun?.status) {
@@ -259,12 +314,15 @@ const buildJobTooltipText = ({
259
314
  lines.push("Latest run: none");
260
315
  }
261
316
  if (Number(job?.state?.runningAtMs || 0) > 0) {
262
- lines.push(`Current run: active (${new Date(Number(job.state.runningAtMs)).toLocaleString()})`);
317
+ lines.push(
318
+ `Current run: active (${new Date(Number(job.state.runningAtMs)).toLocaleString()})`,
319
+ );
263
320
  }
264
321
 
265
322
  if (scheduledAtMs > 0) {
266
323
  const slotLabel = new Date(scheduledAtMs).toLocaleString();
267
- const slotState = scheduledStatus || (scheduledAtMs <= Date.now() ? "past" : "upcoming");
324
+ const slotState =
325
+ scheduledStatus || (scheduledAtMs <= Date.now() ? "past" : "upcoming");
268
326
  lines.push(`Slot: ${slotState} (${slotLabel})`);
269
327
  }
270
328
  return lines.join("\n");
@@ -275,16 +333,29 @@ export const CronCalendar = ({
275
333
  runsByJobId = {},
276
334
  onSelectJob = () => {},
277
335
  }) => {
278
- const [expanded, setExpanded] = useState(() => {
336
+ const [calendarView, setCalendarView] = useState(() => {
279
337
  const settings = readUiSettings();
280
- return settings[kCalendarExpandedUiSettingKey] === true;
338
+ const savedView = String(
339
+ settings?.[kCalendarViewUiSettingKey] || "",
340
+ ).trim();
341
+ if (savedView === kCalendarViewCalendar) return kCalendarViewCalendar;
342
+ if (savedView === kCalendarViewUpcoming) return kCalendarViewUpcoming;
343
+ return settings[kCalendarExpandedUiSettingKey] === true
344
+ ? kCalendarViewCalendar
345
+ : kCalendarViewUpcoming;
281
346
  });
282
- const toggleExpanded = useCallback(() => {
283
- setExpanded((previous) => {
284
- const next = !previous;
285
- updateUiSettings((settings) => ({ ...settings, [kCalendarExpandedUiSettingKey]: next }));
286
- return next;
287
- });
347
+ const isCalendarView = calendarView === kCalendarViewCalendar;
348
+ const onChangeCalendarView = useCallback((nextValue) => {
349
+ const nextView =
350
+ nextValue === kCalendarViewCalendar
351
+ ? kCalendarViewCalendar
352
+ : kCalendarViewUpcoming;
353
+ setCalendarView(nextView);
354
+ updateUiSettings((settings) => ({
355
+ ...settings,
356
+ [kCalendarViewUiSettingKey]: nextView,
357
+ [kCalendarExpandedUiSettingKey]: nextView === kCalendarViewCalendar,
358
+ }));
288
359
  }, []);
289
360
 
290
361
  const [nowMs, setNowMs] = useState(() => Date.now());
@@ -306,7 +377,12 @@ export const CronCalendar = ({
306
377
  [scheduledJobs, nowMs],
307
378
  );
308
379
  const statusBySlotKey = useMemo(
309
- () => mapRunStatusesToSlots({ slots: timeline.slots, bulkRunsByJobId: runsByJobId, nowMs }),
380
+ () =>
381
+ mapRunStatusesToSlots({
382
+ slots: timeline.slots,
383
+ bulkRunsByJobId: runsByJobId,
384
+ nowMs,
385
+ }),
310
386
  [timeline.slots, runsByJobId, nowMs],
311
387
  );
312
388
  const jobById = useMemo(
@@ -320,14 +396,21 @@ export const CronCalendar = ({
320
396
  );
321
397
  const latestRunByJobId = useMemo(
322
398
  () =>
323
- Object.entries(runsByJobId || {}).reduce((accumulator, [jobId, runResult]) => {
324
- const entries = Array.isArray(runResult?.entries) ? runResult.entries : [];
325
- const latest = entries
326
- .filter((entry) => Number(entry?.ts || 0) > 0)
327
- .sort((left, right) => Number(right?.ts || 0) - Number(left?.ts || 0))[0];
328
- accumulator[jobId] = latest || null;
329
- return accumulator;
330
- }, {}),
399
+ Object.entries(runsByJobId || {}).reduce(
400
+ (accumulator, [jobId, runResult]) => {
401
+ const entries = Array.isArray(runResult?.entries)
402
+ ? runResult.entries
403
+ : [];
404
+ const latest = entries
405
+ .filter((entry) => Number(entry?.ts || 0) > 0)
406
+ .sort(
407
+ (left, right) => Number(right?.ts || 0) - Number(left?.ts || 0),
408
+ )[0];
409
+ accumulator[jobId] = latest || null;
410
+ return accumulator;
411
+ },
412
+ {},
413
+ ),
331
414
  [runsByJobId],
332
415
  );
333
416
  const runSummary7dByJobId = useMemo(
@@ -349,47 +432,68 @@ export const CronCalendar = ({
349
432
  if (slotRunTokens > 0) values.push(slotRunTokens);
350
433
  return;
351
434
  }
352
- const projectedAvgTokens = Number(runSummary7dByJobId[slot.jobId]?.avgTokensPerRun || 0);
435
+ const projectedAvgTokens = Number(
436
+ runSummary7dByJobId[slot.jobId]?.avgTokensPerRun || 0,
437
+ );
353
438
  if (projectedAvgTokens > 0) values.push(projectedAvgTokens);
354
439
  });
355
440
  repeatingJobs.forEach((job) => {
356
441
  const jobId = String(job?.id || "");
357
- const projectedAvgTokens = Number(runSummary7dByJobId[jobId]?.avgTokensPerRun || 0);
442
+ const projectedAvgTokens = Number(
443
+ runSummary7dByJobId[jobId]?.avgTokensPerRun || 0,
444
+ );
358
445
  if (projectedAvgTokens > 0) values.push(projectedAvgTokens);
359
446
  });
360
447
  return buildTierThresholds(values);
361
- }, [jobById, nowMs, repeatingJobs, runBySlotKey, runSummary7dByJobId, timeline.slots]);
362
- const getSlotTokenTier = useCallback((slot = null) => {
363
- const jobId = String(slot?.jobId || "");
364
- const job = jobById[jobId] || null;
365
- const enabled = job?.enabled !== false;
366
- const isPastSlot = Number(slot?.scheduledAtMs || 0) <= nowMs;
367
- if (isPastSlot) {
368
- const slotRunTokens = getRunTotalTokens(runBySlotKey[String(slot?.key || "")] || {});
448
+ }, [
449
+ jobById,
450
+ nowMs,
451
+ repeatingJobs,
452
+ runBySlotKey,
453
+ runSummary7dByJobId,
454
+ timeline.slots,
455
+ ]);
456
+ const getSlotTokenTier = useCallback(
457
+ (slot = null) => {
458
+ const jobId = String(slot?.jobId || "");
459
+ const job = jobById[jobId] || null;
460
+ const enabled = job?.enabled !== false;
461
+ const isPastSlot = Number(slot?.scheduledAtMs || 0) <= nowMs;
462
+ if (isPastSlot) {
463
+ const slotRunTokens = getRunTotalTokens(
464
+ runBySlotKey[String(slot?.key || "")] || {},
465
+ );
466
+ return classifyTokenTier({
467
+ enabled,
468
+ tokenValue: slotRunTokens,
469
+ thresholds: slotTierThresholds,
470
+ });
471
+ }
472
+ const projectedAvgTokens = Number(
473
+ runSummary7dByJobId[jobId]?.avgTokensPerRun || 0,
474
+ );
369
475
  return classifyTokenTier({
370
476
  enabled,
371
- tokenValue: slotRunTokens,
477
+ tokenValue: projectedAvgTokens,
372
478
  thresholds: slotTierThresholds,
373
479
  });
374
- }
375
- const projectedAvgTokens = Number(runSummary7dByJobId[jobId]?.avgTokensPerRun || 0);
376
- return classifyTokenTier({
377
- enabled,
378
- tokenValue: projectedAvgTokens,
379
- thresholds: slotTierThresholds,
380
- });
381
- }, [jobById, nowMs, runBySlotKey, runSummary7dByJobId, slotTierThresholds]);
382
- const getJobProjectedTier = useCallback((jobId = "") => {
383
- const job = jobById[jobId] || null;
384
- return classifyTokenTier({
385
- enabled: job?.enabled !== false,
386
- tokenValue: Number(runSummary7dByJobId[jobId]?.avgTokensPerRun || 0),
387
- thresholds: slotTierThresholds,
388
- });
389
- }, [jobById, runSummary7dByJobId, slotTierThresholds]);
480
+ },
481
+ [jobById, nowMs, runBySlotKey, runSummary7dByJobId, slotTierThresholds],
482
+ );
483
+ const getJobProjectedTier = useCallback(
484
+ (jobId = "") => {
485
+ const job = jobById[jobId] || null;
486
+ return classifyTokenTier({
487
+ enabled: job?.enabled !== false,
488
+ tokenValue: Number(runSummary7dByJobId[jobId]?.avgTokensPerRun || 0),
489
+ thresholds: slotTierThresholds,
490
+ });
491
+ },
492
+ [jobById, runSummary7dByJobId, slotTierThresholds],
493
+ );
390
494
 
391
495
  const upcomingSlots = useMemo(
392
- () => getUpcomingSlots({ slots: timeline.slots, nowMs }),
496
+ () => getUpcomingSlots({ slots: timeline.slots, nowMs, limit: 3 }),
393
497
  [timeline.slots, nowMs],
394
498
  );
395
499
 
@@ -418,7 +522,9 @@ export const CronCalendar = ({
418
522
  const renderCompactStrip = () => {
419
523
  return html`
420
524
  ${upcomingSlots.length === 0
421
- ? html`<div class="text-xs text-gray-500 py-1">No upcoming jobs in the next 24 hours.</div>`
525
+ ? html`<div class="text-xs text-gray-500 py-1">
526
+ No upcoming jobs in the next 24 hours.
527
+ </div>`
422
528
  : html`
423
529
  <div class="cron-calendar-compact-strip">
424
530
  ${upcomingSlots.map((slot) => {
@@ -460,8 +566,12 @@ export const CronCalendar = ({
460
566
  ? html`
461
567
  <button
462
568
  class="text-[11px] text-gray-500 hover:text-gray-300 self-center transition-colors"
463
- onClick=${toggleExpanded}
464
- >+${Math.max(0, totalUpcoming - upcomingSlots.length)} more this week</button>
569
+ onClick=${() =>
570
+ onChangeCalendarView(kCalendarViewCalendar)}
571
+ >
572
+ +${Math.max(0, totalUpcoming - upcomingSlots.length)} more
573
+ this week
574
+ </button>
465
575
  `
466
576
  : null}
467
577
  </div>
@@ -476,7 +586,9 @@ export const CronCalendar = ({
476
586
  </div>
477
587
 
478
588
  ${hourRows.length === 0
479
- ? html`<div class="text-sm text-gray-500">No scheduled jobs in this rolling window.</div>`
589
+ ? html`<div class="text-sm text-gray-500">
590
+ No scheduled jobs in this rolling window.
591
+ </div>`
480
592
  : html`
481
593
  <div class="cron-calendar-grid-wrap">
482
594
  <div class="cron-calendar-grid-header">
@@ -493,34 +605,41 @@ export const CronCalendar = ({
493
605
  )}
494
606
  </div>
495
607
  <div class="cron-calendar-grid-body">
496
- ${hourRows.map((hourOfDay) => html`
497
- <div key=${hourOfDay} class="cron-calendar-grid-row">
498
- <div class="cron-calendar-hour-cell">${formatHourLabel(hourOfDay)}</div>
499
- ${timeline.days.map((day) => {
500
- const cellKey = buildCellKey(day.dayKey, hourOfDay);
501
- const cellSlots = slotsByCellKey[cellKey] || [];
502
- const visibleSlots = cellSlots.slice(0, 3);
503
- const overflowCount = Math.max(0, cellSlots.length - visibleSlots.length);
504
- return html`
505
- <div
506
- key=${cellKey}
507
- class=${`cron-calendar-grid-cell ${day.dayKey === todayDayKey ? "is-today" : ""}`}
508
- >
509
- ${visibleSlots.map((slot) => {
510
- const status = statusBySlotKey[slot.key] || "";
511
- const isPast = slot.scheduledAtMs <= nowMs;
512
- const tokenTier = getSlotTokenTier(slot);
513
- const tooltipText = buildJobTooltipText({
514
- jobName: slot.jobName,
515
- job: jobById[slot.jobId] || null,
516
- runSummary7d: runSummary7dByJobId[slot.jobId] || {},
517
- slotRun: runBySlotKey[slot.key] || null,
518
- latestRun: latestRunByJobId[slot.jobId],
519
- scheduledAtMs: slot.scheduledAtMs,
520
- scheduledStatus: status,
521
- nowMs,
522
- });
523
- return html`
608
+ ${hourRows.map(
609
+ (hourOfDay) => html`
610
+ <div key=${hourOfDay} class="cron-calendar-grid-row">
611
+ <div class="cron-calendar-hour-cell">
612
+ ${formatHourLabel(hourOfDay)}
613
+ </div>
614
+ ${timeline.days.map((day) => {
615
+ const cellKey = buildCellKey(day.dayKey, hourOfDay);
616
+ const cellSlots = slotsByCellKey[cellKey] || [];
617
+ const visibleSlots = cellSlots.slice(0, 3);
618
+ const overflowCount = Math.max(
619
+ 0,
620
+ cellSlots.length - visibleSlots.length,
621
+ );
622
+ return html`
623
+ <div
624
+ key=${cellKey}
625
+ class=${`cron-calendar-grid-cell ${day.dayKey === todayDayKey ? "is-today" : ""}`}
626
+ >
627
+ ${visibleSlots.map((slot) => {
628
+ const status = statusBySlotKey[slot.key] || "";
629
+ const isPast = slot.scheduledAtMs <= nowMs;
630
+ const tokenTier = getSlotTokenTier(slot);
631
+ const tooltipText = buildJobTooltipText({
632
+ jobName: slot.jobName,
633
+ job: jobById[slot.jobId] || null,
634
+ runSummary7d:
635
+ runSummary7dByJobId[slot.jobId] || {},
636
+ slotRun: runBySlotKey[slot.key] || null,
637
+ latestRun: latestRunByJobId[slot.jobId],
638
+ scheduledAtMs: slot.scheduledAtMs,
639
+ scheduledStatus: status,
640
+ nowMs,
641
+ });
642
+ return html`
524
643
  <${Tooltip}
525
644
  text=${tooltipText}
526
645
  widthClass="w-72"
@@ -529,16 +648,22 @@ export const CronCalendar = ({
529
648
  >
530
649
  <div
531
650
  key=${slot.key}
532
- class=${`cron-calendar-slot-chip ${slotStateClassName({
533
- isPast,
534
- mappedStatus: status,
535
- tokenTier,
536
- })}`}
651
+ class=${`cron-calendar-slot-chip ${slotStateClassName(
652
+ {
653
+ isPast,
654
+ mappedStatus: status,
655
+ tokenTier,
656
+ },
657
+ )}`}
537
658
  role="button"
538
659
  tabindex="0"
539
660
  onClick=${() => onSelectJob(slot.jobId)}
540
661
  onKeyDown=${(event) => {
541
- if (event.key !== "Enter" && event.key !== " ") return;
662
+ if (
663
+ event.key !== "Enter" &&
664
+ event.key !== " "
665
+ )
666
+ return;
542
667
  event.preventDefault();
543
668
  onSelectJob(slot.jobId);
544
669
  }}
@@ -547,19 +672,21 @@ export const CronCalendar = ({
547
672
  </div>
548
673
  </${Tooltip}>
549
674
  `;
550
- })}
551
- ${overflowCount > 0
552
- ? html`<div class="cron-calendar-slot-overflow">+${overflowCount} more</div>`
553
- : null}
554
- </div>
555
- `;
556
- })}
557
- </div>
558
- `)}
675
+ })}
676
+ ${overflowCount > 0
677
+ ? html`<div class="cron-calendar-slot-overflow">
678
+ +${overflowCount} more
679
+ </div>`
680
+ : null}
681
+ </div>
682
+ `;
683
+ })}
684
+ </div>
685
+ `,
686
+ )}
559
687
  </div>
560
688
  </div>
561
689
  `}
562
-
563
690
  ${repeatingJobs.length > 0
564
691
  ? html`
565
692
  <div class="cron-calendar-repeating-strip">
@@ -586,16 +713,19 @@ export const CronCalendar = ({
586
713
  triggerClassName="inline-flex max-w-full"
587
714
  >
588
715
  <div
589
- class=${`cron-calendar-repeating-pill ${slotStateClassName({
590
- isPast: false,
591
- mappedStatus: "",
592
- tokenTier: getJobProjectedTier(jobId),
593
- })}`}
716
+ class=${`cron-calendar-repeating-pill ${slotStateClassName(
717
+ {
718
+ isPast: false,
719
+ mappedStatus: "",
720
+ tokenTier: getJobProjectedTier(jobId),
721
+ },
722
+ )}`}
594
723
  role="button"
595
724
  tabindex="0"
596
725
  onClick=${() => onSelectJob(jobId)}
597
726
  onKeyDown=${(event) => {
598
- if (event.key !== "Enter" && event.key !== " ") return;
727
+ if (event.key !== "Enter" && event.key !== " ")
728
+ return;
599
729
  event.preventDefault();
600
730
  onSelectJob(jobId);
601
731
  }}
@@ -605,9 +735,11 @@ export const CronCalendar = ({
605
735
  ${formatCronScheduleLabel(job.schedule, {
606
736
  includeTimeZoneWhenDifferent: true,
607
737
  })}
608
- ${avgTokensPerRun > 0
609
- ? ` | avg ${formatTokenCount(avgTokensPerRun)} tk`
610
- : ""}
738
+ ${
739
+ avgTokensPerRun > 0
740
+ ? ` | avg ${formatTokenCount(avgTokensPerRun)} tk`
741
+ : ""
742
+ }
611
743
  </span>
612
744
  </div>
613
745
  </${Tooltip}>
@@ -622,24 +754,15 @@ export const CronCalendar = ({
622
754
 
623
755
  return html`
624
756
  <section class="bg-surface border border-border rounded-xl p-4 space-y-3">
625
- <div class="flex items-center justify-between gap-2">
626
- <div class="flex items-center gap-2">
627
- <h3 class="card-label cron-calendar-title">
628
- ${expanded ? "Rolling 7-Day Schedule" : "Upcoming (next 24h)"}
629
- </h3>
630
- ${!expanded && repeatingJobs.length > 0
631
- ? html`<span class="text-[11px] text-gray-500">+ ${repeatingJobs.length} repeating</span>`
632
- : null}
633
- </div>
634
- <${ActionButton}
635
- onClick=${toggleExpanded}
636
- tone="neutral"
637
- size="sm"
638
- idleLabel=${expanded ? "Collapse" : "Show full week"}
757
+ <div class="flex items-center gap-2">
758
+ <${SegmentedControl}
759
+ options=${kCalendarViewOptions}
760
+ value=${calendarView}
761
+ onChange=${onChangeCalendarView}
639
762
  />
640
763
  </div>
641
764
 
642
- ${expanded ? renderFullGrid() : renderCompactStrip()}
765
+ ${isCalendarView ? renderFullGrid() : renderCompactStrip()}
643
766
  </section>
644
767
  `;
645
768
  };