1// Services wired from init() via setServices()
2let Node = null;
3let logContribution = async () => {};
4let useEnergy = async () => ({ energyUsed: 0 });
5let _metadata = null;
6
7export function setServices({ models, contributions, metadata }) {
8 Node = models.Node;
9 logContribution = contributions.logContribution;
10 if (metadata) _metadata = metadata;
11}
12export function setEnergyService(energy) { useEnergy = energy.useEnergy; }
13
14async function updateSchedule({
15 nodeId,
16 newSchedule,
17 reeffectTime,
18 userId,
19 wasAi = false,
20 chatId = null,
21 sessionId = null,
22}) {
23 if (!nodeId || reeffectTime === undefined) {
24 const error = new Error("nodeId and reeffectTime are required.");
25 error.status = 400;
26 throw error;
27 }
28
29 if (reeffectTime > 1000000) {
30 const error = new Error("reeffect time must be below 1,000,000 hrs");
31 error.status = 400;
32 throw error;
33 }
34
35 const node = await Node.findById(nodeId);
36 if (!node) {
37 const error = new Error("Node not found.");
38 error.status = 404;
39 throw error;
40 }
41 if (node.systemRole) throw new Error("Cannot modify system nodes");
42
43 let formattedDate = null;
44
45 if (newSchedule !== undefined && newSchedule !== "" && newSchedule !== null) {
46 formattedDate = new Date(newSchedule);
47 if (isNaN(formattedDate)) {
48 const error = new Error("Invalid schedule date.");
49 error.status = 400;
50 throw error;
51 }
52 }
53 const { energyUsed } = await useEnergy({
54 userId,
55 action: "editSchedule",
56 });
57
58 await _metadata.setExtMeta(node, "schedules", { date: formattedDate, reeffectTime });
59
60 const scheduleEdited = { date: formattedDate, reeffectTime };
61
62 await logContribution({
63 userId,
64 nodeId,
65 wasAi,
66 chatId,
67 sessionId,
68 action: "editSchedule",
69 scheduleEdited,
70 energyUsed,
71 });
72
73 return {
74 message: "Schedule and re-effect time updated successfully.",
75 node,
76 };
77}
78
79async function getCalendar({ rootNodeId, startDate, endDate }) {
80 if (!rootNodeId) {
81 const error = new Error("rootNodeId is required");
82 error.status = 400;
83 throw error;
84 }
85
86 const start = startDate ? new Date(startDate) : null;
87 const end = endDate ? new Date(endDate) : null;
88
89 if (start && isNaN(start)) {
90 const error = new Error("Invalid startDate");
91 error.status = 400;
92 throw error;
93 }
94
95 if (end && isNaN(end)) {
96 const error = new Error("Invalid endDate");
97 error.status = 400;
98 throw error;
99 }
100
101 const results = [];
102 const visited = new Set();
103
104 async function walk(nodeId) {
105 if (!nodeId || visited.has(nodeId)) return;
106 visited.add(nodeId);
107
108 const node = await Node.findById(nodeId)
109 .select("name metadata children")
110 .lean();
111
112 if (!node) return;
113
114 const meta = node.metadata instanceof Map ? Object.fromEntries(node.metadata) : (node.metadata || {});
115 const sched = meta.schedules || {};
116 const schedule = sched.date;
117
118 if (schedule) {
119 const scheduleDate = new Date(schedule);
120
121 const inRange =
122 (!start || scheduleDate >= start) && (!end || scheduleDate <= end);
123
124 if (inRange) {
125 results.push({
126 nodeId: node._id.toString(),
127 name: node.name ?? "(Untitled)",
128 schedule: scheduleDate,
129 reeffectTime: sched.reeffectTime ?? null,
130 });
131 }
132 }
133
134 if (Array.isArray(node.children)) {
135 for (const childId of node.children) {
136 await walk(childId);
137 }
138 }
139 }
140
141 await walk(rootNodeId);
142
143 return results;
144}
145
146export { updateSchedule, getCalendar };
147
1import express from "express";
2import log from "../../seed/log.js";
3import Node from "../../seed/models/node.js";
4import { sendError, ERR } from "../../seed/protocol.js";
5import urlAuth from "../html-rendering/urlAuth.js";
6import { htmlOnly, buildQS, tokenQS } from "../html-rendering/htmlHelpers.js";
7import { getExtension } from "../loader.js";
8import { renderCalendar } from "./pages/calendar.js";
9
10export default function buildSchedulesHtmlRoutes() {
11 const router = express.Router();
12
13 router.get("/root/:rootId/calendar", urlAuth, htmlOnly, async (req, res) => {
14 try {
15 const { rootId } = req.params;
16 const now = new Date();
17 const month = Math.max(1, Math.min(12, Number(req.query.month) || (now.getMonth() + 1)));
18 const year = Math.max(2000, Math.min(2100, Number(req.query.year) || now.getFullYear()));
19 const startDate = new Date(year, month - 1, 1);
20 const endDate = new Date(year, month, 0, 23, 59, 59);
21
22 let calendar = [];
23 try {
24 const schedules = getExtension("schedules");
25 if (schedules?.exports?.getCalendar) {
26 calendar = await schedules.exports.getCalendar({ rootNodeId: rootId, startDate, endDate });
27 }
28 } catch {}
29
30 const byDay = {};
31 for (const item of calendar) {
32 const d = new Date(item.scheduledDate);
33 if (isNaN(d.getTime())) continue;
34 const day = d.toISOString().split("T")[0];
35 (byDay[day] = byDay[day] || []).push(item);
36 }
37
38 return res.send(renderCalendar({
39 rootId, queryString: buildQS(req), month, year, byDay,
40 }));
41 } catch (err) {
42 log.error("HTML", "Calendar render error:", err.message);
43 sendError(res, 500, ERR.INTERNAL, err.message);
44 }
45 });
46
47 return router;
48}
49
1import tools from "./tools.js";
2import { setServices, setEnergyService, updateSchedule, getCalendar } from "./core.js";
3
4export async function init(core) {
5 setServices({ models: core.models, contributions: core.contributions, metadata: core.metadata });
6 if (core.energy) setEnergyService(core.energy);
7
8 const { default: router, setNodeModel } = await import("./routes.js");
9 setNodeModel(core.models.Node);
10
11 core.hooks.register("enrichContext", async ({ context, node, meta }) => {
12 const sched = meta.schedules;
13 if (sched?.date) context.schedule = sched.date;
14 if (sched?.reeffectTime) context.reeffectTime = sched.reeffectTime;
15 }, "schedules");
16
17 try {
18 const { getExtension } = await import("../loader.js");
19 const htmlExt = getExtension("html-rendering");
20 if (htmlExt) {
21 const { default: buildHtmlRoutes } = await import("./htmlRoutes.js");
22 htmlExt.router.use("/", buildHtmlRoutes());
23 }
24 } catch {}
25
26 // Register navigation for schedule tool (if treeos-base installed)
27 try {
28 const { getExtension } = await import("../loader.js");
29 const base = getExtension("treeos-base");
30 if (base?.exports?.registerToolNavigation) {
31 base.exports.registerToolNavigation("edit-node-schedule", ({ args, withToken: t }) =>
32 t(`/api/v1/node/${args.nodeId}?html`));
33 }
34 } catch {}
35
36 // Register tree quick link
37 try {
38 const { getExtension } = await import("../loader.js");
39 const base = getExtension("treeos-base");
40 base?.exports?.registerSlot?.("tree-quick-links", "schedules", ({ rootId, queryString }) =>
41 `<a href="/api/v1/root/${rootId}/calendar${queryString}" class="back-link">Calendar</a>`,
42 { priority: 20 }
43 );
44 base?.exports?.registerSlot?.("version-meta-cards", "schedules", ({ nodeId, version, qs, scheduleHtml, reeffectTime }) =>
45 `<div class="meta-card">
46 <div class="meta-label">Schedule</div>
47 <div class="schedule-info">
48 <div class="schedule-row">
49 <div class="schedule-text">
50 <div class="meta-value">${scheduleHtml || "Not set"}</div>
51 <div class="repeat-text">Repeat: ${reeffectTime || 0} hours</div>
52 </div>
53 <button id="editScheduleBtn" style="padding:8px 12px;">✏️</button>
54 </div>
55 </div>
56 </div>`,
57 { priority: 10 }
58 );
59 } catch {}
60
61 return {
62 router,
63 tools,
64 modeTools: [
65 { modeKey: "tree:edit", toolNames: ["edit-node-schedule"] },
66 ],
67 exports: { updateSchedule, getCalendar },
68 };
69}
70
1export default {
2 name: "schedules",
3 version: "1.0.1",
4 builtFor: "TreeOS",
5 description:
6 "Nodes exist in time, not just in space. A task has a due date. A goal has a review " +
7 "cadence. A project milestone has a target ship date. Schedules attaches a date and a " +
8 "reeffect interval to any node in the tree. The date is when. The reeffect time is how " +
9 "many hours until the next occurrence after prestige advances the version. Together they " +
10 "create recurring rhythms. A weekly review node with a 168-hour reeffect time will reset " +
11 "its schedule forward by exactly one week every time it prestiges." +
12 "\n\n" +
13 "The calendar endpoint walks the entire tree from root, collecting every node that has " +
14 "a schedule date set, optionally filtered to a date range. This gives a temporal view of " +
15 "the tree. Not what is where, but what is when. The same tree that organizes by topic " +
16 "also organizes by time without any structural compromise. A node under /Projects/Launch " +
17 "shows up in the March calendar because it has a March 15th schedule, not because someone " +
18 "duplicated it into a calendar branch." +
19 "\n\n" +
20 "enrichContext injects the schedule and reeffect time at every node so the AI knows about " +
21 "temporal context. The AI at a node with a schedule date two days from now responds " +
22 "differently than the AI at a node with no deadline. The prestige extension reads schedule " +
23 "data through the exported updateSchedule function to advance dates on version completion. " +
24 "Without prestige installed, schedules are still fully functional as standalone dates.",
25
26 needs: {
27 services: ["contributions", "hooks"],
28 models: ["Node"],
29 },
30
31 optional: {
32 services: ["energy"],
33 extensions: ["html-rendering", "treeos-base"],
34 },
35
36 provides: {
37 models: {},
38 routes: "./routes.js",
39 tools: true,
40 jobs: false,
41 orchestrator: false,
42 energyActions: {
43 editSchedule: { cost: 1 },
44 },
45 sessionTypes: {},
46 cli: [
47 { command: "schedule <date>", scope: ["tree"], description: "Set schedule on current node", method: "POST", endpoint: "/node/:nodeId/editSchedule" },
48 { command: "calendar", scope: ["tree"], description: "Show scheduled nodes for current tree", method: "GET", endpoint: "/root/:rootId/calendar" },
49 ],
50 hooks: {
51 fires: [],
52 listens: ["enrichContext"],
53 },
54 },
55};
56
1/* ------------------------------------------------- */
2/* Calendar page (extracted from root.js) */
3/* ------------------------------------------------- */
4
5import { page } from "../../html-rendering/html/layout.js";
6
7export function renderCalendar({ rootId, queryString, month, year, byDay }) {
8 const css = `
9 body { color: white; }
10 .container { max-width: 1200px; }
11
12 /* Glass Card Base */
13 .glass-card {
14 background: rgba(var(--glass-water-rgb), var(--glass-alpha));
15 backdrop-filter: blur(22px) saturate(140%);
16 -webkit-backdrop-filter: blur(22px) saturate(140%);
17 border-radius: 16px;
18 box-shadow: 0 8px 32px rgba(0, 0, 0, 0.12),
19 inset 0 1px 0 rgba(255, 255, 255, 0.25);
20 border: 1px solid rgba(255, 255, 255, 0.28);
21 position: relative;
22 overflow: hidden;
23 }
24
25 .glass-card::before {
26 content: "";
27 position: absolute;
28 inset: 0;
29 border-radius: inherit;
30 background: linear-gradient(
31 180deg,
32 rgba(255,255,255,0.18),
33 rgba(255,255,255,0.05)
34 );
35 pointer-events: none;
36 }
37
38 /* Header */
39 .header {
40 padding: 24px 28px;
41 margin-bottom: 20px;
42 animation: fadeInUp 0.5s ease-out;
43 }
44
45 .header-top {
46 display: flex;
47 justify-content: space-between;
48 align-items: center;
49 gap: 16px;
50 flex-wrap: wrap;
51 }
52
53 .nav-controls {
54 display: flex;
55 align-items: center;
56 gap: 16px;
57 }
58
59 .nav-button {
60 width: 40px;
61 height: 40px;
62 border-radius: 50%;
63 border: 1px solid rgba(255, 255, 255, 0.3);
64 background: rgba(255, 255, 255, 0.2);
65 color: white;
66 font-size: 18px;
67 font-weight: 700;
68 cursor: pointer;
69 transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
70 display: flex;
71 align-items: center;
72 justify-content: center;
73 box-shadow: 0 4px 16px rgba(0, 0, 0, 0.15),
74 inset 0 1px 0 rgba(255, 255, 255, 0.3);
75 }
76
77 .nav-button:hover {
78 background: rgba(255, 255, 255, 0.3);
79 transform: scale(1.1);
80 box-shadow: 0 6px 20px rgba(0, 0, 0, 0.2);
81 }
82
83 .month-label {
84 font-size: 20px;
85 font-weight: 700;
86 color: white;
87 min-width: 200px;
88 text-align: center;
89 text-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
90 letter-spacing: -0.3px;
91 }
92
93 .clock {
94 font-size: 14px;
95 color: rgba(255, 255, 255, 0.9);
96 font-weight: 500;
97 text-shadow: 0 1px 3px rgba(0, 0, 0, 0.2);
98 }
99
100 /* Calendar Grid - Desktop */
101 .calendar-grid {
102 display: grid;
103 grid-template-columns: repeat(7, 1fr);
104 gap: 12px;
105 padding: 24px;
106 animation: fadeInUp 0.6s ease-out;
107 }
108
109 .day-header {
110 background: rgba(255, 255, 255, 0.2);
111 backdrop-filter: blur(10px);
112 border-radius: 10px;
113 padding: 12px;
114 text-align: center;
115 font-weight: 700;
116 font-size: 14px;
117 color: white;
118 box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1),
119 inset 0 1px 0 rgba(255, 255, 255, 0.3);
120 border: 1px solid rgba(255, 255, 255, 0.25);
121 text-shadow: 0 1px 3px rgba(0, 0, 0, 0.2);
122 }
123
124 .day-cell {
125 background: rgba(255, 255, 255, 0.15);
126 backdrop-filter: blur(10px);
127 border-radius: 12px;
128 padding: 12px;
129 min-height: 120px;
130 cursor: pointer;
131 transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
132 box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1),
133 inset 0 1px 0 rgba(255, 255, 255, 0.25);
134 border: 1px solid rgba(255, 255, 255, 0.2);
135 position: relative;
136 overflow: hidden;
137 }
138
139 .day-cell::before {
140 content: "";
141 position: absolute;
142 inset: 0;
143 border-radius: inherit;
144 background: linear-gradient(
145 180deg,
146 rgba(255,255,255,0.15),
147 rgba(255,255,255,0.05)
148 );
149 pointer-events: none;
150 }
151
152 .day-cell:hover {
153 transform: translateY(-4px);
154 box-shadow: 0 12px 32px rgba(0, 0, 0, 0.2);
155 background: rgba(255, 255, 255, 0.25);
156 border-color: rgba(255, 255, 255, 0.4);
157 }
158
159 .day-cell.other-month {
160 opacity: 0.4;
161 }
162
163 .day-number {
164 font-weight: 700;
165 font-size: 16px;
166 color: white;
167 margin-bottom: 8px;
168 text-shadow: 0 1px 3px rgba(0, 0, 0, 0.2);
169 position: relative;
170 z-index: 1;
171 }
172
173 .day-cell.today .day-number {
174 background: rgba(255, 255, 255, 0.3);
175 color: white;
176 width: 32px;
177 height: 32px;
178 border-radius: 50%;
179 display: flex;
180 align-items: center;
181 justify-content: center;
182 font-size: 14px;
183 box-shadow: 0 0 20px rgba(255, 255, 255, 0.5),
184 inset 0 1px 0 rgba(255, 255, 255, 0.4);
185 border: 2px solid rgba(255, 255, 255, 0.5);
186 animation: pulse 2s infinite;
187 }
188
189 @keyframes pulse {
190 0%, 100% {
191 box-shadow: 0 0 20px rgba(255, 255, 255, 0.5),
192 inset 0 1px 0 rgba(255, 255, 255, 0.4);
193 }
194 50% {
195 box-shadow: 0 0 30px rgba(255, 255, 255, 0.7),
196 inset 0 1px 0 rgba(255, 255, 255, 0.6);
197 }
198 }
199
200 .node-item {
201 display: block;
202 margin: 4px 0;
203 padding: 6px 10px;
204 border-radius: 8px;
205 background: rgba(255, 255, 255, 0.25);
206 color: white;
207 font-size: 12px;
208 font-weight: 600;
209 text-decoration: none;
210 transition: all 0.2s;
211 white-space: nowrap;
212 overflow: hidden;
213 text-overflow: ellipsis;
214 box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
215 border: 1px solid rgba(255, 255, 255, 0.2);
216 position: relative;
217 z-index: 1;
218 }
219
220 .node-item:hover {
221 background: rgba(255, 255, 255, 0.35);
222 transform: translateX(2px);
223 box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
224 }
225
226 .node-count {
227 display: inline-block;
228 margin-top: 4px;
229 padding: 4px 8px;
230 background: rgba(255, 255, 255, 0.2);
231 color: white;
232 font-size: 11px;
233 font-weight: 700;
234 border-radius: 12px;
235 border: 1px solid rgba(255, 255, 255, 0.25);
236 position: relative;
237 z-index: 1;
238 }
239
240 /* List View - Mobile */
241 .calendar-list {
242 display: none;
243 padding: 16px;
244 gap: 12px;
245 }
246
247 .list-day {
248 background: rgba(255, 255, 255, 0.15);
249 backdrop-filter: blur(10px);
250 border-radius: 12px;
251 padding: 16px;
252 cursor: pointer;
253 transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
254 box-shadow: 0 4px 16px rgba(0, 0, 0, 0.1),
255 inset 0 1px 0 rgba(255, 255, 255, 0.25);
256 border: 1px solid rgba(255, 255, 255, 0.2);
257 position: relative;
258 overflow: hidden;
259 }
260
261 .list-day::before {
262 content: "";
263 position: absolute;
264 inset: 0;
265 border-radius: inherit;
266 background: linear-gradient(
267 180deg,
268 rgba(255,255,255,0.15),
269 rgba(255,255,255,0.05)
270 );
271 pointer-events: none;
272 }
273
274 .list-day:hover {
275 transform: translateY(-2px);
276 box-shadow: 0 8px 24px rgba(0, 0, 0, 0.15);
277 background: rgba(255, 255, 255, 0.2);
278 }
279
280 .list-day-header {
281 display: flex;
282 justify-content: space-between;
283 align-items: center;
284 margin-bottom: 12px;
285 position: relative;
286 z-index: 1;
287 }
288
289 .list-day-date {
290 font-weight: 700;
291 font-size: 16px;
292 color: white;
293 text-shadow: 0 1px 3px rgba(0, 0, 0, 0.2);
294 }
295
296 .list-day-badge {
297 padding: 4px 12px;
298 background: rgba(255, 255, 255, 0.25);
299 color: white;
300 border-radius: 12px;
301 font-size: 12px;
302 font-weight: 700;
303 border: 1px solid rgba(255, 255, 255, 0.3);
304 box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
305 }
306
307 /* Day View */
308 .day-view {
309 padding: 24px;
310 animation: fadeInUp 0.6s ease-out;
311 }
312
313 .hour-row {
314 display: flex;
315 border-bottom: 1px solid rgba(255, 255, 255, 0.15);
316 padding: 12px 0;
317 min-height: 60px;
318 transition: background 0.2s;
319 }
320
321 .hour-row:hover {
322 background: rgba(255, 255, 255, 0.08);
323 border-radius: 8px;
324 }
325
326 .hour-label {
327 width: 80px;
328 font-weight: 700;
329 color: white;
330 font-size: 14px;
331 flex-shrink: 0;
332 text-shadow: 0 1px 3px rgba(0, 0, 0, 0.2);
333 }
334
335 .hour-content {
336 flex: 1;
337 display: flex;
338 flex-direction: column;
339 gap: 6px;
340 }
341
342 .empty-state {
343 text-align: center;
344 padding: 60px 40px;
345 color: rgba(255, 255, 255, 0.8);
346 }
347
348 .empty-state-icon {
349 font-size: 64px;
350 margin-bottom: 16px;
351 opacity: 0.6;
352 }
353
354 /* Mobile Responsive */
355 @media (max-width: 768px) {
356 body {
357 padding: 12px;
358 }
359
360 .header {
361 padding: 16px;
362 }
363
364 .header-top {
365 flex-direction: column;
366 align-items: stretch;
367 }
368
369 .nav-controls {
370 justify-content: center;
371 }
372
373 .clock {
374 text-align: center;
375 }
376
377 /* Switch to list view on mobile */
378 .calendar-grid {
379 display: none;
380 }
381
382 .calendar-list {
383 display: flex;
384 flex-direction: column;
385 }
386
387 .day-view {
388 padding: 16px;
389 }
390
391 .hour-label {
392 width: 60px;
393 font-size: 12px;
394 }
395
396 .month-label {
397 font-size: 18px;
398 }
399
400 .nav-button {
401 width: 36px;
402 height: 36px;
403 font-size: 16px;
404 }
405 }
406`;
407
408 const body = `
409 <div class="container">
410 <!-- Header -->
411 <div class="glass-card header">
412 <div class="header-top">
413 <a href="/api/v1/root/${rootId}${queryString}" class="back-link" id="backLink">
414 <- Back to Tree
415 </a>
416
417 <div class="nav-controls">
418 <button class="nav-button" id="prevMonth"><-</button>
419 <div class="month-label" id="monthLabel"></div>
420 <button class="nav-button" id="nextMonth">-></button>
421 </div>
422
423 <div class="clock" id="clock"></div>
424 </div>
425 </div>
426
427 <!-- Calendar Container -->
428 <div class="glass-card" id="calendarContainer"></div>
429 </div>
430`;
431
432 const js = `
433 const params = new URLSearchParams(window.location.search);
434 const dayMode = params.get("day");
435 const calendarData = ${JSON.stringify(byDay)};
436 const month = ${month};
437 const year = ${year};
438
439 const monthNames = ["January","February","March","April","May","June","July","August","September","October","November","December"];
440 const dayNames = ["Sun","Mon","Tue","Wed","Thu","Fri","Sat"];
441
442 const container = document.getElementById("calendarContainer");
443 const monthLabel = document.getElementById("monthLabel");
444 const backLink = document.getElementById("backLink");
445
446 // Clock
447 function tick() {
448 document.getElementById("clock").textContent = new Date().toLocaleString();
449 }
450 tick();
451 setInterval(tick, 1000);
452
453 // Format hour for day view
454 function formatHour(h) {
455 if (h === 0) return "12 AM";
456 if (h < 12) return h + " AM";
457 if (h === 12) return "12 PM";
458 return (h - 12) + " PM";
459 }
460
461 // Render Day View
462 function renderDayView(dayKey) {
463 monthLabel.textContent = dayKey;
464 backLink.textContent = "<- Back to Month";
465 backLink.onclick = (e) => {
466 e.preventDefault();
467 const p = new URLSearchParams(window.location.search);
468 p.delete("day");
469 window.location.search = p.toString();
470 };
471
472 const items = (calendarData[dayKey] || []).slice().sort(
473 (a, b) => new Date(a.schedule) - new Date(b.schedule)
474 );
475
476 const byHour = {};
477 for (const item of items) {
478 const d = new Date(item.schedule);
479 const h = d.getHours();
480 if (!byHour[h]) byHour[h] = [];
481 byHour[h].push(item);
482 }
483
484 let html = '<div class="day-view">';
485
486 if (items.length === 0) {
487 html += '<div class="empty-state"><div class="empty-state-icon">\\ud83d\\udcc5</div><div>No scheduled items for this day</div></div>';
488 } else {
489 for (let h = 0; h < 24; h++) {
490 html += \`
491 <div class="hour-row">
492 <div class="hour-label">\${formatHour(h)}</div>
493 <div class="hour-content">
494 \`;
495
496 (byHour[h] || []).forEach(item => {
497 html += \`<a class="node-item" href="/api/v1/node/\${item.nodeId}/\0${queryString}">\${item.name}</a>\`;
498 });
499
500 html += '</div></div>';
501 }
502 }
503
504 html += '</div>';
505 container.innerHTML = html;
506 }
507
508 // Render Month View
509 function renderMonthView() {
510 monthLabel.textContent = monthNames[month] + " " + year;
511
512 const firstDay = new Date(year, month, 1);
513 const start = new Date(firstDay);
514 start.setDate(1 - firstDay.getDay());
515
516 const today = new Date();
517 const todayStr = today.toISOString().slice(0, 10);
518
519 const isMobile = window.innerWidth <= 768;
520
521 if (isMobile) {
522 // List view for mobile
523 let html = '<div class="calendar-list">';
524
525 const daysWithEvents = [];
526 for (let i = 0; i < 42; i++) {
527 const d = new Date(start);
528 d.setDate(start.getDate() + i);
529 const key = d.toISOString().slice(0, 10);
530 const items = calendarData[key] || [];
531
532 if (items.length > 0 || d.getMonth() === month) {
533 daysWithEvents.push({ date: d, key, items });
534 }
535 }
536
537 if (daysWithEvents.length === 0) {
538 html += '<div class="empty-state"><div class="empty-state-icon">\\ud83d\\udcc5</div><div>No scheduled items this month</div></div>';
539 } else {
540 daysWithEvents.forEach(({ date, key, items }) => {
541 const dayOfWeek = dayNames[date.getDay()];
542 const isToday = key === todayStr;
543
544 html += \`
545 <div class="list-day" onclick="goToDay('\${key}')">
546 <div class="list-day-header">
547 <div class="list-day-date">
548 \${dayOfWeek}, \${monthNames[date.getMonth()]} \${date.getDate()}
549 \${isToday ? ' <span style="text-shadow: 0 0 10px rgba(255,255,255,0.8);">\\u2728 Today</span>' : ''}
550 </div>
551 \${items.length > 0 ? \`<span class="list-day-badge">\${items.length} item\${items.length !== 1 ? 's' : ''}</span>\` : ''}
552 </div>
553 \`;
554
555 if (items.length > 0) {
556 items.slice(0, 3).forEach(item => {
557 html += \`<a class="node-item" href="/api/v1/node/\${item.nodeId}/\0${queryString}" onclick="event.stopPropagation()">\${item.name}</a>\`;
558 });
559
560 if (items.length > 3) {
561 html += \`<div class="node-count">+\${items.length - 3} more</div>\`;
562 }
563 }
564
565 html += '</div>';
566 });
567 }
568
569 html += '</div>';
570 container.innerHTML = html;
571 } else {
572 // Grid view for desktop
573 let html = '<div class="calendar-grid">';
574
575 // Day headers
576 dayNames.forEach(day => {
577 html += \`<div class="day-header">\${day}</div>\`;
578 });
579
580 // Days
581 for (let i = 0; i < 42; i++) {
582 const d = new Date(start);
583 d.setDate(start.getDate() + i);
584 const key = d.toISOString().slice(0, 10);
585 const items = calendarData[key] || [];
586 const isOtherMonth = d.getMonth() !== month;
587 const isToday = key === todayStr;
588
589 html += \`
590 <div class="day-cell \${isOtherMonth ? 'other-month' : ''} \${isToday ? 'today' : ''}" onclick="goToDay('\${key}')">
591 <div class="day-number">\${d.getDate()}</div>
592 \`;
593
594 items.slice(0, 3).forEach(item => {
595 html += \`<a class="node-item" href="/api/v1/node/\${item.nodeId}/\0${queryString}" onclick="event.stopPropagation()">\${item.name}</a>\`;
596 });
597
598 if (items.length > 3) {
599 html += \`<div class="node-count">+\${items.length - 3} more</div>\`;
600 }
601
602 html += '</div>';
603 }
604
605 html += '</div>';
606 container.innerHTML = html;
607 }
608 }
609
610 // Navigate to day
611 function goToDay(key) {
612 const p = new URLSearchParams(window.location.search);
613 p.set("day", key);
614 window.location.search = p.toString();
615 }
616
617 // Navigation buttons
618 document.getElementById("prevMonth").onclick = () => {
619 const p = new URLSearchParams(window.location.search);
620
621 if (dayMode) {
622 const d = new Date(dayMode);
623 d.setDate(d.getDate() - 1);
624 p.set("day", d.toISOString().slice(0, 10));
625 } else {
626 let m = month - 1;
627 let y = year;
628 if (m < 0) { m = 11; y--; }
629 p.set("month", m);
630 p.set("year", y);
631 }
632
633 window.location.search = p.toString();
634 };
635
636 document.getElementById("nextMonth").onclick = () => {
637 const p = new URLSearchParams(window.location.search);
638
639 if (dayMode) {
640 const d = new Date(dayMode);
641 d.setDate(d.getDate() + 1);
642 p.set("day", d.toISOString().slice(0, 10));
643 } else {
644 let m = month + 1;
645 let y = year;
646 if (m > 11) { m = 0; y++; }
647 p.set("month", m);
648 p.set("year", y);
649 }
650
651 window.location.search = p.toString();
652 };
653
654 // Initial render
655 if (dayMode) {
656 renderDayView(dayMode);
657 } else {
658 renderMonthView();
659 }
660
661 // Re-render on resize
662 let resizeTimer;
663 window.addEventListener('resize', () => {
664 clearTimeout(resizeTimer);
665 resizeTimer = setTimeout(() => {
666 if (!dayMode) renderMonthView();
667 }, 250);
668 });
669`;
670
671 return page({
672 title: "Calendar",
673 css,
674 body,
675 js,
676 });
677}
678
1import log from "../../seed/log.js";
2import express from "express";
3import { sendOk, sendError, ERR } from "../../seed/protocol.js";
4import authenticate from "../../seed/middleware/authenticate.js";
5import { updateSchedule } from "./core.js";
6
7// Node model wired from init via setNodeModel before routes are used.
8let _Node = null;
9export function setNodeModel(Node) { _Node = Node; }
10
11const router = express.Router();
12
13async function useLatest(req, res, next) {
14 try {
15 const node = await _Node.findById(req.params.nodeId).select("metadata").lean();
16 if (!node) return sendError(res, 404, ERR.NODE_NOT_FOUND, "Node not found");
17 const meta = node.metadata instanceof Map ? node.metadata.get("prestige") : node.metadata?.prestige;
18 req.params.version = String(meta?.current || 0);
19 next();
20 } catch (err) {
21 sendError(res, 500, ERR.INTERNAL, err.message);
22 }
23}
24
25const editScheduleHandler = async (req, res) => {
26 try {
27 const { nodeId, version } = req.params;
28 const userId = req.userId;
29
30 const newSchedule = req.body?.newSchedule || req.query?.newSchedule;
31 const reeffectTime = req.body?.reeffectTime ?? req.query?.reeffectTime;
32
33 if (reeffectTime === undefined) {
34 return sendError(res, 400, ERR.INVALID_INPUT, "reeffectTime is required");
35 }
36
37 const result = await updateSchedule({
38 nodeId,
39 versionIndex: Number(version),
40 newSchedule,
41 reeffectTime: Number(reeffectTime),
42 userId,
43 });
44
45 if ("html" in req.query) {
46 return res.redirect(
47 `/api/v1/node/${nodeId}/${version}?token=${req.query.token ?? ""}&html`,
48 );
49 }
50
51 sendOk(res, result);
52 } catch (err) {
53 log.error("Schedules", "editSchedule error:", err);
54 sendError(res, err.status || 400, ERR.INVALID_INPUT, err.message);
55 }
56};
57
58router.post("/node/:nodeId/editSchedule", authenticate, useLatest, editScheduleHandler);
59router.post("/node/:nodeId/:version/editSchedule", authenticate, editScheduleHandler);
60
61export default router;
62
1import { z } from "zod";
2import { updateSchedule } from "./core.js";
3
4export default [
5 {
6 name: "edit-node-schedule",
7 description:
8 "Set or update a node's schedule. Use for reminders, recurring events, deadlines.",
9 schema: {
10 nodeId: z
11 .string()
12 .describe("The node to schedule."),
13 newSchedule: z
14 .string()
15 .describe("The schedule date/time (ISO 8601 format)."),
16 reeffectTime: z
17 .number()
18 .optional()
19 .describe("Recurring interval in hours. e.g. 168 for weekly, 24 for daily. Omit for one-time."),
20 userId: z.string().describe("Injected by server. Ignore."),
21 chatId: z
22 .string()
23 .nullable()
24 .optional()
25 .describe("Injected by server. Ignore."),
26 sessionId: z
27 .string()
28 .nullable()
29 .optional()
30 .describe("Injected by server. Ignore."),
31 },
32 annotations: {
33 readOnlyHint: false,
34 destructiveHint: false,
35 idempotentHint: true,
36 },
37 handler: async ({
38 nodeId,
39 newSchedule,
40 reeffectTime,
41 userId,
42 chatId,
43 sessionId,
44 }) => {
45 try {
46 const result = await updateSchedule({
47 nodeId,
48 newSchedule,
49 reeffectTime: reeffectTime || 0,
50 userId,
51 wasAi: true,
52 chatId,
53 sessionId,
54 });
55
56 return {
57 content: [{ type: "text", text: JSON.stringify(result, null, 2) }],
58 };
59 } catch (err) {
60 return {
61 content: [
62 {
63 type: "text",
64 text: `Failed to update schedule: ${err.message}`,
65 },
66 ],
67 };
68 }
69 },
70 },
71];
72
Loading comments...