main
js 1,142 lines 32.2 KB
Raw
1 import { createStore } from "/js/AlpineStore.js";
2 import { fetchApi } from "/js/api.js";
3 import {
4 formatDateTime,
5 getUserDateTimeParts,
6 getUserTimezone,
7 toUserISOString,
8 toUserWallClockISOString,
9 } from "/js/time-utils.js";
10 import { store as chatsStore } from "/components/sidebar/chats/chats-store.js";
11 import { store as projectsStore } from "/components/projects/projects-store.js";
12 import { store as notificationsStore } from "/components/notifications/notification-store.js";
13
14 const VIEW_MODE_STORAGE_KEY = "scheduler_view_mode";
15 const NOTIFICATION_DURATION = {
16 success: 3,
17 info: 3,
18 warning: 4,
19 error: 5,
20 };
21 const DEFAULT_TASK_STATE = "idle";
22 const TASK_TYPES = ["scheduled", "adhoc", "planned"];
23
24 /**
25 * @typedef {Object} SchedulerPlan
26 * @property {string[]} todo
27 * @property {string|null} in_progress
28 * @property {string[]} done
29 */
30
31 /**
32 * @typedef {Object} SchedulerProject
33 * @property {string|null} name
34 * @property {string|null} title
35 * @property {string} color
36 */
37
38 /**
39 * @typedef {Object} SchedulerTask
40 * @property {string} uuid
41 * @property {string} name
42 * @property {string} type
43 * @property {string} state
44 * @property {SchedulerPlan} plan
45 * @property {Object|string} schedule
46 * @property {string} token
47 * @property {SchedulerProject|null} project
48 * @property {string|null} project_name
49 * @property {string} [project_color]
50 * @property {string[]} attachments
51 * @property {string} [system_prompt]
52 * @property {string} [prompt]
53 * @property {string} [created_at]
54 * @property {string} [updated_at]
55 * @property {string} [last_run]
56 * @property {string} [last_result]
57 */
58
59 /**
60 * @typedef {Object} EditingTask
61 * @property {string} [uuid]
62 * @property {string} name
63 * @property {string} type
64 * @property {string} state
65 * @property {SchedulerPlan} plan
66 * @property {ReturnType<typeof defaultSchedule>} schedule
67 * @property {string} token
68 * @property {SchedulerProject|null} project
69 * @property {boolean} dedicated_context
70 * @property {string[]} attachments
71 * @property {string} system_prompt
72 * @property {string} prompt
73 */
74
75 /**
76 * @template T
77 * @typedef {Object} SchedulerApiResult
78 * @property {boolean} ok
79 * @property {string} [error]
80 * @property {T} [data]
81 */
82
83 // -----------------------------------------------------------------------------
84 // Pure helpers
85 // -----------------------------------------------------------------------------
86
87 const defaultSchedule = () => ({
88 minute: "*",
89 hour: "*",
90 day: "*",
91 month: "*",
92 weekday: "*",
93 timezone: getUserTimezone(),
94 });
95
96 const emptyPlan = () => ({
97 todo: [],
98 in_progress: null,
99 done: [],
100 });
101
102 const defaultEditingTask = (overrides = {}) => ({
103 name: "",
104 type: "scheduled",
105 state: DEFAULT_TASK_STATE,
106 schedule: defaultSchedule(),
107 token: "",
108 plan: emptyPlan(),
109 system_prompt: "",
110 prompt: "",
111 attachments: [],
112 project: null,
113 dedicated_context: true,
114 ...overrides,
115 });
116
117 const readPersistedViewMode = () => {
118 if (typeof window === "undefined") return "list";
119 return window.localStorage?.getItem(VIEW_MODE_STORAGE_KEY) || "list";
120 };
121
122 const sleep = (ms = 0) =>
123 new Promise((resolve) => {
124 setTimeout(resolve, ms);
125 });
126
127 function safeJsonClone(value) {
128 try {
129 return JSON.parse(JSON.stringify(value));
130 } catch {
131 return value;
132 }
133 }
134
135 function normalizeAttachments(value) {
136 if (!value) return [];
137 if (Array.isArray(value)) {
138 return value.filter((item) => typeof item === "string" && item.trim().length > 0);
139 }
140 if (typeof value === "string") {
141 return value
142 .split("\n")
143 .map((line) => line.trim())
144 .filter((line) => line.length > 0);
145 }
146 return [];
147 }
148
149 function normalizeSchedule(schedule) {
150 if (!schedule) return defaultSchedule();
151 if (typeof schedule === "string") {
152 const [minute = "*", hour = "*", day = "*", month = "*", weekday = "*"] = schedule
153 .split(" ")
154 .map((segment) => segment || "*");
155 return {
156 minute,
157 hour,
158 day,
159 month,
160 weekday,
161 timezone: getUserTimezone(),
162 };
163 }
164 return {
165 minute: schedule.minute || "*",
166 hour: schedule.hour || "*",
167 day: schedule.day || "*",
168 month: schedule.month || "*",
169 weekday: schedule.weekday || "*",
170 timezone: schedule.timezone || getUserTimezone(),
171 };
172 }
173
174 function normalizePlanStruct(plan) {
175 if (!plan) return emptyPlan();
176 const clone = {
177 todo: Array.isArray(plan.todo) ? [...plan.todo] : [],
178 in_progress: plan.in_progress || null,
179 done: Array.isArray(plan.done) ? [...plan.done] : [],
180 };
181 const sanitized = clone.todo
182 .map((value) => new Date(value))
183 .filter((date) => !Number.isNaN(date.getTime()))
184 .map((date) => toUserISOString(date))
185 .sort();
186 clone.todo = sanitized;
187 clone.done = clone.done
188 .map((value) => new Date(value))
189 .filter((date) => !Number.isNaN(date.getTime()))
190 .map((date) => toUserISOString(date));
191 if (clone.in_progress) {
192 const inProgress = new Date(clone.in_progress);
193 clone.in_progress = Number.isNaN(inProgress.getTime())
194 ? null
195 : toUserISOString(inProgress);
196 }
197 return clone;
198 }
199
200 function ensureTaskValidity(task) {
201 return Boolean(task && task.uuid && task.name && task.type);
202 }
203
204 function extractProjectInfo(task) {
205 if (!task) return null;
206 const slug = task.project_name || task.project?.name || null;
207 const title = task.project?.title || task.project?.name || slug;
208 const color = task.project_color || task.project?.color || "";
209 if (!slug && !title) return null;
210 return {
211 name: slug,
212 title: title || slug,
213 color: color || "",
214 };
215 }
216
217 function composeEditingTask(task = {}) {
218 const base = task && task.uuid ? { ...task } : { ...defaultEditingTask(), ...task };
219 return {
220 ...base,
221 schedule: normalizeSchedule(base.schedule),
222 plan: normalizePlanStruct(base.plan),
223 attachments: normalizeAttachments(base.attachments),
224 token: base.token || "",
225 project: base.project || extractProjectInfo(base) || null,
226 dedicated_context:
227 typeof base.dedicated_context === "boolean" ? base.dedicated_context : true,
228 state: base.state || DEFAULT_TASK_STATE,
229 };
230 }
231
232 function normalizeTaskFromBackend(task) {
233 if (!ensureTaskValidity(task)) return null;
234 return composeEditingTask(task);
235 }
236
237 function buildPayloadFromEditingTask(editingTask, { isCreating = false } = {}) {
238 const payload = {
239 name: editingTask.name.trim(),
240 system_prompt: editingTask.system_prompt || "",
241 prompt: editingTask.prompt || "",
242 state: editingTask.state || DEFAULT_TASK_STATE,
243 timezone: getUserTimezone(),
244 attachments: normalizeAttachments(editingTask.attachments),
245 dedicated_context: editingTask.dedicated_context,
246 };
247
248 if (editingTask.type === "scheduled") {
249 payload.schedule = normalizeSchedule(editingTask.schedule);
250 }
251
252 if (editingTask.type === "planned") {
253 payload.plan = normalizePlanStruct(editingTask.plan);
254 }
255
256 if (editingTask.type === "adhoc") {
257 payload.token = editingTask.token;
258 }
259
260 // Only send project fields when creating a new task (project changes are not allowed for existing tasks)
261 if (isCreating && editingTask.project && editingTask.project.name) {
262 payload.project_name = editingTask.project.name;
263 if (editingTask.project.color) {
264 payload.project_color = editingTask.project.color;
265 }
266 }
267
268 if (!isCreating && editingTask.uuid) {
269 payload.task_id = editingTask.uuid;
270 }
271
272 return payload;
273 }
274
275 async function callSchedulerEndpoint(endpoint, payload = {}, defaultError) {
276 try {
277 const response = await fetchApi(endpoint, {
278 method: "POST",
279 headers: {
280 "Content-Type": "application/json",
281 },
282 body: JSON.stringify(payload),
283 });
284 const data = await response.json().catch(() => ({}));
285 if (!response.ok) {
286 return { ok: false, error: data?.error || defaultError || "Task request failed" };
287 }
288 return { ok: true, data };
289 } catch (error) {
290 return { ok: false, error: error?.message || defaultError || "Task request failed" };
291 }
292 }
293
294 const schedulerApi = {
295 async listTasks() {
296 const result = await callSchedulerEndpoint(
297 "/scheduler_tasks_list",
298 { timezone: getUserTimezone() },
299 "Failed to fetch tasks"
300 );
301 if (!result.ok) return { ok: false, error: result.error };
302 const rawTasks = Array.isArray(result.data?.tasks) ? result.data.tasks : [];
303 const normalized = rawTasks
304 .filter(ensureTaskValidity)
305 .map((task) => normalizeTaskFromBackend(task))
306 .filter(Boolean);
307 return { ok: true, tasks: normalized };
308 },
309
310 async createTask(payload) {
311 const result = await callSchedulerEndpoint(
312 "/scheduler_task_create",
313 payload,
314 "Failed to create task"
315 );
316 if (!result.ok) return { ok: false, error: result.error };
317 const task = result.data?.task ? normalizeTaskFromBackend(result.data.task) : null;
318 return { ok: true, task };
319 },
320
321 async updateTask(payload) {
322 const result = await callSchedulerEndpoint(
323 "/scheduler_task_update",
324 payload,
325 "Failed to update task"
326 );
327 if (!result.ok) return { ok: false, error: result.error };
328 const task = result.data?.task ? normalizeTaskFromBackend(result.data.task) : null;
329 return { ok: true, task };
330 },
331
332 async runTask(taskId) {
333 return callSchedulerEndpoint(
334 "/scheduler_task_run",
335 { task_id: taskId, timezone: getUserTimezone() },
336 "Failed to run task"
337 );
338 },
339
340 async deleteTask(taskId) {
341 return callSchedulerEndpoint(
342 "/scheduler_task_delete",
343 { task_id: taskId, timezone: getUserTimezone() },
344 "Failed to delete task"
345 );
346 },
347 };
348
349 const notificationChannels = {
350 success: "frontendSuccess",
351 info: "frontendInfo",
352 warning: "frontendWarning",
353 error: "frontendError",
354 };
355
356 function pushNotification(type, message, title = "Tasks", duration) {
357 const channel = notificationChannels[type];
358 if (!channel || typeof notificationsStore[channel] !== "function") return;
359 const ttl = duration ?? NOTIFICATION_DURATION[type] ?? 4;
360 notificationsStore[channel](message, title, ttl);
361 }
362
363 function destroyPlannerInput(inputId) {
364 const input = typeof document !== "undefined" ? document.getElementById(inputId) : null;
365 if (!input || !input._flatpickr) return;
366 input._flatpickr.destroy();
367 const wrapper = input.closest(".scheduler-flatpickr-wrapper");
368 if (wrapper && wrapper.parentNode) {
369 wrapper.parentNode.insertBefore(input, wrapper);
370 wrapper.parentNode.removeChild(wrapper);
371 }
372 input.classList.remove("scheduler-flatpickr-input");
373 }
374
375 function setupPlannerInput(inputId) {
376 if (typeof flatpickr === "undefined") {
377 return null;
378 }
379 const input = document.getElementById(inputId);
380 if (!input) return null;
381
382 destroyPlannerInput(inputId);
383
384 const wrapper = document.createElement("div");
385 wrapper.className = "scheduler-flatpickr-wrapper";
386 wrapper.style.overflow = "visible";
387 input.parentNode.insertBefore(wrapper, input);
388 wrapper.appendChild(input);
389 input.classList.add("scheduler-flatpickr-input");
390
391 const nowParts = getUserDateTimeParts();
392 const roundedMinute = Math.ceil(nowParts.minute / 5) * 5;
393 const options = {
394 dateFormat: "Y-m-d H:i",
395 enableTime: true,
396 time_24hr: true,
397 static: false,
398 appendTo: document.body,
399 allowInput: true,
400 positionElement: wrapper,
401 theme: "scheduler-theme",
402 minuteIncrement: 5,
403 defaultHour: roundedMinute >= 60 ? (nowParts.hour + 1) % 24 : nowParts.hour,
404 defaultMinute: roundedMinute % 60,
405 onOpen(selectedDates, dateStr, instance) {
406 instance.calendarContainer.style.zIndex = "9999";
407 instance.calendarContainer.style.position = "absolute";
408 instance.calendarContainer.style.visibility = "visible";
409 instance.calendarContainer.style.opacity = "1";
410 instance.calendarContainer.classList.add("scheduler-theme");
411 },
412 onReady(selectedDates, dateStr, instance) {
413 if (!dateStr) {
414 const now = new Date();
415 now.setMinutes(now.getMinutes() + 30);
416 instance.setDate(now, true);
417 }
418 },
419 };
420
421 const picker = flatpickr(input, options);
422 const clearButton = document.createElement("button");
423 clearButton.className = "scheduler-flatpickr-clear";
424 clearButton.innerHTML = "×";
425 clearButton.type = "button";
426 clearButton.addEventListener("click", (event) => {
427 event.preventDefault();
428 event.stopPropagation();
429 if (picker) picker.clear();
430 });
431 wrapper.appendChild(clearButton);
432
433 return picker;
434 }
435
436 function readDateFromPlannerInput(input) {
437 if (!input) return null;
438 if (input._flatpickr && input._flatpickr.selectedDates.length > 0) {
439 return input._flatpickr.selectedDates[0];
440 }
441 if (input.value) {
442 const date = new Date(input.value);
443 if (!Number.isNaN(date.getTime())) {
444 return date;
445 }
446 }
447 return null;
448 }
449
450 function sortByDate(value) {
451 const date = new Date(value);
452 return Number.isNaN(date.getTime()) ? 0 : date.getTime();
453 }
454
455 // -----------------------------------------------------------------------------
456 // Store definition
457 // -----------------------------------------------------------------------------
458
459 const schedulerStoreModel = {
460 // Core collection state -----------------------------------------------------
461 tasks: [],
462 isLoading: false,
463 showLoadingState: false,
464 hasNoTasks: true,
465
466 // Filtering & view ---------------------------------------------------------
467 filterType: "all",
468 filterState: "all",
469 sortField: "name",
470 sortDirection: "asc",
471 viewMode: readPersistedViewMode(),
472 selectedTaskForDetail: null,
473
474 // Pagination ---------------------------------------------------------------
475 currentPage: 1,
476 pageSize: 10,
477
478 // Editor state -------------------------------------------------------------
479 isCreating: false,
480 isEditing: false,
481 editingTask: defaultEditingTask(),
482 selectedProjectSlug: "",
483 projectOptions: [],
484
485 // Polling ------------------------------------------------------------------
486 pollingInterval: null,
487 pollingActive: false,
488
489 // Computed -----------------------------------------------------------------
490 get filteredTasks() {
491 if (!Array.isArray(this.tasks)) return [];
492 let filtered = [...this.tasks];
493
494 if (this.filterType && this.filterType !== "all") {
495 filtered = filtered.filter((task) =>
496 task.type ? task.type.toLowerCase() === this.filterType.toLowerCase() : false
497 );
498 }
499
500 if (this.filterState && this.filterState !== "all") {
501 filtered = filtered.filter((task) =>
502 task.state ? task.state.toLowerCase() === this.filterState.toLowerCase() : false
503 );
504 }
505
506 return this.sortTasks(filtered);
507 },
508
509 get totalPages() {
510 if (!Array.isArray(this.filteredTasks) || this.filteredTasks.length === 0) return 1;
511 return Math.ceil(this.filteredTasks.length / this.pageSize);
512 },
513
514 get paginatedTasks() {
515 if (!Array.isArray(this.filteredTasks)) return [];
516 // Ensure currentPage is within valid range
517 const maxPage = this.totalPages;
518 if (this.currentPage > maxPage) {
519 this.currentPage = Math.max(1, maxPage);
520 }
521 const start = (this.currentPage - 1) * this.pageSize;
522 const end = start + this.pageSize;
523 return this.filteredTasks.slice(start, end);
524 },
525
526 get attachmentsText() {
527 const attachments = Array.isArray(this.editingTask.attachments)
528 ? this.editingTask.attachments
529 : [];
530 return attachments.join("\n");
531 },
532
533 set attachmentsText(value) {
534 if (typeof value === "string") {
535 this.editingTask.attachments = value.split("\n");
536 } else {
537 this.editingTask.attachments = [];
538 }
539 },
540
541 // Lifecycle ----------------------------------------------------------------
542 init() {
543 this.resetEditingTask();
544 this.refreshProjectOptions();
545 },
546
547 persistViewMode(mode) {
548 this.viewMode = mode;
549 try {
550 window.localStorage?.setItem(VIEW_MODE_STORAGE_KEY, mode);
551 } catch {
552 /* ignore storage failures */
553 }
554 },
555
556 setViewMode(mode) {
557 this.persistViewMode(mode);
558 },
559
560 onTabActivated() {
561 this.pollingActive = true;
562 this.startPolling();
563 },
564
565 onTabDeactivated() {
566 this.stopPolling();
567 },
568
569 async onModalClosed() {
570 this.stopPolling();
571 this.destroyFlatpickr("all");
572 this.isCreating = false;
573 this.isEditing = false;
574 this.resetEditingTask();
575 this.selectedTaskForDetail = null;
576 this.persistViewMode("list");
577 },
578
579 startPolling() {
580 if (this.pollingInterval) return;
581 this.fetchTasks();
582 this.pollingInterval = setInterval(() => {
583 if (this.pollingActive) {
584 this.fetchTasks();
585 }
586 }, 2000);
587 },
588
589 stopPolling() {
590 this.pollingActive = false;
591 if (this.pollingInterval) {
592 clearInterval(this.pollingInterval);
593 this.pollingInterval = null;
594 }
595 },
596
597 // Data fetching -------------------------------------------------------------
598 async fetchTasks({ manual = false } = {}) {
599 if (this.isCreating || this.isEditing) return;
600 if (manual) this.isLoading = true;
601
602 try {
603 const { ok, error, tasks } = await schedulerApi.listTasks();
604 if (!ok) {
605 if (manual) this.notifyError(`Failed to fetch tasks: ${error}`);
606 this.tasks = [];
607 this.hasNoTasks = true;
608 return;
609 }
610
611 // Smart merge: preserve object references to prevent UI flickering
612 const taskMap = new Map(this.tasks.map((t) => [t.uuid, t]));
613 this.tasks = tasks.map((newTask) => {
614 const existing = taskMap.get(newTask.uuid);
615 if (existing) {
616 // Update existing object in-place if different
617 if (JSON.stringify(existing) !== JSON.stringify(newTask)) {
618 Object.assign(existing, newTask);
619 }
620 return existing; // Return the SAME object reference
621 }
622 return newTask; // New object
623 });
624
625 this.hasNoTasks = this.tasks.length === 0;
626 } catch (error) {
627 if (manual) this.notifyError(`Failed to fetch tasks: ${error.message}`);
628 this.tasks = [];
629 this.hasNoTasks = true;
630 } finally {
631 this.isLoading = false;
632 }
633 },
634
635 async saveTask() {
636 if (!this.editingTask.name?.trim() || !this.editingTask.prompt?.trim()) {
637 window.alert("Task name and prompt are required");
638 return;
639 }
640
641 if (!TASK_TYPES.includes(this.editingTask.type)) {
642 window.alert("Invalid task type");
643 return;
644 }
645
646 if (this.editingTask.type === "adhoc" && !this.editingTask.token) {
647 this.editingTask.token = this.generateRandomToken();
648 }
649
650 const payload = buildPayloadFromEditingTask(this.editingTask, {
651 isCreating: this.isCreating,
652 });
653
654 try {
655 const result = this.isCreating
656 ? await schedulerApi.createTask(payload)
657 : await schedulerApi.updateTask(payload);
658
659 if (!result.ok) {
660 throw new Error(result.error);
661 }
662
663 const message = this.isCreating
664 ? "Task created successfully"
665 : "Task updated successfully";
666 this.notifySuccess(message);
667
668 if (result.task) {
669 if (this.isCreating) {
670 this.tasks = [...this.tasks, result.task];
671 } else {
672 this.tasks = this.tasks.map((task) =>
673 task.uuid === result.task.uuid ? result.task : task
674 );
675 }
676 } else {
677 await this.fetchTasks({ manual: true });
678 }
679 } catch (error) {
680 this.notifyError(`Failed to save task: ${error.message}`);
681 return;
682 } finally {
683 this.destroyFlatpickr("all");
684 this.resetEditingTask();
685 this.isCreating = false;
686 this.isEditing = false;
687 }
688 },
689
690 async runTask(taskId) {
691 try {
692 const result = await schedulerApi.runTask(taskId);
693 if (!result.ok) throw new Error(result.error);
694 const warning = result.data?.warning;
695 const message = result.data?.message || "Task started successfully";
696 if (warning) {
697 this.notifyWarning(warning);
698 } else {
699 this.notifySuccess(message);
700 }
701 this.fetchTasks({ manual: true });
702 } catch (error) {
703 this.notifyError(`Failed to run task: ${error.message}`);
704 }
705 },
706
707 async resetTaskState(taskId) {
708 const task = this.tasks.find((t) => t.uuid === taskId);
709 if (!task) {
710 this.notifyError("Task not found");
711 return;
712 }
713 if (task.state === "idle") {
714 this.notifyInfo("Task is already in idle state");
715 return;
716 }
717
718 this.showLoadingState = true;
719 try {
720 const result = await schedulerApi.updateTask({ task_id: taskId, state: "idle" });
721 if (!result.ok) throw new Error(result.error);
722 this.notifySuccess("Task state reset to idle");
723 await this.fetchTasks({ manual: true });
724 } catch (error) {
725 this.notifyError(`Failed to reset task state: ${error.message}`);
726 } finally {
727 this.showLoadingState = false;
728 }
729 },
730
731 async deleteTask(taskId) {
732 try {
733 if (typeof chatsStore.switchFromContext === "function") {
734 await chatsStore.switchFromContext(taskId);
735 }
736 } catch (error) {
737 console.warn("[scheduler] Failed to switch from context before delete", error);
738 }
739
740 try {
741 const result = await schedulerApi.deleteTask(taskId);
742 if (!result.ok) throw new Error(result.error);
743 this.notifySuccess("Task deleted successfully");
744 this.tasks = this.tasks.filter((task) => task.uuid !== taskId);
745 this.hasNoTasks = this.tasks.length === 0;
746 if (this.selectedTaskForDetail?.uuid === taskId) {
747 this.closeTaskDetail();
748 }
749 } catch (error) {
750 this.notifyError(`Failed to delete task: ${error.message}`);
751 }
752 },
753
754 async deleteTaskFromSidebar(taskId) {
755 await this.deleteTask(taskId);
756 },
757
758 // Domain helpers -----------------------------------------------------------
759 resetEditingTask() {
760 this.editingTask = defaultEditingTask();
761 this.selectedProjectSlug = "";
762 },
763
764 setEditingTask(task) {
765 const normalized = composeEditingTask(task);
766 this.editingTask = normalized;
767 this.selectedProjectSlug = normalized.project?.name || "";
768 },
769
770 async refreshProjectOptions() {
771 try {
772 if (
773 !Array.isArray(projectsStore.projectList) ||
774 projectsStore.projectList.length === 0
775 ) {
776 if (typeof projectsStore.loadProjectsList === "function") {
777 await projectsStore.loadProjectsList();
778 }
779 }
780 } catch (error) {
781 console.warn("[scheduler] Failed to load project list", error);
782 }
783
784 const list = Array.isArray(projectsStore.projectList)
785 ? projectsStore.projectList
786 : [];
787
788 this.projectOptions = list.map((proj) => ({
789 name: proj.name,
790 title: proj.title || proj.name,
791 color: proj.color || "",
792 }));
793 },
794
795 deriveActiveProject() {
796 const selected = chatsStore?.selectedContext || null;
797 if (!selected || !selected.project) return null;
798 const project = selected.project;
799 return {
800 name: project.name || null,
801 title: project.title || project.name || null,
802 color: project.color || "",
803 };
804 },
805
806 onProjectSelect(slug) {
807 this.selectedProjectSlug = slug || "";
808 if (!slug) {
809 this.editingTask.project = null;
810 return;
811 }
812
813 const option = this.projectOptions.find((item) => item.name === slug);
814 if (option) {
815 this.editingTask.project = { ...option };
816 } else {
817 this.editingTask.project = { name: slug, title: slug, color: "" };
818 }
819 },
820
821 changeSort(field) {
822 if (this.sortField === field) {
823 this.sortDirection = this.sortDirection === "asc" ? "desc" : "asc";
824 } else {
825 this.sortField = field;
826 this.sortDirection = "asc";
827 }
828 // Reset to first page when sorting changes
829 this.currentPage = 1;
830 },
831
832 // Pagination methods --------------------------------------------------------
833 nextPage() {
834 if (this.currentPage < this.totalPages) {
835 this.currentPage++;
836 }
837 },
838
839 prevPage() {
840 if (this.currentPage > 1) {
841 this.currentPage--;
842 }
843 },
844
845 goToPage(page) {
846 const pageNum = parseInt(page, 10);
847 if (Number.isNaN(pageNum)) return;
848 if (pageNum < 1) {
849 this.currentPage = 1;
850 } else if (pageNum > this.totalPages) {
851 this.currentPage = this.totalPages;
852 } else {
853 this.currentPage = pageNum;
854 }
855 },
856
857 sortTasks(tasks) {
858 if (!Array.isArray(tasks) || tasks.length === 0) return tasks;
859 const direction = this.sortDirection === "asc" ? 1 : -1;
860 const field = this.sortField;
861 return [...tasks].sort((a, b) => {
862 const fieldA = a[field];
863 const fieldB = b[field];
864 if (fieldA === undefined && fieldB === undefined) return 0;
865 if (fieldA === undefined) return 1;
866 if (fieldB === undefined) return -1;
867 if (["createdAt", "updatedAt", "last_run"].includes(field)) {
868 return (sortByDate(fieldA) - sortByDate(fieldB)) * direction;
869 }
870 if (typeof fieldA === "string" && typeof fieldB === "string") {
871 return fieldA.localeCompare(fieldB) * direction;
872 }
873 return (fieldA - fieldB) * direction;
874 });
875 },
876
877 formatDate(dateString) {
878 if (!dateString) return "Never";
879 return formatDateTime(dateString, "full");
880 },
881
882 formatPlan(task) {
883 if (!task || !task.plan) return "No plan";
884 const todoCount = Array.isArray(task.plan.todo) ? task.plan.todo.length : 0;
885 const inProgress = task.plan.in_progress ? "Yes" : "No";
886 const doneCount = Array.isArray(task.plan.done) ? task.plan.done.length : 0;
887 let nextRun = "";
888 if (Array.isArray(task.plan.todo) && task.plan.todo.length > 0) {
889 const nextTime = new Date(task.plan.todo[0]);
890 nextRun = Number.isNaN(nextTime.getTime())
891 ? "Invalid date"
892 : formatDateTime(nextTime, "short");
893 } else {
894 nextRun = "None";
895 }
896 return `Next: ${nextRun}\nTodo: ${todoCount}\nIn Progress: ${inProgress}\nDone: ${doneCount}`;
897 },
898
899 formatSchedule(task) {
900 if (!task.schedule) return "None";
901 if (typeof task.schedule === "string") return task.schedule;
902 return `${task.schedule.minute || "*"} ${task.schedule.hour || "*"} ${
903 task.schedule.day || "*"
904 } ${task.schedule.month || "*"} ${task.schedule.weekday || "*"}`;
905 },
906
907 formatTaskType(type) {
908 const typeMap = {
909 scheduled: "Scheduled",
910 adhoc: "Ad-hoc",
911 planned: "Planned",
912 };
913 return typeMap[type] || type;
914 },
915
916 getStateBadgeClass(state) {
917 switch (state) {
918 case "idle":
919 return "scheduler-status-idle";
920 case "running":
921 return "scheduler-status-running";
922 case "disabled":
923 return "scheduler-status-disabled";
924 case "error":
925 return "scheduler-status-error";
926 default:
927 return "";
928 }
929 },
930
931 extractTaskProject(task) {
932 return extractProjectInfo(task);
933 },
934
935 formatProjectName(project) {
936 if (!project) return "No Project";
937 return project.title || project.name || "No Project";
938 },
939
940 formatProjectLabel(project) {
941 return `Project: ${this.formatProjectName(project)}`;
942 },
943
944 formatTaskProject(task) {
945 return this.formatProjectName(this.extractTaskProject(task));
946 },
947
948 syncTasksFromSidebar(sidebarTasks) {
949 // Sync scheduler store with sidebar's poll data for instant access
950 if (!Array.isArray(sidebarTasks) || sidebarTasks.length === 0) return;
951
952 // Smart merge: preserve object references to prevent UI flickering
953 const taskMap = new Map(this.tasks.map((t) => [t.uuid, t]));
954 this.tasks = sidebarTasks.map((sidebarTask) => {
955 const taskId = sidebarTask.uuid || sidebarTask.id;
956 const existing = taskMap.get(taskId);
957 if (existing) {
958 // Update existing object in-place if different
959 if (JSON.stringify(existing) !== JSON.stringify(sidebarTask)) {
960 Object.assign(existing, sidebarTask);
961 }
962 return existing;
963 }
964 return sidebarTask;
965 });
966 this.hasNoTasks = this.tasks.length === 0;
967 },
968
969 showTaskDetail(taskId) {
970 // Sync with sidebar data if our array is empty (e.g., on page load before modal opened)
971 if (this.tasks.length === 0) {
972 const tasksStore = globalThis.Alpine?.store?.('tasks');
973 if (tasksStore?.tasks?.length > 0) {
974 this.syncTasksFromSidebar(tasksStore.tasks);
975 }
976 }
977
978 const task = this.tasks.find((t) => t.uuid === taskId);
979 if (!task) {
980 this.notifyError("Task not found");
981 return;
982 }
983
984 const snapshot = safeJsonClone(task);
985 if (!snapshot.attachments) {
986 snapshot.attachments = [];
987 }
988
989 this.selectedTaskForDetail = snapshot;
990 const closePromise = window.openModal("modals/scheduler/scheduler-task-detail.html");
991 if (closePromise && typeof closePromise.then === "function") {
992 closePromise.then(() => {
993 if (this.selectedTaskForDetail?.uuid === snapshot.uuid) {
994 this.selectedTaskForDetail = null;
995 }
996 });
997 }
998 },
999
1000 closeTaskDetail() {
1001 this.selectedTaskForDetail = null;
1002 window.closeModal();
1003 },
1004
1005 async editFromDetail() {
1006 const taskId = this.selectedTaskForDetail?.uuid;
1007 if (!taskId) return;
1008 this.closeTaskDetail();
1009 await this.startEditTask(taskId);
1010 // Open main scheduler modal to show the editor
1011 window.openModal("modals/scheduler/scheduler-modal.html");
1012 },
1013
1014 async deleteFromDetail() {
1015 const taskId = this.selectedTaskForDetail?.uuid;
1016 if (!taskId) return;
1017 await this.deleteTask(taskId);
1018 },
1019
1020 async startCreateTask() {
1021 this.isCreating = true;
1022 this.isEditing = false;
1023 await this.refreshProjectOptions();
1024
1025 let initialProject = this.deriveActiveProject();
1026 if (!initialProject && this.projectOptions.length > 0) {
1027 initialProject = { ...this.projectOptions[0] };
1028 }
1029
1030 this.editingTask = defaultEditingTask({
1031 token: this.generateRandomToken(),
1032 project: initialProject,
1033 });
1034 this.selectedProjectSlug = initialProject?.name || "";
1035 setTimeout(() => this.initFlatpickr("create"), 100);
1036 },
1037
1038 async startEditTask(taskId) {
1039 const task = this.tasks.find((t) => t.uuid === taskId);
1040 if (!task) {
1041 this.notifyError("Task not found");
1042 return;
1043 }
1044
1045 this.isCreating = false;
1046 this.isEditing = true;
1047 this.setEditingTask(safeJsonClone(task));
1048 setTimeout(() => this.initFlatpickr("edit"), 100);
1049 },
1050
1051 cancelEdit() {
1052 this.destroyFlatpickr("all");
1053 this.resetEditingTask();
1054 this.selectedProjectSlug = "";
1055 this.isCreating = false;
1056 this.isEditing = false;
1057 },
1058
1059 normalizePlan() {
1060 this.editingTask.plan = normalizePlanStruct(this.editingTask.plan);
1061 },
1062
1063 addPlannedTime(mode = "create") {
1064 if (!this.editingTask.plan) {
1065 this.editingTask.plan = emptyPlan();
1066 }
1067 if (!Array.isArray(this.editingTask.plan.todo)) {
1068 this.editingTask.plan.todo = [];
1069 }
1070
1071 const inputId = mode === "edit" ? "newPlannedTime-edit" : "newPlannedTime-create";
1072 const input = document.getElementById(inputId);
1073 if (!input) {
1074 console.warn("[scheduler] Input element not found for planned time", inputId);
1075 return;
1076 }
1077
1078 const selectedDate = readDateFromPlannerInput(input);
1079 if (!selectedDate) {
1080 window.alert("Please select a valid date and time");
1081 return;
1082 }
1083
1084 this.editingTask.plan.todo.push(toUserWallClockISOString(selectedDate));
1085 this.editingTask.plan.todo.sort();
1086
1087 if (input._flatpickr) {
1088 input._flatpickr.clear();
1089 } else {
1090 input.value = "";
1091 }
1092 },
1093
1094 generateRandomToken() {
1095 const characters = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
1096 let token = "";
1097 for (let i = 0; i < 16; i++) {
1098 token += characters.charAt(Math.floor(Math.random() * characters.length));
1099 }
1100 return token;
1101 },
1102
1103 // UI bridge helpers --------------------------------------------------------
1104 initFlatpickr(mode = "all") {
1105 if (mode === "all" || mode === "create") {
1106 setupPlannerInput("newPlannedTime-create");
1107 }
1108 if (mode === "all" || mode === "edit") {
1109 setupPlannerInput("newPlannedTime-edit");
1110 }
1111 },
1112
1113 destroyFlatpickr(mode = "all") {
1114 if (mode === "all" || mode === "create") {
1115 destroyPlannerInput("newPlannedTime-create");
1116 }
1117 if (mode === "all" || mode === "edit") {
1118 destroyPlannerInput("newPlannedTime-edit");
1119 }
1120 },
1121
1122 // Notifications ------------------------------------------------------------
1123 notifySuccess(message, options = {}) {
1124 pushNotification("success", message, options.title, options.duration);
1125 },
1126
1127 notifyInfo(message, options = {}) {
1128 pushNotification("info", message, options.title, options.duration);
1129 },
1130
1131 notifyWarning(message, options = {}) {
1132 pushNotification("warning", message, options.title, options.duration);
1133 },
1134
1135 notifyError(message, options = {}) {
1136 pushNotification("error", message, options.title, options.duration);
1137 },
1138 };
1139
1140 const store = createStore("schedulerStore", schedulerStoreModel);
1141
1142 export { store };