main
js 205 lines 5.9 KB
Raw
1 import { createStore } from "/js/AlpineStore.js";
2 import { callJsonApi } from "/js/api.js";
3 import { formatDuration } from "/js/time-utils.js";
4 import { store as chatsStore } from "/components/sidebar/chats/chats-store.js";
5 import {
6 toastFrontendError,
7 toastFrontendSuccess,
8 } from "/components/notifications/notification-store.js";
9
10 const GOAL_API_PATH = "/plugins/_goal/goal";
11
12 const model = {
13 goal: null,
14 loading: false,
15 saving: false,
16 editing: false,
17 draft: "",
18 lastContextId: "",
19 clockIntervalId: null,
20 goalChangedHandler: null,
21 now: Date.now(),
22
23 get visible() {
24 return Boolean(this.goal?.objective && this.goal.status !== "complete");
25 },
26
27 get contextId() {
28 return chatsStore?.getSelectedChatId?.() || globalThis.getContext?.() || "";
29 },
30
31 get statusLabel() {
32 const status = this.goal?.status || "active";
33 if (status === "paused") return "Goal paused";
34 if (status === "complete") return "Goal complete";
35 if (status === "blocked") return "Goal blocked";
36 return "Pursuing goal";
37 },
38
39 get statusIcon() {
40 const status = this.goal?.status || "active";
41 if (status === "paused") return "pause_circle";
42 if (status === "complete") return "check_circle";
43 if (status === "blocked") return "error";
44 return "track_changes";
45 },
46
47 get elapsedSeconds() {
48 if (!this.goal) return 0;
49 const status = this.goal.status || "active";
50 const storedSeconds = Number.parseInt(this.goal.elapsed_seconds, 10);
51 let seconds = Number.isNaN(storedSeconds) ? 0 : storedSeconds;
52 if (Number.isNaN(storedSeconds) && status !== "active") {
53 seconds = this.secondsBetween(this.goal.created_at, this.goal.updated_at);
54 }
55 if (status === "active") {
56 seconds += this.secondsBetween(
57 this.goal.active_since || this.goal.created_at || this.goal.updated_at,
58 this.now,
59 );
60 }
61 return Math.max(0, seconds);
62 },
63
64 secondsBetween(start, end) {
65 const startMs = Date.parse(start || "");
66 const endMs = typeof end === "number" ? end : Date.parse(end || "");
67 if (Number.isNaN(startMs) || Number.isNaN(endMs)) return 0;
68 return Math.max(0, Math.floor((endMs - startMs) / 1000));
69 },
70
71 get elapsedLabel() {
72 return formatDuration(this.elapsedSeconds * 1000);
73 },
74
75 onMount() {
76 document.getElementById("progress-bar-box")?.classList.add("has-goal-bar");
77 this.goalChangedHandler = (event) => {
78 const detail = event?.detail || {};
79 if (detail.context_id && detail.context_id !== this.contextId) return;
80 this.goal = detail.goal || null;
81 this.now = Date.now();
82 if (!this.goal) this.editing = false;
83 };
84 window.addEventListener("goal:changed", this.goalChangedHandler);
85 this.clockIntervalId = window.setInterval(() => {
86 this.now = Date.now();
87 }, 1000);
88 },
89
90 cleanup() {
91 document.getElementById("progress-bar-box")?.classList.remove("has-goal-bar");
92 if (this.goalChangedHandler) {
93 window.removeEventListener("goal:changed", this.goalChangedHandler);
94 }
95 if (this.clockIntervalId) {
96 window.clearInterval(this.clockIntervalId);
97 }
98 this.goalChangedHandler = null;
99 this.clockIntervalId = null;
100 },
101
102 async refresh(force = false) {
103 const contextId = this.contextId;
104 if (!contextId) {
105 this.goal = null;
106 this.lastContextId = "";
107 return;
108 }
109 if (!force && this.loading) return;
110
111 this.loading = true;
112 try {
113 const response = await callJsonApi(GOAL_API_PATH, {
114 action: "get",
115 context_id: contextId,
116 });
117 this.goal = response?.goal || null;
118 this.now = Date.now();
119 this.lastContextId = contextId;
120 if (!this.goal) this.editing = false;
121 } catch (error) {
122 console.error("Failed to load goal:", error);
123 this.goal = null;
124 this.lastContextId = contextId;
125 } finally {
126 this.loading = false;
127 }
128 },
129
130 startEdit() {
131 if (!this.goal) return;
132 this.draft = this.goal.objective || "";
133 this.editing = true;
134 requestAnimationFrame(() => {
135 document.querySelector(".goal-strip-input")?.focus?.();
136 document.querySelector(".goal-strip-input")?.select?.();
137 });
138 },
139
140 cancelEdit() {
141 this.editing = false;
142 this.draft = "";
143 },
144
145 async saveEdit() {
146 const objective = (this.draft || "").trim();
147 if (!objective) {
148 void toastFrontendError("Goal objective is required.", "Goal");
149 return;
150 }
151 const response = await this.update(
152 { action: "update", objective, status: "active" },
153 "Goal updated.",
154 );
155 this.editing = false;
156 if (response?.reactivated) {
157 await globalThis.sendMessage?.({
158 message: response.goal?.objective || objective,
159 context: response.goal?.context_id || this.contextId,
160 });
161 }
162 },
163
164 async pauseOrResume() {
165 if (!this.goal) return;
166 const action = this.goal.status === "active" ? "pause" : "resume";
167 await this.update(
168 { action },
169 action === "pause" ? "Goal paused." : "Goal resumed.",
170 );
171 },
172
173 async deleteGoal() {
174 await this.update({ action: "delete" }, "Goal deleted.");
175 },
176
177 async update(payload, successMessage) {
178 const contextId = this.contextId;
179 if (!contextId || this.saving) return;
180
181 this.saving = true;
182 try {
183 const response = await callJsonApi(GOAL_API_PATH, {
184 ...payload,
185 context_id: contextId,
186 });
187 this.goal = response?.goal || null;
188 this.now = Date.now();
189 this.lastContextId = contextId;
190 window.dispatchEvent(new CustomEvent("goal:changed", {
191 detail: { goal: this.goal, context_id: contextId },
192 }));
193 void toastFrontendSuccess(successMessage, "Goal");
194 return response;
195 } catch (error) {
196 console.error("Failed to update goal:", error);
197 void toastFrontendError(error?.message || "Failed to update goal.", "Goal");
198 return null;
199 } finally {
200 this.saving = false;
201 }
202 },
203 };
204
205 export const store = createStore("goalBar", model);