@linxin666/dsh-client-ui-task-board 0.1.17 → 0.1.19

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 (35) hide show
  1. package/README.i18n.yaml +2 -2
  2. package/README.md +3 -3
  3. package/README.zh.md +3 -3
  4. package/lib/client.js +430 -134
  5. package/lib/client.js.map +1 -1
  6. package/lib/types/client/PluginSettingsCard.d.ts +11 -0
  7. package/lib/types/client/TaskBoardSettingsCard.d.ts +5 -0
  8. package/lib/types/client/locales.d.ts +7 -0
  9. package/lib/types/client/schedule-presets.d.ts +10 -0
  10. package/lib/types/client/settings-form.d.ts +8 -0
  11. package/lib/types/core/controller.d.ts +27 -0
  12. package/lib/types/core/execution.d.ts +16 -0
  13. package/lib/types/core/scheduler.d.ts +7 -0
  14. package/lib/types/core/tasks.d.ts +16 -0
  15. package/lib/types/core/use-cases/task-archive.d.ts +22 -0
  16. package/lib/types/core/use-cases/task-create.d.ts +1 -6
  17. package/package.json +1 -1
  18. package/src/client/PluginSettingsCard.tsx +50 -47
  19. package/src/client/TaskBoardSettingsCard.tsx +8 -0
  20. package/src/client/board/NewTaskModal.tsx +68 -0
  21. package/src/client/board/TaskBoard.tsx +56 -25
  22. package/src/client/board/TaskDetail.tsx +27 -8
  23. package/src/client/board.module.css +68 -1
  24. package/src/client/index.ts +16 -7
  25. package/src/client/locales.ts +15 -1
  26. package/src/client/schedule-presets.ts +13 -0
  27. package/src/client/settings-card.module.css +9 -0
  28. package/src/client/settings-form.ts +15 -1
  29. package/src/core/controller.ts +58 -0
  30. package/src/core/execution.ts +41 -2
  31. package/src/core/scheduler.ts +15 -0
  32. package/src/core/store.ts +1 -0
  33. package/src/core/tasks.ts +15 -0
  34. package/src/core/use-cases/task-archive.ts +48 -0
  35. package/src/core/use-cases/task-create.ts +12 -3
@@ -28,6 +28,17 @@ export interface PluginSettingsCardProps<TKey extends string = string> {
28
28
  onSave: () => void;
29
29
  /** Drop every staged edit. */
30
30
  onDiscard: () => void;
31
+ /**
32
+ * Render the controls expanded on arrival (still collapsible). Defaults to
33
+ * true: promoted first-level sections show their settings immediately.
34
+ */
35
+ defaultOpen?: boolean;
36
+ /**
37
+ * Render without the collapse affordance: a static header and an always
38
+ * visible body. Used by first-level settings sections whose nav entry
39
+ * already provides the selection.
40
+ */
41
+ alwaysOpen?: boolean;
31
42
  /** The plugin's controls. */
32
43
  children: ReactNode;
33
44
  }
@@ -40,6 +40,11 @@ export declare class TaskBoardSettingsCardController {
40
40
  * @returns the card's snapshot and its form actions.
41
41
  */
42
42
  inject(): TaskBoardSettingsCardFace;
43
+ /**
44
+ * Release the card's scope subscription and bound stores; the slot
45
+ * disposer calls this on teardown.
46
+ */
47
+ dispose(): void;
43
48
  }
44
49
  /** Props the renderer binds for the task-board card. */
45
50
  export type TaskBoardSettingsCardProps = PropsRuntime<'web-ui.plugin.item'> & PropsLocale<'task-board'> & InjectFace<TaskBoardSettingsCardFace>;
@@ -13,6 +13,10 @@ export declare const zh: {
13
13
  'board.search': string;
14
14
  'board.empty': string;
15
15
  'board.filterAll': string;
16
+ 'board.archive': string;
17
+ 'board.archiveView': string;
18
+ 'board.backToBoard': string;
19
+ 'archive.empty': string;
16
20
  'board.status': string;
17
21
  'board.status.backlog': string;
18
22
  'board.status.todo': string;
@@ -40,6 +44,9 @@ export declare const zh: {
40
44
  'detail.run': string;
41
45
  'detail.rerun': string;
42
46
  'detail.delete': string;
47
+ 'detail.archive': string;
48
+ 'detail.restore': string;
49
+ 'detail.archivedAt': string;
43
50
  'detail.viewSession': string;
44
51
  'detail.noSession': string;
45
52
  'detail.executionStarted': string;
@@ -0,0 +1,10 @@
1
+ /**
2
+ * Shared scheduled-run presets for the schedule editors (the new-task dialog
3
+ * and the task detail panel): cron expression → locale label.
4
+ */
5
+ import type { TaskBoardKey } from './locales.ts';
6
+ /** Common scheduled-run presets (cron → locale label). */
7
+ export declare const SCHEDULE_PRESETS: ReadonlyArray<{
8
+ cron: string;
9
+ label: TaskBoardKey;
10
+ }>;
@@ -149,11 +149,19 @@ export declare class CardForm<T> {
149
149
  private readonly specs;
150
150
  private readonly staged;
151
151
  private readonly listeners;
152
+ /** The scope subscription installed in the constructor; released by dispose(). */
153
+ private readonly disposeScope;
154
+ private disposed;
152
155
  private saving;
153
156
  private failed;
154
157
  private failedReason;
155
158
  /** @param scope - the bound settings scope for this card's namespace. */
156
159
  constructor(scope: SettingsScope<T>, specs: FieldSpec[]);
160
+ /**
161
+ * Release the scope subscription and every bound store listener. The card
162
+ * must call this on teardown; later calls are no-ops.
163
+ */
164
+ dispose(): void;
157
165
  /** Publish a projection of this form, rebuilt whenever the scope or a draft changes. */
158
166
  bind<S>(project: () => S): SnapshotStore<S>;
159
167
  /** Read the card-level state: what the Host serves, and what a save would do. */
@@ -64,6 +64,8 @@ export interface ExecutionOptionsSnapshot {
64
64
  export interface ControllerSnapshot {
65
65
  tasks: readonly TaskRecord[];
66
66
  boardOpen: boolean;
67
+ /** True when the board shows the archive view instead of the columns. */
68
+ archiveView: boolean;
67
69
  selectedTaskId: string | undefined;
68
70
  /** Picker option sets (workspace list + agent-preset roster). */
69
71
  executionOptions: ExecutionOptionsSnapshot;
@@ -78,6 +80,7 @@ export declare class BoardController {
78
80
  private readonly deps;
79
81
  private tasks;
80
82
  private boardOpen;
83
+ private archiveView;
81
84
  private selectedTaskId;
82
85
  private executionOptions;
83
86
  private listeners;
@@ -95,6 +98,12 @@ export declare class BoardController {
95
98
  openBoard(): void;
96
99
  closeBoard(): void;
97
100
  toggleBoard(): void;
101
+ /**
102
+ * Switch between the kanban columns and the archive view. Leaving the
103
+ * archive view with an archived task still selected closes the selection —
104
+ * the detail overlay must not linger over a task that is off-board.
105
+ */
106
+ toggleArchiveView(): void;
98
107
  openTask(id: string): void;
99
108
  closeTask(): void;
100
109
  createTask(input: NewTaskInput): TaskRecord | undefined;
@@ -106,6 +115,15 @@ export declare class BoardController {
106
115
  setExecutionOptions(patch: Partial<ExecutionOptionsSnapshot>): void;
107
116
  moveTask(id: string, status: TaskStatus): void;
108
117
  deleteTask(id: string): void;
118
+ /**
119
+ * Archive a settled task (done/failed). Running or on-board-unsettled
120
+ * tasks are refused so the runner keeps exclusive ownership of their
121
+ * lifecycle.
122
+ * @returns true when applied.
123
+ */
124
+ archiveTask(id: string): boolean;
125
+ /** Restore an archived task back onto the board (same status column). */
126
+ restoreTask(id: string): boolean;
109
127
  /**
110
128
  * Update a task's schedule rule. A blank or invalid cron expression is
111
129
  * rejected (returns false, state untouched). When the rule ends up enabled
@@ -125,6 +143,15 @@ export declare class BoardController {
125
143
  * schedule rule (it was deleted mid-tick, for example).
126
144
  */
127
145
  applyScheduleNextRun(id: string, nextRunAt: number | undefined, lastTriggeredAt: number | undefined): void;
146
+ /**
147
+ * Reload the ledger from the persisted store without notifying subscribers.
148
+ * The scheduler calls this before every tick so a task deleted in another
149
+ * tab (or a stale in-memory copy) can never be fired from this tab: the
150
+ * fire decision and the subsequent roll-forward both run on the freshest
151
+ * persisted truth. Deliberately silent — same-origin external changes still
152
+ * reach subscribers through the storage-event subscription.
153
+ */
154
+ reloadFromStore(): void;
128
155
  /**
129
156
  * Jump to an execution's session transcript. Selecting the session changes
130
157
  * `current`, which closes the board (the conversation view takes over).
@@ -133,6 +133,15 @@ export type ExecutionEvent = {
133
133
  */
134
134
  export declare class ExecutionService {
135
135
  private readonly env;
136
+ /**
137
+ * In-flight preset switches keyed by `sessionId\u0000mode`. The session
138
+ * list mirror can lag behind a just-applied switch, so concurrent runs
139
+ * against one blank session would otherwise each issue the same select
140
+ * RPC (an amplified storm); sharing one in-flight call keeps the wire to
141
+ * a single request. The settled entry is forgotten so a later run can
142
+ * switch again.
143
+ */
144
+ private readonly presetSwitches;
136
145
  /** @param env - the runtime faces (real or fake). */
137
146
  constructor(env: ExecutionEnvironment);
138
147
  run(task: TaskRecord, execution: ExecutionRecord, onEvent: (event: ExecutionEvent) => void): Promise<void>;
@@ -143,6 +152,13 @@ export declare class ExecutionService {
143
152
  * the wire refuses.
144
153
  */
145
154
  private applyMode;
155
+ /**
156
+ * One in-flight `select` per (session, preset): concurrent runs against
157
+ * the same blank session share the same wire call instead of each issuing
158
+ * a duplicate RPC. The entry is removed once the call settles, so a later
159
+ * run (after a shared failure, say) issues a fresh switch.
160
+ */
161
+ private switchPreset;
146
162
  /**
147
163
  * Apply the task-pinned permission preset through the `/permission <id>`
148
164
  * slash command. No-op when the task pins none; fails the run when the
@@ -3,6 +3,13 @@ import type { TaskRecord } from './tasks.ts';
3
3
  export interface SchedulerDeps {
4
4
  /** Read the current task ledger (the controller snapshot). */
5
5
  tasks(): readonly TaskRecord[];
6
+ /**
7
+ * Re-sync the task ledger from its persisted source before the tick decides
8
+ * what is due. The wiring passes the controller's store reload so a task
9
+ * deleted elsewhere can never be fired from a stale in-memory copy.
10
+ * Optional: absent in pure in-memory harnesses.
11
+ */
12
+ refresh?: () => void;
6
13
  /** Clock; defaults to Date.now in the controller wiring. */
7
14
  now(): number;
8
15
  /** Trigger one task's real execution (the controller's runTask); resolves
@@ -75,7 +75,14 @@ export interface TaskRecord {
75
75
  * `/permission <id>` slash command; absent leaves the session default.
76
76
  */
77
77
  permission?: TaskPermission;
78
+ /**
79
+ * When the task was archived (ms epoch). Archived tasks keep their status
80
+ * and execution history but leave the main board; absent means on-board.
81
+ */
82
+ archivedAt?: number;
78
83
  }
84
+ /** Statuses a settled task may be archived from. */
85
+ export declare const ARCHIVABLE_STATUSES: readonly TaskStatus[];
79
86
  /** Permission presets a task may pin on its execution session (the `/permission <id>` ids). */
80
87
  export declare const TASK_PERMISSIONS: readonly ["read-only", "workspace-write", "danger-full-access"];
81
88
  /** One permission preset id. */
@@ -93,6 +100,15 @@ export interface NewTaskInput {
93
100
  mode?: string;
94
101
  /** Permission preset applied to the execution session; absent = session default. */
95
102
  permission?: TaskPermission;
103
+ /**
104
+ * Optional scheduled-run rule requested at creation time (the new-task
105
+ * dialog): an enable flag plus a 5-field cron expression. The create use
106
+ * case arms it only when enabled and the expression is valid.
107
+ */
108
+ schedule?: {
109
+ enabled: boolean;
110
+ cron: string;
111
+ };
96
112
  }
97
113
  /** The five kanban columns, in display order. */
98
114
  export declare const COLUMNS: readonly {
@@ -0,0 +1,22 @@
1
+ /**
2
+ * Archive/restore task use case: move a settled (done/failed) task off the
3
+ * main board and back. Purely a marker transition — the task keeps its
4
+ * status, execution history and transcript references, so archiving never
5
+ * loses traceability and restoring lands it in the exact column it left.
6
+ */
7
+ import type { TaskRecord } from '../tasks.ts';
8
+ /** Result of an archive transition. */
9
+ export interface ArchiveTaskResult {
10
+ /** The next ledger. */
11
+ tasks: readonly TaskRecord[];
12
+ /** Whether the archive was applied (false = unknown task / not archivable). */
13
+ archived: boolean;
14
+ }
15
+ /**
16
+ * Archive one task: only settled statuses (done/failed) can be archived;
17
+ * a running or not-yet-settled task stays on the board (its runner still
18
+ * owns its lifecycle). Already-archived tasks are a no-op.
19
+ */
20
+ export declare function applyArchiveTask(tasks: readonly TaskRecord[], id: string, now: number): ArchiveTaskResult;
21
+ /** Restore one task back onto the main board (clears the archive marker). */
22
+ export declare function applyRestoreTask(tasks: readonly TaskRecord[], id: string, now: number): ArchiveTaskResult;
@@ -1,8 +1,3 @@
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
1
  import { type NewTaskInput, type TaskRecord } from '../tasks.ts';
7
2
  /** Result of a create transition: the new task (when accepted) + the next ledger. */
8
3
  export interface CreateTaskResult {
@@ -15,7 +10,7 @@ export interface CreateTaskResult {
15
10
  * Apply a create against the current ledger. Returns the new task and the
16
11
  * appended ledger, or the unchanged ledger when the title is blank.
17
12
  * @param tasks - current ledger.
18
- * @param input - raw user input (title/description/prompt).
13
+ * @param input - raw user input (title/description/prompt + optional schedule).
19
14
  * @param now - clock instant (ms epoch).
20
15
  * @param id - minted task id.
21
16
  */
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@linxin666/dsh-client-ui-task-board",
3
3
  "description": "Task board for the dsh web GUI: a sidebar entry plus a multi-column kanban view with local persistence, task details, and real execution through dsh sessions (session.prompt). Hot-pluggable — mounted via ~/.dsh/cordis.patch.yml + a profile node_modules symlink, no dsh source changes.",
4
- "version": "0.1.17",
4
+ "version": "0.1.19",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
7
7
  "types": "lib/types/index.d.ts",
@@ -45,6 +45,17 @@ export interface PluginSettingsCardProps<TKey extends string = string> {
45
45
  onSave: () => void
46
46
  /** Drop every staged edit. */
47
47
  onDiscard: () => void
48
+ /**
49
+ * Render the controls expanded on arrival (still collapsible). Defaults to
50
+ * true: promoted first-level sections show their settings immediately.
51
+ */
52
+ defaultOpen?: boolean
53
+ /**
54
+ * Render without the collapse affordance: a static header and an always
55
+ * visible body. Used by first-level settings sections whose nav entry
56
+ * already provides the selection.
57
+ */
58
+ alwaysOpen?: boolean
48
59
  /** The plugin's controls. */
49
60
  children: ReactNode
50
61
  }
@@ -55,57 +66,27 @@ export interface PluginSettingsCardProps<TKey extends string = string> {
55
66
  * @returns the card, or nothing while the namespace is still loading.
56
67
  */
57
68
  export function PluginSettingsCard<TKey extends string = string>(props: PluginSettingsCardProps<TKey>) {
58
- const [open, setOpen] = useState(false)
59
- const { state } = props
69
+ const [open, setOpen] = useState(props.defaultOpen ?? true)
70
+ const { state, alwaysOpen } = props
60
71
  if (!state.available) return null
61
72
  const title = props.t(props.titleKey)
62
73
  const description = props.t(props.descriptionKey)
63
74
  const blocked = !state.dirty || state.invalid || state.saving
64
- const cardClass = open ? `${css.cardOpen} ${css.card}` : css.card
65
- // The namespace exists but the Host does not serve it to this client (the
66
- // official settings allowlist omits third-party namespaces): show a card
67
- // that explains the gap instead of vanishing, so a missing card never
68
- // reads as a missing plugin.
69
- if (!state.exposed) {
70
- return (
71
- <li className={cardClass}>
72
- <button
73
- type="button"
74
- className={css.header}
75
- aria-expanded={open}
76
- aria-label={`${props.t(open ? 'settings.collapse' : 'settings.expand')}: ${title}`}
77
- onClick={() => { setOpen(!open) }}
78
- >
79
- <span className={css.headText}>
80
- <span className={css.name} title={title}>{title}</span>
81
- <span className={css.description} title={description}>{description}</span>
82
- </span>
83
- <svg
84
- width="14"
85
- height="14"
86
- viewBox="0 0 14 14"
87
- fill="none"
88
- xmlns="http://www.w3.org/2000/svg"
89
- className={open ? `${css.chevron} ${css.chevronOpen}` : css.chevron}
90
- >
91
- <path
92
- d="M11.8486 5.5L11.4238 5.92383L8.69727 8.65137C8.44157 8.90706 8.21562 9.13382 8.01172 9.29785C7.79912 9.46883 7.55595 9.61756 7.25 9.66602C7.08435 9.69222 6.91565 9.69222 6.75 9.66602C6.44405 9.61756 6.20088 9.46883 5.98828 9.29785C5.78438 9.13382 5.55843 8.90706 5.30273 8.65137L2.57617 5.92383L2.15137 5.5L3 4.65137L3.42383 5.07617L6.15137 7.80273C6.42595 8.07732 6.59876 8.24849 6.74023 8.3623C6.87291 8.46904 6.92272 8.47813 6.9375 8.48047C6.97895 8.48703 7.02105 8.48703 7.0625 8.48047C7.07728 8.47813 7.12709 8.46904 7.25977 8.3623C7.40124 8.24849 7.57405 8.07732 7.84863 7.80273L10.5762 5.07617L11 4.65137L11.8486 5.5Z"
93
- fill="currentColor"
94
- />
95
- </svg>
96
- </button>
97
- {open
98
- ? (
99
- <div className={css.body}>
100
- <p className={css.notExposed} role="status">{props.t('settings.notExposed')}</p>
101
- </div>
102
- )
103
- : null}
104
- </li>
75
+ const expanded = alwaysOpen === true || open
76
+ const cardClass = expanded ? `${css.cardOpen} ${css.card}` : css.card
77
+ // With alwaysOpen the nav entry already provides the selection, so the
78
+ // header is a static title row instead of a disclosure button.
79
+ const header = alwaysOpen === true
80
+ ? (
81
+ <div className={css.headerStatic}>
82
+ <span className={css.headText}>
83
+ <span className={css.name} title={title}>{title}</span>
84
+ <span className={css.description} title={description}>{description}</span>
85
+ </span>
86
+ {state.dirty ? <span className={css.pending} title={props.t('settings.unsaved')}>{props.t('settings.unsaved')}</span> : null}
87
+ </div>
105
88
  )
106
- }
107
- return (
108
- <li className={cardClass}>
89
+ : (
109
90
  <button
110
91
  type="button"
111
92
  className={css.header}
@@ -132,7 +113,29 @@ export function PluginSettingsCard<TKey extends string = string>(props: PluginSe
132
113
  />
133
114
  </svg>
134
115
  </button>
135
- {open
116
+ )
117
+ // The namespace exists but the Host does not serve it to this client (the
118
+ // official settings allowlist omits third-party namespaces): show a card
119
+ // that explains the gap instead of vanishing, so a missing card never
120
+ // reads as a missing plugin.
121
+ if (!state.exposed) {
122
+ return (
123
+ <li className={cardClass}>
124
+ {header}
125
+ {expanded
126
+ ? (
127
+ <div className={css.body}>
128
+ <p className={css.notExposed} role="status">{props.t('settings.notExposed')}</p>
129
+ </div>
130
+ )
131
+ : null}
132
+ </li>
133
+ )
134
+ }
135
+ return (
136
+ <li className={cardClass}>
137
+ {header}
138
+ {expanded
136
139
  ? (
137
140
  <div className={css.body}>
138
141
  {!state.writable ? <p className={css.readOnly} role="status">{props.t('settings.readOnly')}</p> : null}
@@ -63,6 +63,14 @@ export class TaskBoardSettingsCardController {
63
63
  inject(): TaskBoardSettingsCardFace {
64
64
  return { hooks: { taskBoardSettingsCard: this.store }, ...this.form.actions() }
65
65
  }
66
+
67
+ /**
68
+ * Release the card's scope subscription and bound stores; the slot
69
+ * disposer calls this on teardown.
70
+ */
71
+ dispose(): void {
72
+ this.form.dispose()
73
+ }
66
74
  }
67
75
 
68
76
  /** Props the renderer binds for the task-board card. */
@@ -4,8 +4,10 @@
4
4
  */
5
5
  import { useEffect, useState } from 'react'
6
6
  import type { BoardController } from '../../core/controller.ts'
7
+ import { isValidCron, nextRunAtMs } from '../../core/schedule.ts'
7
8
  import { TASK_PERMISSIONS, type TaskPermission } from '../../core/tasks.ts'
8
9
  import { t, type TaskBoardKey } from '../locales.ts'
10
+ import { SCHEDULE_PRESETS } from '../schedule-presets.ts'
9
11
  import css from '../board.module.css'
10
12
 
11
13
  /** New-task form overlay. */
@@ -16,6 +18,9 @@ export function NewTaskModal({ controller, onClose }: { controller: BoardControl
16
18
  const [workspaceId, setWorkspaceId] = useState('')
17
19
  const [mode, setMode] = useState('')
18
20
  const [permission, setPermission] = useState('')
21
+ const [scheduleEnabled, setScheduleEnabled] = useState(false)
22
+ const [scheduleCron, setScheduleCron] = useState('')
23
+ const [scheduleError, setScheduleError] = useState<string | undefined>(undefined)
19
24
  const [error, setError] = useState<string | undefined>(undefined)
20
25
  const [options, setOptions] = useState(controller.getSnapshot().executionOptions)
21
26
 
@@ -27,6 +32,13 @@ export function NewTaskModal({ controller, onClose }: { controller: BoardControl
27
32
  )
28
33
 
29
34
  const submit = (): void => {
35
+ if (scheduleEnabled) {
36
+ const cron = scheduleCron.trim()
37
+ if (cron === '' || !isValidCron(cron)) {
38
+ setScheduleError(t('detail.schedule.invalid'))
39
+ return
40
+ }
41
+ }
30
42
  const task = controller.createTask({
31
43
  title,
32
44
  description,
@@ -34,6 +46,7 @@ export function NewTaskModal({ controller, onClose }: { controller: BoardControl
34
46
  workspaceId: workspaceId === '' ? undefined : workspaceId,
35
47
  mode: mode === '' ? undefined : mode,
36
48
  permission: permission === '' ? undefined : permission as TaskPermission,
49
+ schedule: scheduleEnabled ? { enabled: true, cron: scheduleCron.trim() } : undefined,
37
50
  })
38
51
  if (task === undefined) {
39
52
  setError(t('new.required'))
@@ -42,6 +55,11 @@ export function NewTaskModal({ controller, onClose }: { controller: BoardControl
42
55
  onClose()
43
56
  }
44
57
 
58
+ /** Next-run preview for a valid armed cron (creation-time only). */
59
+ const scheduleNextRun = scheduleEnabled && scheduleCron.trim() !== '' && isValidCron(scheduleCron)
60
+ ? nextRunAtMs(scheduleCron, Date.now())
61
+ : undefined
62
+
45
63
  return (
46
64
  <div className={css.modalBackdrop} onMouseDown={event => { if (event.target === event.currentTarget) onClose() }}>
47
65
  <form
@@ -131,6 +149,56 @@ export function NewTaskModal({ controller, onClose }: { controller: BoardControl
131
149
  </select>
132
150
  </label>
133
151
 
152
+ <section className={css.detailSection}>
153
+ <h4>{t('detail.schedule')}</h4>
154
+ <label className={css.scheduleToggle}>
155
+ <input
156
+ type="checkbox"
157
+ checked={scheduleEnabled}
158
+ onChange={event => {
159
+ setScheduleEnabled(event.target.checked)
160
+ if (!event.target.checked) setScheduleError(undefined)
161
+ }}
162
+ />
163
+ <span>{t('detail.schedule.enable')}</span>
164
+ </label>
165
+ {scheduleEnabled && (
166
+ <>
167
+ <div className={css.scheduleRow}>
168
+ <input
169
+ className={`${css.input} ${css.scheduleInput}${scheduleError !== undefined ? ` ${css.scheduleInputInvalid}` : ''}`}
170
+ value={scheduleCron}
171
+ placeholder="0 9 * * *"
172
+ spellCheck={false}
173
+ aria-label={t('detail.schedule.cron')}
174
+ onChange={event => { setScheduleCron(event.target.value); setScheduleError(undefined) }}
175
+ />
176
+ <select
177
+ className={css.schedulePreset}
178
+ value=""
179
+ aria-label={t('detail.schedule.presets')}
180
+ onChange={event => {
181
+ if (event.target.value === '') return
182
+ setScheduleCron(event.target.value)
183
+ setScheduleError(undefined)
184
+ }}
185
+ >
186
+ <option value="">{t('detail.schedule.presets')}…</option>
187
+ {SCHEDULE_PRESETS.map(preset => (
188
+ <option key={preset.cron} value={preset.cron}>{t(preset.label)}</option>
189
+ ))}
190
+ </select>
191
+ </div>
192
+ {scheduleError !== undefined && <p className={css.formError}>{scheduleError}</p>}
193
+ {scheduleError === undefined && scheduleNextRun !== undefined && (
194
+ <p className={css.scheduleMeta}>
195
+ {t('detail.schedule.nextRun')} {new Date(scheduleNextRun).toLocaleString()}
196
+ </p>
197
+ )}
198
+ </>
199
+ )}
200
+ </section>
201
+
134
202
  {error !== undefined && <p className={css.formError}>{error}</p>}
135
203
 
136
204
  <footer className={css.modalFooter}>
@@ -49,12 +49,26 @@ export function TaskBoard({ controller }: { controller: BoardController }) {
49
49
  const [filter, setFilter] = useState('')
50
50
  const [showNew, setShowNew] = useState(false)
51
51
  const selected = selectedTaskOf(snapshot)
52
- const visible = snapshot.tasks.filter(task => matchesFilter(task, filter))
52
+ const archiveView = snapshot.archiveView
53
+ // Archived tasks leave the columns; the archive view shows them instead.
54
+ const visible = snapshot.tasks.filter(task =>
55
+ (archiveView ? task.archivedAt !== undefined : task.archivedAt === undefined)
56
+ && matchesFilter(task, filter),
57
+ )
53
58
  const openTask = useCallback((id: string): void => { controller.openTask(id) }, [controller])
54
59
 
55
60
  return (
56
61
  <div className={css.board} data-dsh-taskboard-board="">
57
62
  <header className={css.boardHeader}>
63
+ <button
64
+ type="button"
65
+ className={`${css.ghostButton} ${css.backButton}`}
66
+ aria-label={t('board.close')}
67
+ onClick={() => { controller.closeBoard() }}
68
+ >
69
+ <span aria-hidden="true">‹</span>
70
+ <span>{t('board.close')}</span>
71
+ </button>
58
72
  <h2 className={css.boardTitle}>{t('board.title')}</h2>
59
73
  <input
60
74
  className={css.search}
@@ -66,39 +80,56 @@ export function TaskBoard({ controller }: { controller: BoardController }) {
66
80
  />
67
81
  <button
68
82
  type="button"
69
- className={css.primaryButton}
70
- onClick={() => { setShowNew(true) }}
83
+ className={archiveView ? css.primaryButton : css.ghostButton}
84
+ onClick={() => { controller.toggleArchiveView() }}
71
85
  >
72
- + {t('board.new')}
86
+ {archiveView
87
+ ? t('board.backToBoard')
88
+ : t('board.archiveView', { count: String(snapshot.tasks.filter(task => task.archivedAt !== undefined).length) })}
73
89
  </button>
74
90
  <button
75
91
  type="button"
76
- className={css.ghostButton}
77
- onClick={() => { controller.closeBoard() }}
92
+ className={css.primaryButton}
93
+ onClick={() => { setShowNew(true) }}
78
94
  >
79
- {t('board.close')}
95
+ + {t('board.new')}
80
96
  </button>
81
97
  </header>
82
98
 
83
99
  <div className={css.columns}>
84
- {COLUMNS.map(column => {
85
- const tasks = visible.filter(task => task.status === column.status)
86
- return (
87
- <section key={column.status} className={css.column} data-status={column.status}>
88
- <header className={css.columnHeader}>
89
- <span className={css.statusDot} data-status={column.status} aria-hidden="true" />
90
- <h3 className={css.columnTitle}>{t(STATUS_KEY[column.status])}</h3>
91
- <span className={css.columnCount}>{tasks.length}</span>
92
- </header>
93
- <div className={css.cards}>
94
- {tasks.map(task => (
95
- <MemoTaskCard key={task.id} task={task} onOpen={openTask} />
96
- ))}
97
- {tasks.length === 0 && <div className={css.columnEmpty}>{t('board.empty')}</div>}
98
- </div>
99
- </section>
100
- )
101
- })}
100
+ {archiveView ? (
101
+ <section className={css.column} data-status="archived">
102
+ <header className={css.columnHeader}>
103
+ <h3 className={css.columnTitle}>{t('board.archive')}</h3>
104
+ <span className={css.columnCount}>{visible.length}</span>
105
+ </header>
106
+ <div className={css.cards}>
107
+ {visible.map(task => (
108
+ <MemoTaskCard key={task.id} task={task} onOpen={openTask} />
109
+ ))}
110
+ {visible.length === 0 && <div className={css.columnEmpty}>{t('archive.empty')}</div>}
111
+ </div>
112
+ </section>
113
+ ) : (
114
+ COLUMNS.map(column => {
115
+ const tasks = visible.filter(task => task.status === column.status)
116
+ return (
117
+ <section key={column.status} className={css.column} data-status={column.status}>
118
+ <header className={css.columnHeader}>
119
+ <span className={css.statusDot} data-status={column.status} aria-hidden="true" />
120
+ <h3 className={css.columnTitle}>{t(STATUS_KEY[column.status])}</h3>
121
+ <span className={css.columnCount}>{tasks.length}</span>
122
+ </header>
123
+ <div className={css.cards}>
124
+ {tasks.map(task => (
125
+ <MemoTaskCard key={task.id} task={task} onOpen={openTask} />
126
+ ))}
127
+ {tasks.length === 0 && <div className={css.columnEmpty}>{t('board.empty')}</div>}
128
+ </div>
129
+ </section>
130
+ )
131
+ })
132
+ )}
102
133
  </div>
103
134
 
104
135
  {selected !== undefined && (