@linxin666/dsh-client-ui-task-board 0.4.2 → 0.4.4

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 (73) hide show
  1. package/README.i18n.yaml +2 -2
  2. package/README.md +21 -13
  3. package/README.zh.md +21 -13
  4. package/lib/client.js +1566 -929
  5. package/lib/client.js.map +1 -1
  6. package/lib/index.js +934 -176
  7. package/lib/types/client/board/TaskForm.d.ts +19 -1
  8. package/lib/types/client/board/preset-label.d.ts +22 -0
  9. package/lib/types/client/host-api.d.ts +24 -1
  10. package/lib/types/client/index.d.ts +15 -1
  11. package/lib/types/client/locales.d.ts +38 -2
  12. package/lib/types/client/native-panel.d.ts +62 -0
  13. package/lib/types/client/schedule-zone.d.ts +47 -0
  14. package/lib/types/client/settings-entry-form.d.ts +49 -0
  15. package/lib/types/core/controller.d.ts +42 -2
  16. package/lib/types/core/degraded-reason.d.ts +46 -0
  17. package/lib/types/core/package-name.d.ts +9 -0
  18. package/lib/types/core/schedule.d.ts +95 -20
  19. package/lib/types/core/subtask.d.ts +30 -11
  20. package/lib/types/core/tasks.d.ts +26 -2
  21. package/lib/types/core/use-cases/task-create.d.ts +2 -1
  22. package/lib/types/core/use-cases/task-schedule.d.ts +8 -4
  23. package/lib/types/core/use-cases/task-update.d.ts +1 -1
  24. package/lib/types/host-ledger.d.ts +65 -4
  25. package/lib/types/host-runner.d.ts +76 -3
  26. package/lib/types/host-service.d.ts +77 -5
  27. package/lib/types/index.d.ts +11 -1
  28. package/lib/types/mount-once.d.ts +38 -7
  29. package/lib/types/protocol.d.ts +9 -3
  30. package/package.json +26 -23
  31. package/src/client/board/LinkSubtaskModal.tsx +21 -11
  32. package/src/client/board/NewTaskModal.tsx +271 -150
  33. package/src/client/board/TaskCard.tsx +24 -5
  34. package/src/client/board/TaskDetail.tsx +83 -33
  35. package/src/client/board/TaskForm.tsx +48 -3
  36. package/src/client/board/preset-label.ts +44 -0
  37. package/src/client/board.module.css +130 -136
  38. package/src/client/host-api.ts +109 -6
  39. package/src/client/index.ts +50 -18
  40. package/src/client/locales.ts +82 -8
  41. package/src/client/native-panel.tsx +124 -0
  42. package/src/client/schedule-zone.ts +135 -0
  43. package/src/client/settings-entry-form.ts +142 -0
  44. package/src/core/controller.ts +66 -4
  45. package/src/core/degraded-reason.ts +81 -0
  46. package/src/core/package-name.ts +9 -0
  47. package/src/core/schedule.ts +211 -55
  48. package/src/core/store.ts +10 -1
  49. package/src/core/subtask.ts +77 -22
  50. package/src/core/tasks.ts +38 -4
  51. package/src/core/use-cases/task-create.ts +14 -2
  52. package/src/core/use-cases/task-schedule.ts +17 -6
  53. package/src/core/use-cases/task-update.ts +4 -1
  54. package/src/host/agent-tools.ts +40 -8
  55. package/src/host-ledger.ts +223 -23
  56. package/src/host-routes.ts +37 -1
  57. package/src/host-runner.ts +164 -14
  58. package/src/host-service.ts +232 -39
  59. package/src/index.ts +23 -1
  60. package/src/mount-once.ts +107 -13
  61. package/src/protocol.ts +27 -8
  62. package/lib/types/client/board-mount.d.ts +0 -23
  63. package/lib/types/client/body-mutations.d.ts +0 -45
  64. package/lib/types/client/panel-mount-core.d.ts +0 -56
  65. package/lib/types/client/sidebar-entry-core.d.ts +0 -55
  66. package/lib/types/client/sidebar-entry.d.ts +0 -29
  67. package/lib/types/host/run-guarded.d.ts +0 -39
  68. package/src/client/board-mount.tsx +0 -43
  69. package/src/client/body-mutations.ts +0 -133
  70. package/src/client/panel-mount-core.ts +0 -155
  71. package/src/client/sidebar-entry-core.ts +0 -237
  72. package/src/client/sidebar-entry.ts +0 -53
  73. package/src/host/run-guarded.ts +0 -76
@@ -17,6 +17,7 @@ import { applyArchiveTask, applyRestoreTask } from './use-cases/task-archive.ts'
17
17
  import { applyCreateTask } from './use-cases/task-create.ts'
18
18
  import { applyDeleteTask } from './use-cases/task-delete.ts'
19
19
  import { applyScheduleNextRun as applyScheduleRollForward, applySetSchedule } from './use-cases/task-schedule.ts'
20
+ import { resolveHostTimeZone } from './schedule.ts'
20
21
  import { applySetParent } from './use-cases/task-parent.ts'
21
22
  import { applyUpdateTask, type TaskUpdatePatch } from './use-cases/task-update.ts'
22
23
  import { DEFAULT_SUBTASK_DEPTH } from './subtask.ts'
@@ -62,6 +63,21 @@ function currentOf(sessions: SessionsControllerFace | undefined): string | undef
62
63
  return sessions?.current()
63
64
  }
64
65
 
66
+ /** Stable id shared by the board's sidebar panel row and its main-slot page. */
67
+ export const TASK_BOARD_PANEL_ID = 'task-board'
68
+
69
+ /**
70
+ * The layout's panel-navigation face. The board no longer owns the center
71
+ * column at the DOM level: selecting the panel is the layout's business, and
72
+ * this is the one call that asks for it (null returns to the conversation).
73
+ * Optional, so a composition without the layout service keeps the board's
74
+ * pure state transitions testable.
75
+ */
76
+ export interface PanelNavigationFace {
77
+ /** Select the board panel, or null to hand the column back to the conversation. */
78
+ select(panelId: string | null): void
79
+ }
80
+
65
81
  /** Controller dependencies (all swappable in tests). */
66
82
  export interface ControllerDeps {
67
83
  store: TaskStore
@@ -72,6 +88,8 @@ export interface ControllerDeps {
72
88
  uuid?: () => string
73
89
  /** Host-authoritative transport; absent keeps the legacy in-memory test path. */
74
90
  transport?: TaskBoardTransport
91
+ /** Layout panel selection; absent keeps the board state-only (tests, shell-less hosts). */
92
+ panel?: PanelNavigationFace
75
93
  }
76
94
 
77
95
  /**
@@ -253,15 +271,31 @@ export class BoardController {
253
271
 
254
272
  // --- view state -------------------------------------------------------------
255
273
 
274
+ /**
275
+ * Show the board. The layout owns which panel the center column renders, so
276
+ * the state flip and the panel selection travel together here; a composition
277
+ * with no layout face (tests, a shell-less host) still flips the state.
278
+ *
279
+ * The selection is requested AFTER the snapshot flips so a subscriber
280
+ * rendering against `boardOpen` never observes "open" while the shell still
281
+ * shows the conversation.
282
+ */
256
283
  openBoard(): void {
257
284
  if (this.boardOpen) return
258
285
  this.boardOpen = true
259
286
  this.notify()
287
+ this.selectPanel(TASK_BOARD_PANEL_ID)
260
288
  }
261
289
 
290
+ /**
291
+ * Return the center column to the conversation. The layout's own selection is
292
+ * the source of truth for what the column renders, so this asks for the
293
+ * conversation explicitly rather than only clearing local state.
294
+ */
262
295
  closeBoard(): void {
263
296
  this.boardOpen = false
264
297
  this.notify()
298
+ this.selectPanel(null)
265
299
  }
266
300
 
267
301
  toggleBoard(): void {
@@ -269,6 +303,30 @@ export class BoardController {
269
303
  else this.openBoard()
270
304
  }
271
305
 
306
+ /**
307
+ * Reflect a panel selection that came from OUTSIDE this controller (the user
308
+ * clicked another sidebar row, or the layout dropped the panel id). Keeps
309
+ * `boardOpen` aligned with what the column actually shows without asking the
310
+ * layout to select anything back.
311
+ * @param panelId - the layout's current panel id, or null for the conversation.
312
+ */
313
+ syncPanelSelection(panelId: string | null): void {
314
+ const open = panelId === TASK_BOARD_PANEL_ID
315
+ if (open === this.boardOpen) return
316
+ this.boardOpen = open
317
+ this.notify()
318
+ }
319
+
320
+ /** Ask the layout to select a panel; a shell that serves no layout face is a no-op. */
321
+ private selectPanel(panelId: string | null): void {
322
+ try {
323
+ this.deps.panel?.select(panelId)
324
+ } catch {
325
+ // The layout service throws by contract before its root entry mounts;
326
+ // the state flip above already stands, and the next selection retries.
327
+ }
328
+ }
329
+
272
330
  /**
273
331
  * Switch between the kanban columns and the archive view. Leaving the
274
332
  * archive view with an archived task still selected closes the selection —
@@ -460,8 +518,12 @@ export class BoardController {
460
518
  * @param patch - fields to change (absent fields keep their current value).
461
519
  * @returns true when applied, false when rejected (invalid cron / unknown task).
462
520
  */
463
- setSchedule(id: string, patch: { enabled?: boolean; cron?: string }): boolean {
464
- const { tasks, applied } = applySetSchedule(this.tasks, id, patch, this.now())
521
+ setSchedule(id: string, patch: { enabled?: boolean; cron?: string; timeZone?: string | null }): boolean {
522
+ // The Host zone is the fallback a rule with no stored zone follows: the
523
+ // mirrored snapshot carries it, and a controller without a Host mirror
524
+ // (pure client tests) falls back to the process zone.
525
+ const hostTimeZone = this.hostState?.scheduler.timeZone ?? resolveHostTimeZone()
526
+ const { tasks, applied } = applySetSchedule(this.tasks, id, patch, this.now(), hostTimeZone)
465
527
  if (!applied) return false
466
528
  if (this.deps.transport !== undefined) {
467
529
  void this.commitRemote({ kind: 'set-schedule', taskId: id, patch }, id)
@@ -547,8 +609,8 @@ export class BoardController {
547
609
  * (background navigation, the Host runner creating and selecting a fresh
548
610
  * execution session, settlement, other plugins), so closing on `current`
549
611
  * changes would evict the board without the user asking. The board closes
550
- * only on explicit user navigation: a sidebar session/workspace row click
551
- * (board-mount onClickSidebarRow) or the board's own actions
612
+ * only on explicit user navigation: selecting another panel (the layout owns
613
+ * selection, and `syncPanelSelection` follows it) or the board's own actions
552
614
  * (openSession / close). Keeping the hook preserves the subscription
553
615
  * contract for future listeners.
554
616
  */
@@ -0,0 +1,81 @@
1
+ /**
2
+ * The family health ledger's degraded-record contract (issues #1528, #1730).
3
+ *
4
+ * A family plugin row that fails during apply is recorded by the dsh-web-all
5
+ * shell as a degraded record; the shell serves the ledger through its
6
+ * loopback-only `GET /api/dsh-web-all/degraded` route because the failed
7
+ * plugin could not register any route of its own — the task board whose ledger
8
+ * lock is held by another DSH process is exactly that case, and without this
9
+ * the panel could only report "the Host API is not mounted".
10
+ *
11
+ * Both halves define this shape: the shell WRITES it (packages/dsh-web-all,
12
+ * src/degraded.ts — the two packages have no import edge, one is bundled into
13
+ * the other's profile) and a plugin's browser half PARSES it. The edits move
14
+ * together.
15
+ */
16
+
17
+ /** Where a degraded row failed: module import, plugin shape, or fiber start. */
18
+ export type DegradedStage = 'import' | 'shape' | 'start'
19
+
20
+ /** One degraded family row as the shell's health route serves it. */
21
+ export interface DegradedRow {
22
+ /** Real plugin package name from the shell row config. */
23
+ plugin: string
24
+ /** Where the failure happened. */
25
+ stage: DegradedStage
26
+ /** The shell's one-line reason, for display. */
27
+ reason: string
28
+ }
29
+
30
+ /**
31
+ * The one degraded record naming `plugin`, or undefined when that row is not
32
+ * degraded (or the payload does not come from the shell's own shape). A
33
+ * plugin may appear once: the shell keys the ledger by package name and
34
+ * refreshes the record on every new failure.
35
+ * @param body - parsed JSON body of the health route.
36
+ * @param plugin - the real plugin package name to look for.
37
+ * @returns the degraded row, or undefined when the ledger does not name it.
38
+ */
39
+ export function degradedRowOf(body: unknown, plugin: string): DegradedRow | undefined {
40
+ if (typeof body !== 'object' || body === null) return undefined
41
+ const ledger = (body as { ok?: unknown; degraded?: unknown })
42
+ if (ledger.ok !== true || !Array.isArray(ledger.degraded)) return undefined
43
+ for (const entry of ledger.degraded) {
44
+ if (typeof entry !== 'object' || entry === null) continue
45
+ const record = entry as { plugin?: unknown; stage?: unknown; reason?: unknown }
46
+ if (record.plugin !== plugin) continue
47
+ const stage = record.stage === 'import' || record.stage === 'shape' || record.stage === 'start' ? record.stage : undefined
48
+ if (stage === undefined) continue
49
+ return { plugin, stage, reason: typeof record.reason === 'string' ? record.reason : '' }
50
+ }
51
+ return undefined
52
+ }
53
+
54
+ /** The ledger owner pid a task-board lock reason names, when it names one. */
55
+ const OWNED_BY_PROCESS_RE = /already owned by process\s+(\d+)/
56
+
57
+ /**
58
+ * The marker the ledger appends when it could NOT confirm the recorded PID
59
+ * belongs to the lock's real owner: the PID was reused by an unrelated process
60
+ * after a crash, and the reason then carries the manual-removal step instead.
61
+ * That reason must be rendered verbatim — telling the user to close "the other
62
+ * DSH instance" would send them after a process that never touched the board.
63
+ */
64
+ const UNCONFIRMED_OWNER_MARKER = 'reused after a crash'
65
+
66
+ /**
67
+ * Extract the pid of the LIVE process a ledger-lock failure blames, when the
68
+ * reason identifies one. The task board's lock refusal is the one degraded
69
+ * reason that carries an owner the user can act on, so the panel renders the
70
+ * pid in its own sentence and adds the guidance that actually applies.
71
+ * @param reason - the shell's one-line failure reason.
72
+ * @returns the confirmed owner pid, or undefined when the reason names none or
73
+ * warns that the PID may have been reused (its own text owns the recovery).
74
+ */
75
+ export function ledgerOwnerPid(reason: string): number | undefined {
76
+ if (reason.includes(UNCONFIRMED_OWNER_MARKER)) return undefined
77
+ const match = OWNED_BY_PROCESS_RE.exec(reason)
78
+ if (match === null) return undefined
79
+ const pid = Number(match[1])
80
+ return Number.isSafeInteger(pid) && pid > 0 ? pid : undefined
81
+ }
@@ -0,0 +1,9 @@
1
+ /**
2
+ * npm identity of this plugin. The dsh-web-all degraded ledger is keyed by the
3
+ * real plugin package name of a family row, and this plugin's browser half
4
+ * must name its OWN row there to learn why its Host API is missing (issue
5
+ * #1730): the served bundle cannot derive the name, because a family row
6
+ * mounts the package from a versioned profile path. The host half spells the
7
+ * same name in its single-mount guard (src/index.ts); the two move together.
8
+ */
9
+ export const TASK_BOARD_PACKAGE = '@linxin666/dsh-client-ui-task-board'
@@ -1,15 +1,36 @@
1
1
  /**
2
- * Minimal 5-field cron parsing and next-run computation for scheduled task
3
- * runs. Framework-free and dependency-free so the scheduler and controller
4
- * share one tiny pure module.
2
+ * Zone-aware 5-field cron parsing and next-run computation for scheduled task
3
+ * runs. Framework-free and dependency-free (Intl only) so the Host scheduler
4
+ * and the browser next-run preview share one tiny pure module.
5
5
  *
6
6
  * Grammar: five whitespace-separated fields, 分 时 日 月 周. Every field
7
7
  * supports the wildcard, step (wildcard or range + "/n"), single value,
8
8
  * inclusive range a-b, and comma lists mixing any of those. Ranges: minutes
9
9
  * 0-59, hours 0-23, days 1-31, months 1-12, weekdays 0-7 (0 and 7 both mean
10
- * Sunday). When both the day and weekday fields are restricted they combine
11
- * with OR semantics (standard cron). Invalid expressions parse to null and
12
- * are rejected by the UI/controller.
10
+ * Sunday). Invalid expressions parse to null and are rejected by the UI and
11
+ * the Host.
12
+ *
13
+ * Day semantics follow Vixie cron: a field is *starred* when its field text
14
+ * starts with `*`. When day-of-month and day-of-week are both restricted the
15
+ * fields combine with OR; otherwise they combine with AND. The distinction
16
+ * matters for a stepped star such as `*/2`, which still restricts the dates it
17
+ * matches, so `0 0 */2 * 1` means "the Mondays that fall on an even day of
18
+ * month" rather than "every even day, or every Monday".
19
+ *
20
+ * Time zone: every candidate wall clock is interpreted in an explicit IANA
21
+ * zone (the schedule's stored zone, or the Host zone when none is given).
22
+ * A wall clock that does not exist (spring-forward gap) is skipped, and an
23
+ * ambiguous wall clock (fall-back overlap) resolves to the earlier instant, so
24
+ * a repeated local minute fires exactly once. The Host-scheduler semantics are
25
+ * byte-identical to `@deepseek-ai/dsh-schedule`'s resolver, which was verified
26
+ * against it across twelve IANA zones (including 30-minute and 45-minute DST
27
+ * offsets and a date-line jump) and 300k decision instants.
28
+ *
29
+ * That upstream is on the experimental track and is NOT a dependency here (no
30
+ * import, no inject, no peer range), so a change to it fails no test. Re-run the
31
+ * differential check on any `dsh-schedule` release or cohort bump that moves it,
32
+ * and keep this comment honest; the re-verification trigger is recorded in
33
+ * [the owning Agent Note](../../../../.agents/notes/implemented/feature/2026-09-29-task-board-schedule-time-zone.md).
13
34
  */
14
35
 
15
36
  /** The parsed match sets of one cron expression. */
@@ -20,10 +41,10 @@ export interface CronSchedule {
20
41
  months: ReadonlySet<number>
21
42
  /** Weekdays 0-6, 0 = Sunday (input 7 normalized to 0). */
22
43
  weekdays: ReadonlySet<number>
23
- /** Whether the day-of-month field was the literal '*' (unrestricted). */
24
- dayWildcard: boolean
25
- /** Whether the weekday field was the literal '*' (unrestricted). */
26
- weekdayWildcard: boolean
44
+ /** Whether the day-of-month field text starts with `*` (Vixie starred). */
45
+ dayStarred: boolean
46
+ /** Whether the weekday field text starts with `*` (Vixie starred). */
47
+ weekdayStarred: boolean
27
48
  }
28
49
 
29
50
  /** Inclusive ranges per field, in cron order. */
@@ -35,6 +56,141 @@ const FIELD_RANGES: ReadonlyArray<readonly [number, number]> = [
35
56
  [0, 7], // weekdays (7 = Sunday, normalized below)
36
57
  ]
37
58
 
59
+ /** Fallback zone when the process reports no usable IANA zone. */
60
+ export const HOST_TIME_ZONE_FALLBACK = 'UTC'
61
+
62
+ /** IANA `Area/Location`, plus the bare `UTC` the platform reports. */
63
+ const IANA_ZONE = /^[A-Za-z][A-Za-z0-9_+.-]*(?:\/[A-Za-z0-9_+.-]+)+$/
64
+
65
+ /** One resolved wall-clock reading in some zone. */
66
+ export interface ZonedParts {
67
+ year: number
68
+ month: number
69
+ day: number
70
+ hour: number
71
+ minute: number
72
+ }
73
+
74
+ /** Cached formatters: constructing an Intl formatter dominates the cost of a resolve. */
75
+ const FORMATTERS = new Map<string, Intl.DateTimeFormat>()
76
+
77
+ function zoneFormatter(timeZone: string): Intl.DateTimeFormat {
78
+ let formatter = FORMATTERS.get(timeZone)
79
+ if (formatter === undefined) {
80
+ formatter = new Intl.DateTimeFormat('en-US', {
81
+ timeZone,
82
+ hourCycle: 'h23',
83
+ year: 'numeric',
84
+ month: '2-digit',
85
+ day: '2-digit',
86
+ hour: '2-digit',
87
+ minute: '2-digit',
88
+ })
89
+ FORMATTERS.set(timeZone, formatter)
90
+ }
91
+ return formatter
92
+ }
93
+
94
+ /**
95
+ * The wall-clock reading of one instant in an explicit zone. Reading through
96
+ * Intl (rather than the process `Date` accessors) is what makes the result
97
+ * independent of the Host's own TZ.
98
+ * @param timeZone - explicit IANA zone.
99
+ * @param epochMs - instant to read.
100
+ * @returns the zone-local calendar and clock fields.
101
+ */
102
+ export function partsInZone(timeZone: string, epochMs: number): ZonedParts {
103
+ const fields: Record<string, string> = {}
104
+ for (const part of zoneFormatter(timeZone).formatToParts(new Date(epochMs))) {
105
+ if (part.type !== 'literal') fields[part.type] = part.value
106
+ }
107
+ return {
108
+ year: Number(fields.year),
109
+ month: Number(fields.month),
110
+ day: Number(fields.day),
111
+ hour: Number(fields.hour),
112
+ minute: Number(fields.minute),
113
+ }
114
+ }
115
+
116
+ /** The zone's UTC offset in effect at one instant, in milliseconds. */
117
+ function offsetAt(timeZone: string, epochMs: number): number {
118
+ const parts = partsInZone(timeZone, epochMs)
119
+ return Date.UTC(parts.year, parts.month - 1, parts.day, parts.hour, parts.minute) - epochMs
120
+ }
121
+
122
+ /**
123
+ * Resolve a wall clock in an explicit zone to its instant.
124
+ *
125
+ * The offset is probed a day either side of the naive guess so both DST
126
+ * transitions are covered, then every candidate offset whose inverse maps back
127
+ * to the requested wall clock is kept. No survivor means the wall clock does
128
+ * not exist (a spring-forward gap); several means it is ambiguous (a fall-back
129
+ * overlap), and the earliest instant wins so the repeated clock is taken once.
130
+ * @param timeZone - explicit IANA zone.
131
+ * @param year - full calendar year.
132
+ * @param month - 1-12.
133
+ * @param day - day of month.
134
+ * @param hour - 0-23.
135
+ * @param minute - 0-59.
136
+ * @returns the instant in ms epoch, or undefined when the wall clock is skipped.
137
+ */
138
+ export function zonedEpoch(
139
+ timeZone: string,
140
+ year: number,
141
+ month: number,
142
+ day: number,
143
+ hour: number,
144
+ minute: number,
145
+ ): number | undefined {
146
+ const naive = Date.UTC(year, month - 1, day, hour, minute)
147
+ const offsets = new Set([
148
+ offsetAt(timeZone, naive - 86_400_000),
149
+ offsetAt(timeZone, naive),
150
+ offsetAt(timeZone, naive + 86_400_000),
151
+ ])
152
+ let earliest: number | undefined
153
+ for (const offset of offsets) {
154
+ const candidate = naive - offset
155
+ const parts = partsInZone(timeZone, candidate)
156
+ if (parts.year !== year || parts.month !== month || parts.day !== day) continue
157
+ if (parts.hour !== hour || parts.minute !== minute) continue
158
+ if (earliest === undefined || candidate < earliest) earliest = candidate
159
+ }
160
+ return earliest
161
+ }
162
+
163
+ /**
164
+ * The Host process's IANA zone, used for schedules that store no zone of their
165
+ * own (a ledger written before zones were persisted).
166
+ * @returns a zone name Intl accepts, never empty.
167
+ */
168
+ export function resolveHostTimeZone(): string {
169
+ try {
170
+ const zone = Intl.DateTimeFormat().resolvedOptions().timeZone
171
+ return typeof zone === 'string' && zone !== '' ? zone : HOST_TIME_ZONE_FALLBACK
172
+ } catch {
173
+ return HOST_TIME_ZONE_FALLBACK
174
+ }
175
+ }
176
+
177
+ /**
178
+ * Whether a value names a zone this runtime can actually resolve. A stored
179
+ * zone is trusted only after it round-trips through Intl, so a typo or a zone
180
+ * that this ICU build does not know can never arm a schedule.
181
+ * @param value - candidate zone name.
182
+ * @returns true when the name is usable.
183
+ */
184
+ export function isValidTimeZone(value: string): boolean {
185
+ if (value.trim() !== value || value === '') return false
186
+ if (value !== 'UTC' && !IANA_ZONE.test(value)) return false
187
+ try {
188
+ return new Intl.DateTimeFormat('en-US', { timeZone: value }).resolvedOptions().timeZone !== undefined
189
+ } catch {
190
+ return false
191
+ }
192
+ }
193
+
38
194
  /**
39
195
  * Parse a 5-field cron expression.
40
196
  * @returns the match sets, or null when the expression is invalid.
@@ -57,11 +213,11 @@ export function parseCron(expr: string): CronSchedule | null {
57
213
  days: sets[2],
58
214
  months: sets[3],
59
215
  weekdays,
60
- // Only the literal '*' marks a field unrestricted: an explicit full
61
- // enumeration such as '1-31' is a restricted field and must not collapse
62
- // into the wildcard (it participates in day/weekday OR semantics).
63
- dayWildcard: fields[2] === '*',
64
- weekdayWildcard: fields[4] === '*',
216
+ // Vixie selects the day branch from the field TEXT: only a field starting
217
+ // with '*' is starred. A written-out range such as '1-31' is restricted and
218
+ // still participates in the OR/AND choice.
219
+ dayStarred: fields[2].startsWith('*'),
220
+ weekdayStarred: fields[4].startsWith('*'),
65
221
  }
66
222
  }
67
223
 
@@ -71,57 +227,61 @@ export function isValidCron(expr: string): boolean {
71
227
  }
72
228
 
73
229
  /**
74
- * Compute the next matching instant after `fromMs` (ms epoch), in local time,
75
- * at minute granularity, strictly greater than `fromMs`. Returns the ms epoch
76
- * of the matching minute's start, or undefined when the calendar constraint
77
- * can never match (for example `0 0 30 2 *`). The five-year horizon includes
78
- * a full leap cycle, so a valid February 29 schedule remains reachable from
79
- * every non-leap year.
230
+ * Compute the next matching instant after `fromMs` strictly greater than it,
231
+ * interpreting the expression's wall clock in `timeZone` (the Host zone when
232
+ * omitted). Returns the ms epoch of the matching minute's start, or undefined
233
+ * when the calendar constraint can never match (for example `0 0 30 2 *`).
234
+ * The five-year horizon includes a full leap cycle, so a valid February 29
235
+ * schedule remains reachable from every non-leap year.
80
236
  *
81
237
  * Walks candidate year/month/day/hour/minute values straight from the parsed
82
238
  * field sets instead of scanning every minute: a sparse expression such as
83
239
  * `0 0 29 2 *` used to iterate ~1.5M wall-clock minutes before reaching the
84
- * next leap day. Wall-clock field construction + the final `matches` re-check
85
- * preserve the old minute scan's DST semantics exactly (nonexistent spring
86
- * minutes normalize forward and the repeated fall-back hour is never visited).
240
+ * next leap day. A candidate wall clock that the zone skips (spring-forward
241
+ * gap) is passed over, which is what makes the gap semantics fall out of the
242
+ * candidate walk rather than needing a separate rule.
243
+ * @param expr - 5-field cron expression.
244
+ * @param fromMs - exclusive lower bound, ms epoch.
245
+ * @param timeZone - explicit IANA zone; defaults to the Host zone.
246
+ * @returns the next trigger instant, or undefined when unreachable.
87
247
  */
88
- export function nextRunAtMs(expr: string, fromMs: number): number | undefined {
248
+ export function nextRunAtMs(expr: string, fromMs: number, timeZone?: string): number | undefined {
89
249
  const schedule = parseCron(expr)
90
250
  if (schedule === null) return undefined
91
251
  if (!hasPossibleCalendarDay(schedule)) return undefined
92
- const from = new Date(fromMs)
252
+ const zone = timeZone ?? resolveHostTimeZone()
253
+ const start = partsInZone(zone, fromMs)
93
254
  const limitMs = fromMs + 5 * 366 * 24 * 60 * 60 * 1000
94
255
 
95
256
  const sortedMinutes = [...schedule.minutes].sort((a, b) => a - b)
96
257
  const sortedHours = [...schedule.hours].sort((a, b) => a - b)
97
258
  const sortedMonths = [...schedule.months].sort((a, b) => a - b)
98
259
 
99
- let year = from.getFullYear()
100
- let month = from.getMonth() + 1
101
- let day = from.getDate()
102
- let hour = from.getHours()
103
- // Strictly after fromMs: the old scan started from the next minute.
104
- let minute = from.getMinutes() + 1
260
+ let year = start.year
261
+ let month = start.month
262
+ let day = start.day
263
+ let hour = start.hour
264
+ // Strictly after fromMs: the walk starts from the next minute.
265
+ let minute = start.minute + 1
105
266
 
106
- while (new Date(year, month - 1, 1, 0, 0, 0, 0).getTime() <= limitMs) {
267
+ while (Date.UTC(year, month - 1, 1) <= limitMs + 86_400_000) {
107
268
  for (const candidateMonth of sortedMonths) {
108
269
  if (candidateMonth < month) continue
109
- const daysInMonth = new Date(year, candidateMonth, 0).getDate()
270
+ const daysInMonth = new Date(Date.UTC(year, candidateMonth, 0)).getUTCDate()
110
271
  const dayStart = candidateMonth === month ? day : 1
111
272
  for (let candidateDay = dayStart; candidateDay <= daysInMonth; candidateDay += 1) {
112
- const dayProbe = new Date(year, candidateMonth - 1, candidateDay, 0, 0, 0, 0)
113
- if (!dayCandidate(schedule, dayProbe)) continue
273
+ if (!dayCandidate(schedule, year, candidateMonth, candidateDay)) continue
114
274
  const hourStart = candidateMonth === month && candidateDay === day ? hour : 0
115
275
  for (const candidateHour of sortedHours) {
116
276
  if (candidateHour < hourStart) continue
117
277
  const minuteStart = candidateMonth === month && candidateDay === day && candidateHour === hour ? minute : 0
118
278
  for (const candidateMinute of sortedMinutes) {
119
279
  if (candidateMinute < minuteStart) continue
120
- const candidate = new Date(year, candidateMonth - 1, candidateDay, candidateHour, candidateMinute, 0, 0)
121
- const time = candidate.getTime()
280
+ const time = zonedEpoch(zone, year, candidateMonth, candidateDay, candidateHour, candidateMinute)
281
+ if (time === undefined) continue
122
282
  if (time <= fromMs) continue
123
283
  if (time > limitMs) return undefined
124
- if (matches(schedule, candidate)) return time
284
+ return time
125
285
  }
126
286
  }
127
287
  }
@@ -135,18 +295,22 @@ export function nextRunAtMs(expr: string, fromMs: number): number | undefined {
135
295
  return undefined
136
296
  }
137
297
 
138
- /** Day/weekday OR gate shared by {@link matches} and the candidate scan. */
139
- function dayCandidate(schedule: CronSchedule, date: Date): boolean {
140
- const dayMatches = schedule.days.has(date.getDate())
141
- const weekdayMatches = schedule.weekdays.has(date.getDay())
142
- if (schedule.dayWildcard) return weekdayMatches
143
- if (schedule.weekdayWildcard) return dayMatches
144
- return dayMatches || weekdayMatches
298
+ /**
299
+ * The Vixie day gate shared by the candidate walk. Both fields restricted is
300
+ * OR; any other combination is AND, because a starred field matches every
301
+ * value and so cannot widen the result.
302
+ */
303
+ function dayCandidate(schedule: CronSchedule, year: number, month: number, day: number): boolean {
304
+ const dayMatches = schedule.days.has(day)
305
+ // Day of week is a property of the calendar date, so it is zone-independent.
306
+ const weekdayMatches = schedule.weekdays.has(new Date(Date.UTC(year, month - 1, day)).getUTCDay())
307
+ if (!schedule.dayStarred && !schedule.weekdayStarred) return dayMatches || weekdayMatches
308
+ return dayMatches && weekdayMatches
145
309
  }
146
310
 
147
311
  /** Reject impossible month/day pairs without spending the multi-year scan. */
148
312
  function hasPossibleCalendarDay(schedule: CronSchedule): boolean {
149
- if (schedule.dayWildcard || !schedule.weekdayWildcard) return true
313
+ if (schedule.dayStarred || !schedule.weekdayStarred) return true
150
314
  const maximumDay = new Map<number, number>([
151
315
  [1, 31], [2, 29], [3, 31], [4, 30], [5, 31], [6, 30],
152
316
  [7, 31], [8, 31], [9, 30], [10, 31], [11, 30], [12, 31],
@@ -194,14 +358,6 @@ function parseField(field: string, min: number, max: number, out: Set<number>):
194
358
  return true
195
359
  }
196
360
 
197
- /** Day/weekday OR semantics: a restricted day field alone gates, and vice versa. */
198
- function matches(schedule: CronSchedule, date: Date): boolean {
199
- if (!schedule.minutes.has(date.getMinutes())) return false
200
- if (!schedule.hours.has(date.getHours())) return false
201
- if (!schedule.months.has(date.getMonth() + 1)) return false
202
- return dayCandidate(schedule, date)
203
- }
204
-
205
361
  function isDigits(value: string): boolean {
206
362
  return /^\d+$/.test(value)
207
363
  }
package/src/core/store.ts CHANGED
@@ -9,7 +9,7 @@
9
9
  * channel later); tests run against the in-memory backend and a jsdom
10
10
  * localStorage backend.
11
11
  */
12
- import { isValidCron } from './schedule.ts'
12
+ import { isValidCron, isValidTimeZone } from './schedule.ts'
13
13
  import { isTaskPermission, isTaskStatus, normalizeTags, normalizeTargetId, type ScheduleRule, type TaskFreeze, type TaskRecord, type TaskPermission, type TaskStatus } from './tasks.ts'
14
14
  import { isExecutionOutcome } from './subtask.ts'
15
15
  import type { TaskHandover } from './handover.ts'
@@ -68,6 +68,7 @@ function isTaskRecordShape(value: unknown): value is Omit<TaskRecord, 'status'>
68
68
  if (record.mode !== undefined && typeof record.mode !== 'string') return false
69
69
  if (record.permission !== undefined && typeof record.permission !== 'string') return false
70
70
  if (record.reuseSession !== undefined && typeof record.reuseSession !== 'boolean') return false
71
+ if (record.goalRun !== undefined && typeof record.goalRun !== 'boolean') return false
71
72
  if (!Array.isArray(record.executions)) return false
72
73
  for (const execution of record.executions) {
73
74
  if (typeof execution !== 'object' || execution === null) return false
@@ -108,9 +109,14 @@ function normalizeSchedule(schedule: unknown): ScheduleRule | undefined {
108
109
  // schedule instead of being dropped for later repair.
109
110
  if (typeof rule.cron !== 'string') return undefined
110
111
  if (rule.cron.trim() === '' || !isValidCron(rule.cron)) return undefined
112
+ // A stored zone survives only when this runtime can resolve it; an unusable
113
+ // name is dropped (the rule then follows the Host zone) instead of being
114
+ // kept to fail every later resolve.
115
+ const timeZone = typeof rule.timeZone === 'string' && isValidTimeZone(rule.timeZone) ? rule.timeZone : undefined
111
116
  return {
112
117
  enabled: rule.enabled === true,
113
118
  cron: rule.cron,
119
+ ...(timeZone === undefined ? {} : { timeZone }),
114
120
  nextRunAt: typeof rule.nextRunAt === 'number' ? rule.nextRunAt : undefined,
115
121
  lastTriggeredAt: typeof rule.lastTriggeredAt === 'number' ? rule.lastTriggeredAt : undefined,
116
122
  }
@@ -202,6 +208,9 @@ export function parseLedger(raw: string | null): TaskRecord[] {
202
208
  task.archivedAt = typeof row.archivedAt === 'number' && Number.isFinite(row.archivedAt) ? row.archivedAt : undefined
203
209
  task.permission = isTaskPermission(row.permission) ? row.permission as TaskPermission : undefined
204
210
  task.reuseSession = row.reuseSession === true ? true : undefined
211
+ // The goal opt-in is stored only when it is OFF: absent means the default
212
+ // (start each run with /goal), so a hand-edited true normalizes back to it.
213
+ task.goalRun = row.goalRun === false ? false : undefined
205
214
  task.freeze = normalizeFreeze(row.freeze)
206
215
  task.handover = normalizeHandover(row.handover)
207
216
  // Tags are repaired field by field like the schedule: a malformed entry is