@linxin666/dsh-client-ui-task-board 0.1.14 → 0.1.15

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.
@@ -7,14 +7,22 @@
7
7
  * navigates to a session (the sessions-list `current` selection changes).
8
8
  * Framework-free (structural runtime faces) so the whole orchestration is
9
9
  * unit-testable with fakes.
10
+ *
11
+ * The per use-case domain transitions (create/update/delete/schedule) live in
12
+ * dedicated modules under core/use-cases and are applied here; the controller
13
+ * owns only the orchestration seam (state, persistence, notify, execution,
14
+ * navigation, reconciliation).
10
15
  */
11
16
  import { ExecutionService, type ExecutionEvent } from './execution.ts'
12
- import { isValidCron, nextRunAtMs } from './schedule.ts'
13
17
  import type { TaskStore } from './store.ts'
14
18
  import {
15
- createTask, settleExecution, startExecution, withSchedule, withStatus,
19
+ settleExecution, startExecution, withStatus,
16
20
  type NewTaskInput, type TaskRecord, type TaskStatus,
17
21
  } from './tasks.ts'
22
+ import { applyCreateTask } from './use-cases/task-create.ts'
23
+ import { applyDeleteTask } from './use-cases/task-delete.ts'
24
+ import { applyScheduleNextRun as applyScheduleRollForward, applySetSchedule } from './use-cases/task-schedule.ts'
25
+ import { applyUpdateTask } from './use-cases/task-update.ts'
18
26
 
19
27
  /** The sessions face the controller needs for navigation awareness. */
20
28
  export interface SessionsControllerFace {
@@ -168,21 +176,18 @@ export class BoardController {
168
176
  this.notify()
169
177
  }
170
178
 
171
- // --- task mutations ---------------------------------------------------------
179
+ // --- task mutations (use-case transitions in core/use-cases) -----------------
172
180
 
173
181
  createTask(input: NewTaskInput): TaskRecord | undefined {
174
- const title = input.title.trim()
175
- if (title === '') return undefined
176
- const task = createTask(input, this.now(), this.uuid())
177
- this.tasks = [...this.tasks, task]
182
+ const { task, tasks } = applyCreateTask(this.tasks, input, this.now(), this.uuid())
183
+ if (task === undefined) return undefined
184
+ this.tasks = [...tasks]
178
185
  this.persistAndNotify()
179
186
  return task
180
187
  }
181
188
 
182
189
  updateTask(id: string, patch: Partial<Pick<TaskRecord, 'title' | 'description' | 'prompt'>>): void {
183
- this.tasks = this.tasks.map(task => task.id === id
184
- ? { ...task, ...patch, updatedAt: this.now() }
185
- : task)
190
+ this.tasks = [...applyUpdateTask(this.tasks, id, patch, this.now())]
186
191
  this.persistAndNotify()
187
192
  }
188
193
 
@@ -192,8 +197,9 @@ export class BoardController {
192
197
  }
193
198
 
194
199
  deleteTask(id: string): void {
195
- this.tasks = this.tasks.filter(task => task.id !== id)
196
- if (this.selectedTaskId === id) this.selectedTaskId = undefined
200
+ const { tasks, selectionCleared } = applyDeleteTask(this.tasks, this.selectedTaskId, id)
201
+ this.tasks = [...tasks]
202
+ if (selectionCleared) this.selectedTaskId = undefined
197
203
  this.persistAndNotify()
198
204
  }
199
205
 
@@ -203,21 +209,15 @@ export class BoardController {
203
209
  * Update a task's schedule rule. A blank or invalid cron expression is
204
210
  * rejected (returns false, state untouched). When the rule ends up enabled
205
211
  * the next run instant is computed immediately; a disabled rule carries no
206
- * next-run instant.
212
+ * next-run instant. Delegates the domain transition to the schedule use case.
207
213
  * @param id - the task to schedule.
208
214
  * @param patch - fields to change (absent fields keep their current value).
209
215
  * @returns true when applied, false when rejected (invalid cron / unknown task).
210
216
  */
211
217
  setSchedule(id: string, patch: { enabled?: boolean; cron?: string }): boolean {
212
- const task = this.tasks.find(candidate => candidate.id === id)
213
- if (task === undefined) return false
214
- const current = task.schedule
215
- const cron = (patch.cron ?? current?.cron ?? '').trim()
216
- if (cron === '' || !isValidCron(cron)) return false
217
- const enabled = patch.enabled ?? current?.enabled ?? false
218
- const nextRunAt = enabled ? nextRunAtMs(cron, this.now()) : undefined
219
- this.tasks = this.tasks.map(candidate =>
220
- candidate.id === id ? withSchedule(candidate, { enabled, cron, nextRunAt }, this.now()) : candidate)
218
+ const { tasks, applied } = applySetSchedule(this.tasks, id, patch, this.now())
219
+ if (!applied) return false
220
+ this.tasks = [...tasks]
221
221
  this.persistAndNotify()
222
222
  return true
223
223
  }
@@ -228,10 +228,8 @@ export class BoardController {
228
228
  * schedule rule (it was deleted mid-tick, for example).
229
229
  */
230
230
  applyScheduleNextRun(id: string, nextRunAt: number | undefined, lastTriggeredAt: number | undefined): void {
231
- this.tasks = this.tasks.map(task =>
232
- task.id === id && task.schedule !== undefined
233
- ? withSchedule(task, { nextRunAt, lastTriggeredAt }, this.now())
234
- : task)
231
+ const next = applyScheduleRollForward(this.tasks, id, nextRunAt, lastTriggeredAt, this.now())
232
+ this.tasks = [...next]
235
233
  this.persistAndNotify()
236
234
  }
237
235
 
@@ -392,3 +390,4 @@ function attachSessionId(
392
390
  execution.id === executionId ? { ...execution, sessionId } : execution),
393
391
  }
394
392
  }
393
+
@@ -9,6 +9,12 @@
9
9
  * a task still running at its due instant is skipped by the controller's
10
10
  * runTask guard and simply waits for the next cron match.
11
11
  *
12
+ * The ticker is controlled: `start` arms a single interval guarded by an
13
+ * idempotence check (a second start while running is a no-op, so wiring can
14
+ * never leak a duplicate timer), and `stop`/`dispose` clear the timer and
15
+ * drop the environment listeners. The board unmount path calls stop/dispose,
16
+ * so no interval or listener outlives the board.
17
+ *
12
18
  * Framework-free: all runtime access flows through the injected deps
13
19
  * (structural faces), so tests drive ticks directly without timers.
14
20
  */
@@ -47,27 +53,54 @@ export interface SchedulerDeps {
47
53
  */
48
54
  export class SchedulerService {
49
55
  private timer: ReturnType<typeof setInterval> | undefined
56
+ private environmentListener: (() => void) | undefined
50
57
  private disposed = false
58
+ private started = false
51
59
 
52
60
  /** @param deps - tasks/clock/trigger/apply faces (see {@link SchedulerDeps}). */
53
61
  constructor(private readonly deps: SchedulerDeps) {}
54
62
 
55
- /** Start ticking: one immediate check (catch-up after reload) + the interval. */
63
+ /**
64
+ * Start ticking: one immediate check (catch-up after reload) + the interval.
65
+ * Single-instance: a second start while already armed is a no-op, so a
66
+ * re-entrant mount can never stack a duplicate timer.
67
+ */
56
68
  start(): void {
57
69
  if (this.disposed) return
70
+ if (this.started) return
71
+ this.started = true
58
72
  // Immediate catch-up tick: schedules whose due instant passed while the
59
73
  // tab was closed are triggered as soon as the runtime is ready.
60
74
  this.tick()
61
75
  this.timer = setInterval(() => { this.tick() }, this.deps.tickMs ?? 60_000)
62
- this.deps.environment?.addEventListener('visibilitychange', this.onVisibility)
76
+ if (this.deps.environment !== undefined) {
77
+ this.environmentListener = () => { this.tick() }
78
+ this.deps.environment.addEventListener('visibilitychange', this.environmentListener)
79
+ }
80
+ }
81
+
82
+ /**
83
+ * Stop ticking and drop listeners (idempotent). Preferred shutdown verb for
84
+ * callers that treat the scheduler as a controlled ticker; `dispose` is an
85
+ * alias.
86
+ */
87
+ stop(): void {
88
+ this.dispose()
63
89
  }
64
90
 
65
91
  /** Stop ticking and drop listeners (idempotent). */
66
92
  dispose(): void {
93
+ if (this.disposed && this.timer === undefined && this.environmentListener === undefined) return
67
94
  this.disposed = true
68
- if (this.timer !== undefined) clearInterval(this.timer)
69
- this.timer = undefined
70
- this.deps.environment?.removeEventListener('visibilitychange', this.onVisibility)
95
+ this.started = false
96
+ if (this.timer !== undefined) {
97
+ clearInterval(this.timer)
98
+ this.timer = undefined
99
+ }
100
+ if (this.environmentListener !== undefined && this.deps.environment !== undefined) {
101
+ this.deps.environment.removeEventListener('visibilitychange', this.environmentListener)
102
+ this.environmentListener = undefined
103
+ }
71
104
  }
72
105
 
73
106
  /**
@@ -99,6 +132,5 @@ export class SchedulerService {
99
132
  if (accepted) this.deps.applySchedule(task.id, next, now)
100
133
  }
101
134
  }
102
-
103
- private readonly onVisibility = (): void => { this.tick() }
104
135
  }
136
+
@@ -0,0 +1,33 @@
1
+ /**
2
+ * Create-task use case: mint a new task from user input, rejecting a blank
3
+ * title. Pure ledger transition (no persistence or notify — the controller
4
+ * orchestrates those), so it is unit-testable without any runtime face.
5
+ */
6
+ import { createTask, type NewTaskInput, type TaskRecord } from '../tasks.ts'
7
+
8
+ /** Result of a create transition: the new task (when accepted) + the next ledger. */
9
+ export interface CreateTaskResult {
10
+ /** The minted task, or undefined when the input was rejected (blank title). */
11
+ task: TaskRecord | undefined
12
+ /** The next ledger; identical reference when rejected. */
13
+ tasks: readonly TaskRecord[]
14
+ }
15
+
16
+ /**
17
+ * Apply a create against the current ledger. Returns the new task and the
18
+ * appended ledger, or the unchanged ledger when the title is blank.
19
+ * @param tasks - current ledger.
20
+ * @param input - raw user input (title/description/prompt).
21
+ * @param now - clock instant (ms epoch).
22
+ * @param id - minted task id.
23
+ */
24
+ export function applyCreateTask(
25
+ tasks: readonly TaskRecord[],
26
+ input: NewTaskInput,
27
+ now: number,
28
+ id: string,
29
+ ): CreateTaskResult {
30
+ if (input.title.trim() === '') return { task: undefined, tasks }
31
+ const task = createTask(input, now, id)
32
+ return { task, tasks: [...tasks, task] }
33
+ }
@@ -0,0 +1,33 @@
1
+ /**
2
+ * Delete-task use case: drop a task from the ledger and clear the selection
3
+ * when it pointed at the removed task. Pure ledger transition (no persistence
4
+ * or notify — the controller orchestrates those).
5
+ */
6
+ import type { TaskRecord } from '../tasks.ts'
7
+
8
+ /** Result of a delete transition. */
9
+ export interface DeleteTaskResult {
10
+ /** The next ledger (the removed task absent). */
11
+ tasks: readonly TaskRecord[]
12
+ /** Whether the previous selection referenced the removed task. */
13
+ selectionCleared: boolean
14
+ }
15
+
16
+ /**
17
+ * Apply a delete across the ledger. The selection (a task id) is cleared when
18
+ * it matches the removed task, so the UI never lingers on a vanished detail.
19
+ * @param tasks - current ledger.
20
+ * @param selectedTaskId - the currently selected task id (may be undefined).
21
+ * @param id - the task to remove.
22
+ */
23
+ export function applyDeleteTask(
24
+ tasks: readonly TaskRecord[],
25
+ selectedTaskId: string | undefined,
26
+ id: string,
27
+ ): DeleteTaskResult {
28
+ const next = tasks.filter(task => task.id !== id)
29
+ return {
30
+ tasks: next,
31
+ selectionCleared: selectedTaskId === id,
32
+ }
33
+ }
@@ -0,0 +1,74 @@
1
+ /**
2
+ * Schedule use case: arm/disarm a task's cron rule and roll a rule forward.
3
+ * Pure ledger transitions (no persistence or notify — the controller
4
+ * orchestrates those). Validation and next-run computation live here, sharing
5
+ * the core cron parser (schedule.ts) and the withSchedule transition.
6
+ */
7
+ import { isValidCron, nextRunAtMs } from '../schedule.ts'
8
+ import { withSchedule, type TaskRecord } from '../tasks.ts'
9
+
10
+ /** Fields the schedule use case may change on a rule. */
11
+ export interface SetSchedulePatch {
12
+ enabled?: boolean
13
+ cron?: string
14
+ }
15
+
16
+ /** Result of arming/disarming a rule. */
17
+ export interface SetScheduleResult {
18
+ /** The next ledger; unchanged reference when rejected. */
19
+ tasks: readonly TaskRecord[]
20
+ /** Whether the rule was applied (false on unknown task / invalid cron). */
21
+ applied: boolean
22
+ }
23
+
24
+ /**
25
+ * Set a task's schedule rule. A blank or invalid cron is rejected (state
26
+ * untouched); an enabled rule computes the next run instant immediately, a
27
+ * disabled or invalid one carries no next-run instant.
28
+ * @param tasks - current ledger.
29
+ * @param id - the task to schedule.
30
+ * @param patch - rule fields to change (absent fields keep their current value).
31
+ * @param now - clock instant (ms epoch).
32
+ */
33
+ export function applySetSchedule(
34
+ tasks: readonly TaskRecord[],
35
+ id: string,
36
+ patch: SetSchedulePatch,
37
+ now: number,
38
+ ): SetScheduleResult {
39
+ const task = tasks.find(candidate => candidate.id === id)
40
+ if (task === undefined) return { tasks, applied: false }
41
+ const current = task.schedule
42
+ const cron = (patch.cron ?? current?.cron ?? '').trim()
43
+ if (cron === '' || !isValidCron(cron)) return { tasks, applied: false }
44
+ const enabled = patch.enabled ?? current?.enabled ?? false
45
+ const nextRunAt = enabled ? nextRunAtMs(cron, now) : undefined
46
+ return {
47
+ tasks: tasks.map(candidate =>
48
+ candidate.id === id ? withSchedule(candidate, { enabled, cron, nextRunAt }, now) : candidate),
49
+ applied: true,
50
+ }
51
+ }
52
+
53
+ /**
54
+ * Roll a task's schedule rule forward (scheduler callback): persist the next
55
+ * due instant and the trigger instant. No-op for tasks without a rule (deleted
56
+ * mid-tick, for example).
57
+ * @param tasks - current ledger.
58
+ * @param id - the task to roll forward.
59
+ * @param nextRunAt - next due instant (may be undefined to clear).
60
+ * @param lastTriggeredAt - the trigger instant of this run.
61
+ * @param now - clock instant (ms epoch).
62
+ */
63
+ export function applyScheduleNextRun(
64
+ tasks: readonly TaskRecord[],
65
+ id: string,
66
+ nextRunAt: number | undefined,
67
+ lastTriggeredAt: number | undefined,
68
+ now: number,
69
+ ): readonly TaskRecord[] {
70
+ return tasks.map(task =>
71
+ task.id === id && task.schedule !== undefined
72
+ ? withSchedule(task, { nextRunAt, lastTriggeredAt }, now)
73
+ : task)
74
+ }
@@ -0,0 +1,26 @@
1
+ /**
2
+ * Update-task use case: apply an editable-field patch (title/description/
3
+ * prompt) with a fresh updatedAt. Pure ledger transition (no persistence or
4
+ * notify — the controller orchestrates those).
5
+ */
6
+ import type { TaskRecord } from '../tasks.ts'
7
+
8
+ /** Editable fields on a task (the update patch surface). */
9
+ export type TaskUpdatePatch = Partial<Pick<TaskRecord, 'title' | 'description' | 'prompt'>>
10
+
11
+ /**
12
+ * Apply an update across the ledger. Tasks that do not match the id are left
13
+ * untouched; the matched task receives the patch plus a fresh updatedAt.
14
+ * @param tasks - current ledger.
15
+ * @param id - the task to update.
16
+ * @param patch - editable-field changes.
17
+ * @param now - clock instant (ms epoch).
18
+ */
19
+ export function applyUpdateTask(
20
+ tasks: readonly TaskRecord[],
21
+ id: string,
22
+ patch: TaskUpdatePatch,
23
+ now: number,
24
+ ): readonly TaskRecord[] {
25
+ return tasks.map(task => task.id === id ? { ...task, ...patch, updatedAt: now } : task)
26
+ }