EXTENSION for TreeOS
schedules
Nodes exist in time, not just in space. A task has a due date. A goal has a review cadence. A project milestone has a target ship date. Schedules attaches a date and a reeffect interval to any node in the tree. The date is when. The reeffect time is how many hours until the next occurrence after prestige advances the version. Together they create recurring rhythms. A weekly review node with a 168-hour reeffect time will reset its schedule forward by exactly one week every time it prestiges. The calendar endpoint walks the entire tree from root, collecting every node that has a schedule date set, optionally filtered to a date range. This gives a temporal view of the tree. Not what is where, but what is when. The same tree that organizes by topic also organizes by time without any structural compromise. A node under /Projects/Launch shows up in the March calendar because it has a March 15th schedule, not because someone duplicated it into a calendar branch. enrichContext injects the schedule and reeffect time at every node so the AI knows about temporal context. The AI at a node with a schedule date two days from now responds differently than the AI at a node with no deadline. The prestige extension reads schedule data through the exported updateSchedule function to advance dates on version completion. Without prestige installed, schedules are still fully functional as standalone dates.
v1.0.1 by TreeOS Site 0 downloads 7 files 1,134 lines 31.1 KB published 38d ago
treeos ext install schedules
View changelog

Manifest

Provides

  • routes
  • tools
  • 2 CLI commands
  • 1 energy actions

Requires

  • services: contributions, hooks
  • models: Node

Optional

  • services: energy
  • extensions: html-rendering, treeos-base
SHA256: 8da49298b52331b37676c79a63dec075e74e5581df13ad4bdc7006ed830efb71

CLI Commands

CommandMethodDescription
schedule <date>POSTSet schedule on current node
calendarGETShow scheduled nodes for current tree

Hooks

Listens To

  • enrichContext

Source Code

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;">&#9999;&#65039;</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

Versions

Version Published Downloads
1.0.1 38d ago 0
1.0.0 48d ago 0
0 stars
0 flags
React from the CLI: treeos ext star schedules

Comments

Loading comments...

Post comments from the CLI: treeos ext comment schedules "your comment"
Max 3 comments per extension. One star and one flag per user.