@rackbops/plugin-tracker 0.4.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/README.md +42 -10
  2. package/dist/plugin.js +984 -212
  3. package/package.json +2 -2
package/dist/plugin.js CHANGED
@@ -2381,6 +2381,19 @@ function serial() {
2381
2381
  };
2382
2382
  }
2383
2383
 
2384
+ // plugins/tracker/src/limits.ts
2385
+ var MAX_TITLE = 100;
2386
+ var MAX_REMINDER_TEXT = 1500;
2387
+ var MAX_WHEN = 100;
2388
+ var MAX_NOTE = 300;
2389
+ var MAX_URL = 1000;
2390
+ var MAX_NEAR = 100;
2391
+ var CURRENCY_LENGTH = 3;
2392
+ var DATE_LENGTH = 10;
2393
+ var MAX_ZONE = 64;
2394
+ var MAX_TASK_ID = 24;
2395
+ var MAX_LIVE_TASKS = 200;
2396
+
2384
2397
  // plugins/tracker/src/people.ts
2385
2398
  var DEFAULT_TIME_ZONE = "America/New_York";
2386
2399
  var DEFAULT_PREFERRED_HOUR = 9;
@@ -2447,10 +2460,48 @@ async function setPreferences(store, userId, prefs) {
2447
2460
  });
2448
2461
  }
2449
2462
 
2463
+ // plugins/tracker/src/retime.ts
2464
+ var DAY_MS2 = 24 * 60 * 60 * 1000;
2465
+ function localDay(at, zone) {
2466
+ const w = wallClock(at, zone);
2467
+ return `${w.year}-${String(w.month).padStart(2, "0")}-${String(w.day).padStart(2, "0")}`;
2468
+ }
2469
+ function occurrenceDay(s, due, zone) {
2470
+ return s.kind === "period" ? periodDate(s, due, zone) : localDay(due, zone);
2471
+ }
2472
+ function retimed(s, day, owner) {
2473
+ const ctx = { zone: owner.timeZone, preferredHour: owner.preferredHour };
2474
+ const lead = s.kind === "period" ? s.leadDays ?? 0 : 0;
2475
+ let after = new Date(Date.parse(`${day}T00:00:00Z`) - (lead + 2) * DAY_MS2);
2476
+ for (let i = 0;i < 1000; i++) {
2477
+ const due = nextDue(s, after, ctx);
2478
+ if (!due)
2479
+ return null;
2480
+ const on = occurrenceDay(s, due, owner.timeZone);
2481
+ if (on === day)
2482
+ return due;
2483
+ if (on > day)
2484
+ return null;
2485
+ after = due;
2486
+ }
2487
+ return null;
2488
+ }
2489
+ async function heldRuns(store, task) {
2490
+ return (await store.listOccurrences({ taskId: task.id, status: "queued" })).filter((o) => !o.dedupeKey.startsWith(SNOOZE_PREFIX));
2491
+ }
2492
+ async function restoreHeldRun(store, task, held, before, after, now) {
2493
+ const s = task.schedule;
2494
+ const first = held[0];
2495
+ if (!s || !first)
2496
+ return null;
2497
+ const due = retimed(s, occurrenceDay(s, new Date(first.dueAt), before.timeZone), after);
2498
+ if (!due)
2499
+ return null;
2500
+ return store.createOccurrence({ taskId: task.id, lane: task.lane, dueAt: due.toISOString(), dedupeKey: scheduledKey(task.id, due), at: now.toISOString() });
2501
+ }
2502
+
2450
2503
  // plugins/tracker/src/actions.ts
2451
2504
  var MAX_ANSWER = 2000;
2452
- var MAX_REMINDER_TEXT = 1500;
2453
- var MAX_TITLE = 100;
2454
2505
  var NO_SUCH_TASK = "You have no task with that id. `/tasks` lists yours.";
2455
2506
  function clip(text, max = MAX_ANSWER) {
2456
2507
  return text.length <= max ? text : `${text.slice(0, max - 3)}...`;
@@ -2505,7 +2556,7 @@ async function registerPerson(d, user, input) {
2505
2556
  d.admissions.markRegistered(user.id, d.clock.now().toISOString());
2506
2557
  const zoneChanged = updated.timeZone !== user.timeZone;
2507
2558
  if (zoneChanged || updated.preferredHour !== user.preferredHour)
2508
- await rescheduleOwned(d, updated, zoneChanged);
2559
+ await rescheduleOwned(d, updated, zoneChanged, user);
2509
2560
  return registeredText(updated, first);
2510
2561
  }
2511
2562
  async function saveSettings(d, user, input) {
@@ -2519,7 +2570,7 @@ async function saveSettings(d, user, input) {
2519
2570
  }
2520
2571
  const zoneChanged = updated.timeZone !== user.timeZone;
2521
2572
  if (zoneChanged || updated.preferredHour !== user.preferredHour)
2522
- await rescheduleOwned(d, updated, zoneChanged);
2573
+ await rescheduleOwned(d, updated, zoneChanged, user);
2523
2574
  return null;
2524
2575
  }
2525
2576
  async function setHour(d, user, hour) {
@@ -2533,73 +2584,46 @@ async function setHour(d, user, hour) {
2533
2584
  return err.message;
2534
2585
  throw err;
2535
2586
  }
2536
- const moved = await rescheduleOwned(d, updated, false);
2587
+ const moved = await rescheduleOwned(d, updated, false, user);
2537
2588
  const note = moved > 0 ? ` ${moved} recurring reminder(s) moved to it.` : "";
2538
2589
  return `Your preferred hour is now ${String(hour).padStart(2, "0")}:00, ${updated.timeZone} time.${note}`;
2539
2590
  }
2540
- async function rescheduleOwned(d, owner, zoneChanged) {
2591
+ async function rescheduleOwned(d, owner, zoneChanged, before) {
2541
2592
  let moved = 0;
2542
- for (const task of await d.store.listTasks({ ownerId: owner.id, status: "active" })) {
2593
+ const live = [...await d.store.listTasks({ ownerId: owner.id, status: "active" }), ...await d.store.listTasks({ ownerId: owner.id, status: "paused" })];
2594
+ for (const task of live) {
2543
2595
  const s = task.schedule;
2544
2596
  if (!s || s.kind !== "calendar" && s.kind !== "period")
2545
2597
  continue;
2546
2598
  if (!zoneChanged && s.hour !== undefined)
2547
2599
  continue;
2548
- const snoozes = (await d.store.listOccurrences({ taskId: task.id, status: "queued" })).filter((o) => o.dedupeKey.startsWith(SNOOZE_PREFIX));
2549
- const now = d.clock.now();
2550
- await reschedule(d.store, task, owner, s, owner.id, now);
2551
- for (const o of snoozes) {
2552
- await d.store.createOccurrence({ taskId: o.taskId, lane: o.lane, dueAt: o.dueAt, dedupeKey: o.dedupeKey, at: now.toISOString() });
2553
- }
2600
+ const held = task.status === "paused" ? await heldRuns(d.store, task) : [];
2601
+ const updated = await rescheduleKeepingSnoozes(d, task, owner, s, owner.id);
2602
+ if (held.length > 0)
2603
+ await restoreHeldRun(d.store, updated, held, before, owner, d.clock.now());
2554
2604
  moved++;
2555
2605
  }
2556
2606
  return moved;
2557
2607
  }
2558
- function reminderSchedule(when, repeat, zone, now) {
2559
- if (repeat === "none") {
2560
- if (!when)
2561
- return 'Say when: for example "in 20 minutes", "tomorrow 9am" or "friday at 17:30".';
2562
- return { kind: "once", at: when.toISOString() };
2563
- }
2564
- const w = wallClock(when ?? now, zone);
2565
- const start = `${w.year}-${String(w.month).padStart(2, "0")}-${String(w.day).padStart(2, "0")}`;
2566
- const base = { kind: "calendar", every: 1, unit: repeat, start };
2567
- if (!when)
2568
- return base;
2569
- return {
2570
- ...base,
2571
- hour: w.hour,
2572
- minute: w.minute,
2573
- ...repeat === "week" ? { weekdays: [w.weekday] } : {},
2574
- ...repeat === "month" ? { dayOfMonth: w.day } : {}
2575
- };
2576
- }
2577
- async function remind(d, user, input) {
2578
- const type = d.types.reminder;
2579
- if (!type)
2580
- return "Reminders are not available on this bot.";
2581
- const text = input.text.trim();
2582
- if (text.length === 0)
2583
- return "Say what to remind you of.";
2584
- if (text.length > MAX_REMINDER_TEXT)
2585
- return `That reminder is longer than ${MAX_REMINDER_TEXT} characters.`;
2608
+ async function rescheduleKeepingSnoozes(d, task, owner, schedule, actorId) {
2609
+ const snoozes = (await d.store.listOccurrences({ taskId: task.id, status: "queued" })).filter((o) => o.dedupeKey.startsWith(SNOOZE_PREFIX));
2586
2610
  const now = d.clock.now();
2587
- let when = null;
2588
- if (input.when !== undefined && input.when.trim() !== "") {
2589
- const parsed = parseWhen(input.when, now, { zone: user.timeZone, defaultHour: user.preferredHour });
2590
- if (!parsed.ok)
2591
- return parsed.error;
2592
- when = parsed.at;
2611
+ const { task: updated } = await reschedule(d.store, task, owner, schedule, actorId, now);
2612
+ for (const o of snoozes) {
2613
+ await d.store.createOccurrence({ taskId: o.taskId, lane: o.lane, dueAt: o.dueAt, dedupeKey: o.dedupeKey, at: now.toISOString() });
2593
2614
  }
2594
- const schedule = reminderSchedule(when, input.repeat, user.timeZone, now);
2595
- if (typeof schedule === "string")
2596
- return schedule;
2597
- const actor = { userId: user.id, admin: user.admin };
2598
- const { task, next } = await createTask(d.store, actor, user, { type, title: clip(text, MAX_TITLE), config: { text }, schedule }, now);
2599
- const at = next ? formatInstant(next.dueAt, user.timeZone, now) : "never (that time has no next run)";
2600
- const cadence = schedule.kind === "once" ? "" : `, ${describeSchedule(schedule, user, user.timeZone, now)}`;
2601
- return clip(`Reminder \`${task.id}\` set: ${text}
2602
- Next: ${at}${cadence}.`);
2615
+ return updated;
2616
+ }
2617
+ async function liveTaskCap(d, owner) {
2618
+ const active = await d.store.listTasks({ ownerId: owner.id, status: "active" });
2619
+ const paused = await d.store.listTasks({ ownerId: owner.id, status: "paused" });
2620
+ if (active.length + paused.length < MAX_LIVE_TASKS)
2621
+ return null;
2622
+ const where = d.webEditor ? " or delete one on the web" : "";
2623
+ return `You already have ${MAX_LIVE_TASKS} active or paused tasks, the most one person may. Finish one${where} first.`;
2624
+ }
2625
+ function said(r) {
2626
+ return r.ok ? r.text : r.error;
2603
2627
  }
2604
2628
  async function loadTaskList(d, user) {
2605
2629
  const entries = await taskList(d.store, { userId: user.id, admin: user.admin });
@@ -2632,6 +2656,8 @@ async function ownTask(d, user, taskId) {
2632
2656
  return task && task.ownerId === user.id ? task : null;
2633
2657
  }
2634
2658
  async function answerLatest(d, user, input) {
2659
+ if (input.until !== undefined && input.until.trim().length > MAX_WHEN)
2660
+ return `\`until\` is longer than ${MAX_WHEN} characters.`;
2635
2661
  const task = await ownTask(d, user, input.taskId);
2636
2662
  if (!task)
2637
2663
  return NO_SUCH_TASK;
@@ -2662,18 +2688,136 @@ function answeredText(kind, snoozeUntil, user, now) {
2662
2688
  return "Marked done.";
2663
2689
  return "Recorded.";
2664
2690
  }
2665
- async function resumeTask(d, user, taskId) {
2691
+
2692
+ // plugins/tracker/src/manage.ts
2693
+ async function ownLiveTask(d, user, taskId) {
2666
2694
  const task = await ownTask(d, user, taskId);
2695
+ return task && task.status !== "archived" ? task : null;
2696
+ }
2697
+ function editable(task) {
2698
+ return task.status === "active" || task.status === "paused";
2699
+ }
2700
+ var FINISHED = "That task has finished, so it cannot be changed. Set a new one instead.";
2701
+ async function pauseTask(d, user, taskId) {
2702
+ const task = await ownLiveTask(d, user, taskId);
2667
2703
  if (!task)
2668
- return NO_SUCH_TASK;
2669
- const removed = await d.health.resumeTask(task, user.id, d.clock.now());
2670
- if (removed === null)
2671
- return task.status === "paused" ? "That task was not paused by failed DMs." : `That task is ${task.status}, not paused.`;
2704
+ return { ok: false, error: NO_SUCH_TASK };
2705
+ if (task.status !== "active")
2706
+ return { ok: false, error: `That task is ${task.status}, not active.` };
2707
+ const at = d.clock.now().toISOString();
2708
+ await d.store.updateTask(task.id, { status: "paused", at });
2709
+ await d.store.addTaskEvent({ taskId: task.id, actorId: user.id, kind: "paused", detail: "paused by the owner", at });
2710
+ return { ok: true, text: `Paused \`${task.id}\`: nothing is sent until you resume it.` };
2711
+ }
2712
+ async function resumeTask(d, user, taskId) {
2713
+ const task = await ownLiveTask(d, user, taskId);
2714
+ if (!task)
2715
+ return { ok: false, error: NO_SUCH_TASK };
2716
+ if (task.status !== "paused")
2717
+ return { ok: false, error: `That task is ${task.status}, not paused.` };
2718
+ const now = d.clock.now();
2719
+ if (d.health.isPaused(user.id))
2720
+ await d.health.resume(user.id, now);
2721
+ const removed = await d.health.resumeTask(task, user.id, now);
2722
+ if (removed === null) {
2723
+ const current = await d.store.getTask(task.id);
2724
+ if (current?.status === "paused") {
2725
+ const at = now.toISOString();
2726
+ const active = await d.store.updateTask(task.id, { status: "active", at });
2727
+ await d.store.addTaskEvent({ taskId: task.id, actorId: user.id, kind: "resumed", detail: "resumed by the owner", at });
2728
+ await materialize(d.store, active, user, now);
2729
+ }
2730
+ return { ok: true, text: `Resumed \`${task.id}\`.` };
2731
+ }
2672
2732
  const names = [];
2673
2733
  for (const id of removed)
2674
2734
  names.push((await d.store.getUser(id))?.displayName ?? id);
2675
2735
  const without = names.length > 0 ? `, without ${names.join(", ")}` : "";
2676
- return `Resumed \`${task.id}\`${without}.`;
2736
+ return { ok: true, text: `Resumed \`${task.id}\`${without}.` };
2737
+ }
2738
+ async function deleteTask(d, user, taskId) {
2739
+ const task = await ownLiveTask(d, user, taskId);
2740
+ if (!task)
2741
+ return { ok: false, error: NO_SUCH_TASK };
2742
+ const at = d.clock.now().toISOString();
2743
+ await d.store.deleteQueuedOccurrences(task.id);
2744
+ await d.store.updateTask(task.id, { status: "archived", at });
2745
+ await d.store.addTaskEvent({ taskId: task.id, actorId: user.id, kind: "archived", detail: "deleted by the owner", at });
2746
+ return { ok: true, text: `Deleted \`${task.id}\` ${task.title}.` };
2747
+ }
2748
+
2749
+ // plugins/tracker/src/reminders.ts
2750
+ var REPEATS = ["none", "day", "week", "month"];
2751
+ function reminderSchedule(when, repeat, zone, now) {
2752
+ if (repeat === "none") {
2753
+ if (!when)
2754
+ return 'Say when: for example "in 20 minutes", "tomorrow 9am" or "friday at 17:30".';
2755
+ return { kind: "once", at: when.toISOString() };
2756
+ }
2757
+ const w = wallClock(when ?? now, zone);
2758
+ const start = `${w.year}-${String(w.month).padStart(2, "0")}-${String(w.day).padStart(2, "0")}`;
2759
+ const base = { kind: "calendar", every: 1, unit: repeat, start };
2760
+ if (!when)
2761
+ return base;
2762
+ return {
2763
+ ...base,
2764
+ hour: w.hour,
2765
+ minute: w.minute,
2766
+ ...repeat === "week" ? { weekdays: [w.weekday] } : {},
2767
+ ...repeat === "month" ? { dayOfMonth: w.day } : {}
2768
+ };
2769
+ }
2770
+ function repeatOf(schedule) {
2771
+ if (schedule?.kind !== "calendar")
2772
+ return "none";
2773
+ return schedule.unit;
2774
+ }
2775
+ function reminderPlan(d, user, input, keep) {
2776
+ const text = input.text.trim();
2777
+ if (text.length === 0)
2778
+ return { ok: false, error: "Say what to remind you of." };
2779
+ if (text.length > MAX_REMINDER_TEXT)
2780
+ return { ok: false, error: `That reminder is longer than ${MAX_REMINDER_TEXT} characters.` };
2781
+ if (!REPEATS.includes(input.repeat))
2782
+ return { ok: false, error: "`repeat` is once, daily, weekly or monthly." };
2783
+ const now = d.clock.now();
2784
+ let when = null;
2785
+ if (input.when !== undefined && input.when.trim().length > MAX_WHEN)
2786
+ return { ok: false, error: `\`when\` is longer than ${MAX_WHEN} characters.` };
2787
+ if (input.when !== undefined && input.when.trim() !== "") {
2788
+ const parsed = parseWhen(input.when, now, { zone: user.timeZone, defaultHour: user.preferredHour });
2789
+ if (!parsed.ok)
2790
+ return { ok: false, error: parsed.error };
2791
+ when = parsed.at;
2792
+ }
2793
+ if (when === null && keep && repeatOf(keep) === input.repeat)
2794
+ return { ok: true, text, schedule: keep };
2795
+ const schedule = reminderSchedule(when, input.repeat, user.timeZone, now);
2796
+ if (typeof schedule === "string")
2797
+ return { ok: false, error: schedule };
2798
+ return { ok: true, text, schedule };
2799
+ }
2800
+ async function createReminder(d, user, input) {
2801
+ const type = d.types.reminder;
2802
+ if (!type)
2803
+ return { ok: false, error: "Reminders are not available on this bot." };
2804
+ const plan = reminderPlan(d, user, input);
2805
+ if (!plan.ok)
2806
+ return plan;
2807
+ const capped = await liveTaskCap(d, user);
2808
+ if (capped)
2809
+ return { ok: false, error: capped };
2810
+ const { text, schedule } = plan;
2811
+ const now = d.clock.now();
2812
+ const actor = { userId: user.id, admin: user.admin };
2813
+ const { task, next } = await createTask(d.store, actor, user, { type, title: clip(text, MAX_TITLE), config: { text }, schedule }, now);
2814
+ const at = next ? formatInstant(next.dueAt, user.timeZone, now) : "never (that time has no next run)";
2815
+ const cadence = schedule.kind === "once" ? "" : `, ${describeSchedule(schedule, user, user.timeZone, now)}`;
2816
+ return { ok: true, task, text: clip(`Reminder \`${task.id}\` set: ${text}
2817
+ Next: ${at}${cadence}.`) };
2818
+ }
2819
+ async function remind(d, user, input) {
2820
+ return said(await createReminder(d, user, input));
2677
2821
  }
2678
2822
 
2679
2823
  // plugins/tracker/src/interactions.ts
@@ -3204,86 +3348,33 @@ ${block}
3204
3348
  ${clip(rest, Math.max(3, MAX_ANSWER - head.length - block.length - 2))}`);
3205
3349
  }
3206
3350
 
3207
- // plugins/tracker/src/tracked.ts
3208
- var MAX_TITLE2 = 100;
3209
- var MAX_NOTE = 300;
3210
- var MAX_NEAR = 100;
3351
+ // plugins/tracker/src/price.ts
3211
3352
  var MAX_PRICE_TASKS = 20;
3212
3353
  var DEFAULT_POLL_HOURS = 12;
3213
3354
  var MAX_POLL_HOURS = 168;
3214
- var DEFAULT_LEAD_DAYS = 7;
3215
- var MAX_LEAD_DAYS = 365;
3216
- var MAX_EVERY = 100;
3217
3355
  function plural2(n, word) {
3218
3356
  return `${n} ${word}${n === 1 ? "" : "s"}`;
3219
3357
  }
3220
- async function addRenewal(d, user, input) {
3221
- const type = d.types.renewal;
3222
- if (!type)
3223
- return "Renewals are not available on this bot.";
3224
- const name = input.name.trim();
3225
- if (name.length === 0)
3226
- return "Say what renews: a subscription, a domain, a warranty.";
3227
- if (name.length > MAX_TITLE2)
3228
- return `That name is longer than ${MAX_TITLE2} characters.`;
3229
- if (!Number.isFinite(input.amount) || input.amount < 0)
3230
- return "The amount has to be zero or more.";
3231
- const currency = input.currency.trim().toUpperCase();
3232
- if (!/^[A-Z]{3}$/.test(currency))
3233
- return "The currency is a three-letter code, such as USD or EUR.";
3234
- const note = input.note?.trim() ?? "";
3235
- if (note.length > MAX_NOTE)
3236
- return `The note is longer than ${MAX_NOTE} characters.`;
3237
- const every = input.every ?? 1;
3238
- if (!Number.isInteger(every) || every < 1 || every > MAX_EVERY)
3239
- return `\`every\` is a whole number from 1 to ${MAX_EVERY}.`;
3240
- const lead = input.lead ?? DEFAULT_LEAD_DAYS;
3241
- if (!Number.isInteger(lead) || lead < 0 || lead > MAX_LEAD_DAYS)
3242
- return `\`lead\` is a whole number of days from 0 to ${MAX_LEAD_DAYS}.`;
3243
- const renews = input.renews.trim();
3244
- const now = d.clock.now();
3245
- const days = /^\d{4}-\d{2}-\d{2}$/.test(renews) ? daysUntil(renews, now, user.timeZone) : null;
3246
- if (days === null)
3247
- return "Give the date as YYYY-MM-DD, for example 2026-12-01.";
3248
- if (days < 0)
3249
- return "That date has passed: give the next renewal or expiry date.";
3250
- const schedule = { kind: "period", every, unit: input.unit ?? "year", anchor: renews, leadDays: lead };
3251
- const config = { amount: input.amount, currency, ...note ? { note } : {} };
3252
- const actor = { userId: user.id, admin: user.admin };
3253
- let created;
3254
- try {
3255
- created = await createTask(d.store, actor, user, { type, title: name, config, schedule }, now);
3256
- } catch (err) {
3257
- if (err instanceof ScheduleError)
3258
- return `That schedule does not work: ${err.message}.`;
3259
- throw err;
3260
- }
3261
- const { task } = created;
3262
- let next = created.next;
3263
- const first = nextDue(schedule, new Date(0), { zone: user.timeZone, preferredHour: user.preferredHour });
3264
- if (first && first.getTime() <= now.getTime()) {
3265
- next = await d.store.createOccurrence({
3266
- taskId: task.id,
3267
- lane: task.lane,
3268
- dueAt: first.toISOString(),
3269
- dedupeKey: scheduledKey(task.id, first),
3270
- at: now.toISOString()
3271
- });
3272
- }
3273
- const cadence = describeSchedule(schedule, user, user.timeZone, now);
3274
- const when = days === 0 ? "today" : `in ${plural2(days, "day")}`;
3275
- const ask = !next ? "never (that schedule has no next date)" : Date.parse(next.dueAt) <= now.getTime() ? `in the next minute (it renews ${when})` : formatInstant(next.dueAt, user.timeZone, now);
3276
- return clip([
3277
- `Renewal \`${task.id}\` set: ${name}, ${money(input.amount, currency)}, ${cadence}.`,
3278
- `First ask: ${ask}. Each ask has Keep, Cancel and Renewed buttons; paid a different amount? Answer with \`/task decide\` instead of a button.`
3279
- ].join(`
3280
- `));
3281
- }
3282
3358
  function nearPattern(near) {
3283
3359
  const literal = near.trim().replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
3284
3360
  return `${literal}[^0-9]{0,40}([0-9][0-9.,]{0,15})`;
3285
3361
  }
3286
- async function preview(d, v) {
3362
+ function priceSettingsPlan(input) {
3363
+ const hours = input.hours ?? DEFAULT_POLL_HOURS;
3364
+ if (!Number.isInteger(hours) || hours < 1 || hours > MAX_POLL_HOURS)
3365
+ return { ok: false, error: `\`hours\` is a whole number from 1 to ${MAX_POLL_HOURS}.` };
3366
+ const drop = input.drop ?? DEFAULT_DROP_PERCENT;
3367
+ if (!Number.isFinite(drop) || drop < 1 || drop > 90)
3368
+ return { ok: false, error: "`drop` is a percentage from 1 to 90." };
3369
+ const baseline = input.baseline ?? DEFAULT_BASELINE;
3370
+ if (!BASELINE_RULES.includes(baseline))
3371
+ return { ok: false, error: "`baseline` is first, last or peak." };
3372
+ const name = input.name?.trim() ?? "";
3373
+ if (name.length > MAX_TITLE)
3374
+ return { ok: false, error: `That name is longer than ${MAX_TITLE} characters.` };
3375
+ return { ok: true, name, hours, drop, baseline };
3376
+ }
3377
+ async function previewPrice(d, v) {
3287
3378
  if (!d.fetch)
3288
3379
  return { ok: false, error: "Price tracking is not available on this bot." };
3289
3380
  try {
@@ -3309,65 +3400,165 @@ async function ownedPriceTasks(d, user) {
3309
3400
  const paused = await d.store.listTasks({ ownerId: user.id, status: "paused" });
3310
3401
  return [...active, ...paused].filter((t) => t.type === "price").length;
3311
3402
  }
3403
+ function atCap(d) {
3404
+ const where = d.webEditor ? ", or delete one on the web," : "";
3405
+ return `You already track ${MAX_PRICE_TASKS} prices, the most one person may. Stop one with \`/task done\`${where} first.`;
3406
+ }
3312
3407
  function titleFor(url) {
3313
3408
  const u = new URL(url);
3314
- return clip(`${u.hostname}${u.pathname === "/" ? "" : u.pathname}`, MAX_TITLE2);
3409
+ return clip(`${u.hostname}${u.pathname === "/" ? "" : u.pathname}`, MAX_TITLE);
3315
3410
  }
3316
- async function trackPrice(d, user, input) {
3411
+ async function startPrice(d, user, input) {
3317
3412
  if (!d.types.price || !d.fetch)
3318
- return "Price tracking is not available on this bot.";
3413
+ return { ok: false, error: "Price tracking is not available on this bot." };
3319
3414
  const url = input.url.trim();
3415
+ if (url.length > MAX_URL)
3416
+ return { ok: false, error: `That address is longer than ${MAX_URL} characters.` };
3320
3417
  const problem = urlProblem(url);
3321
3418
  if (problem)
3322
- return problem;
3323
- const hours = input.hours ?? DEFAULT_POLL_HOURS;
3324
- if (!Number.isInteger(hours) || hours < 1 || hours > MAX_POLL_HOURS)
3325
- return `\`hours\` is a whole number from 1 to ${MAX_POLL_HOURS}.`;
3326
- const drop = input.drop ?? DEFAULT_DROP_PERCENT;
3327
- if (!Number.isFinite(drop) || drop < 1 || drop > 90)
3328
- return "`drop` is a percentage from 1 to 90.";
3329
- const baseline = input.baseline ?? DEFAULT_BASELINE;
3330
- if (!BASELINE_RULES.includes(baseline))
3331
- return "`baseline` is first, last or peak.";
3419
+ return { ok: false, error: problem };
3420
+ const settings = priceSettingsPlan(input);
3421
+ if (!settings.ok)
3422
+ return settings;
3332
3423
  const near = input.near?.trim() ?? "";
3333
3424
  if (near.length > MAX_NEAR)
3334
- return `\`near\` is longer than ${MAX_NEAR} characters.`;
3335
- const name = input.name?.trim() ?? "";
3336
- if (name.length > MAX_TITLE2)
3337
- return `That name is longer than ${MAX_TITLE2} characters.`;
3338
- if (await ownedPriceTasks(d, user) >= MAX_PRICE_TASKS) {
3339
- return `You already track ${MAX_PRICE_TASKS} prices, the most one person may. Stop one with \`/task done\` first.`;
3340
- }
3341
- const v = {
3342
- url,
3343
- title: name || titleFor(url),
3344
- hours,
3345
- config: { url, dropPercent: drop, baseline, ...near ? { pattern: nearPattern(near) } : {} }
3346
- };
3425
+ return { ok: false, error: `\`near\` is longer than ${MAX_NEAR} characters.` };
3426
+ if (await ownedPriceTasks(d, user) >= MAX_PRICE_TASKS)
3427
+ return { ok: false, error: atCap(d) };
3428
+ const capped = await liveTaskCap(d, user);
3429
+ if (capped)
3430
+ return { ok: false, error: capped };
3431
+ const { name, hours, drop, baseline } = settings;
3432
+ const config = { url, dropPercent: drop, baseline, ...near ? { pattern: nearPattern(near) } : {} };
3433
+ return { ok: true, pending: { url, title: name || titleFor(url), hours, config } };
3434
+ }
3435
+ async function trackPrice(d, user, input) {
3436
+ const started = await startPrice(d, user, input);
3437
+ if (!started.ok)
3438
+ return started.error;
3439
+ const { pending } = started;
3347
3440
  return {
3348
- outside: () => preview(d, v),
3349
- finish: (result) => finishPrice(d, user, v, result)
3441
+ outside: () => previewPrice(d, pending),
3442
+ finish: async (result) => said(await finishPrice(d, user, pending, result))
3350
3443
  };
3351
3444
  }
3352
3445
  async function finishPrice(d, user, v, seen) {
3353
3446
  if (!seen.ok)
3354
- return seen.error;
3447
+ return seen;
3355
3448
  const type = d.types.price;
3356
3449
  if (!type)
3357
- return "Price tracking is not available on this bot.";
3358
- if (await ownedPriceTasks(d, user) >= MAX_PRICE_TASKS) {
3359
- return `You already track ${MAX_PRICE_TASKS} prices, the most one person may. Stop one with \`/task done\` first.`;
3360
- }
3450
+ return { ok: false, error: "Price tracking is not available on this bot." };
3451
+ if (await ownedPriceTasks(d, user) >= MAX_PRICE_TASKS)
3452
+ return { ok: false, error: atCap(d) };
3453
+ const capped = await liveTaskCap(d, user);
3454
+ if (capped)
3455
+ return { ok: false, error: capped };
3361
3456
  const now = d.clock.now();
3362
3457
  const schedule = { kind: "poll", every: v.hours, unit: "hour", start: now.toISOString() };
3363
3458
  const actor = { userId: user.id, admin: user.admin };
3364
3459
  const { task } = await createTask(d.store, actor, user, { type, title: v.title, config: v.config, schedule }, now);
3365
3460
  const rule = v.config.baseline ?? DEFAULT_BASELINE;
3366
- return clip([
3367
- `Tracking \`${task.id}\`: ${v.title}. I read ${seen.text} just now.`,
3368
- `I check every ${plural2(v.hours, "hour")} and DM you when it drops ${v.config.dropPercent}% or more from the ${rule} price seen; the first check, within a minute, DMs the starting price. \`/task done ${task.id}\` stops it.`
3369
- ].join(`
3370
- `));
3461
+ return {
3462
+ ok: true,
3463
+ task,
3464
+ text: clip([
3465
+ `Tracking \`${task.id}\`: ${v.title}. I read ${seen.text} just now.`,
3466
+ `I check every ${plural2(v.hours, "hour")} and DM you when it drops ${v.config.dropPercent}% or more from the ${rule} price seen; the first check, within a minute, DMs the starting price. \`/task done ${task.id}\` stops it.`
3467
+ ].join(`
3468
+ `))
3469
+ };
3470
+ }
3471
+
3472
+ // plugins/tracker/src/tracked.ts
3473
+ var DEFAULT_LEAD_DAYS = 7;
3474
+ var MAX_LEAD_DAYS = 365;
3475
+ var MAX_EVERY = 100;
3476
+ function plural3(n, word) {
3477
+ return `${n} ${word}${n === 1 ? "" : "s"}`;
3478
+ }
3479
+ var PERIOD_UNITS = ["year", "month", "week", "day"];
3480
+ function renewalPlan(d, user, input) {
3481
+ const bad = (error) => ({ ok: false, error });
3482
+ const name = input.name.trim();
3483
+ if (name.length === 0)
3484
+ return bad("Say what renews: a subscription, a domain, a warranty.");
3485
+ if (name.length > MAX_TITLE)
3486
+ return bad(`That name is longer than ${MAX_TITLE} characters.`);
3487
+ if (!Number.isFinite(input.amount) || input.amount < 0)
3488
+ return bad("The amount has to be zero or more.");
3489
+ const currency = input.currency.trim().toUpperCase();
3490
+ if (!new RegExp(`^[A-Z]{${CURRENCY_LENGTH}}$`).test(currency))
3491
+ return bad("The currency is a three-letter code, such as USD or EUR.");
3492
+ const note = input.note?.trim() ?? "";
3493
+ if (note.length > MAX_NOTE)
3494
+ return bad(`The note is longer than ${MAX_NOTE} characters.`);
3495
+ const unit = input.unit ?? "year";
3496
+ if (!PERIOD_UNITS.includes(unit))
3497
+ return bad("`unit` is yearly, monthly, weekly or daily.");
3498
+ const every = input.every ?? 1;
3499
+ if (!Number.isInteger(every) || every < 1 || every > MAX_EVERY)
3500
+ return bad(`\`every\` is a whole number from 1 to ${MAX_EVERY}.`);
3501
+ const lead = input.lead ?? DEFAULT_LEAD_DAYS;
3502
+ if (!Number.isInteger(lead) || lead < 0 || lead > MAX_LEAD_DAYS)
3503
+ return bad(`\`lead\` is a whole number of days from 0 to ${MAX_LEAD_DAYS}.`);
3504
+ const renews = input.renews.trim();
3505
+ const days = renews.length === DATE_LENGTH && /^\d{4}-\d{2}-\d{2}$/.test(renews) ? daysUntil(renews, d.clock.now(), user.timeZone) : null;
3506
+ if (days === null)
3507
+ return bad("Give the date as YYYY-MM-DD, for example 2026-12-01.");
3508
+ if (days < 0)
3509
+ return bad("That date has passed: give the next renewal or expiry date.");
3510
+ return { ok: true, name, amount: input.amount, currency, note, days, schedule: { kind: "period", every, unit, anchor: renews, leadDays: lead } };
3511
+ }
3512
+ async function firstAskIfDue(d, task, owner, schedule) {
3513
+ const now = d.clock.now();
3514
+ const first = nextDue(schedule, new Date(0), { zone: owner.timeZone, preferredHour: owner.preferredHour });
3515
+ if (!first || first.getTime() > now.getTime())
3516
+ return null;
3517
+ return d.store.createOccurrence({ taskId: task.id, lane: task.lane, dueAt: first.toISOString(), dedupeKey: scheduledKey(task.id, first), at: now.toISOString() });
3518
+ }
3519
+ function askText(next, days, user, now) {
3520
+ const when = days === 0 ? "today" : `in ${plural3(days, "day")}`;
3521
+ if (!next)
3522
+ return "never (that schedule has no next date)";
3523
+ return Date.parse(next.dueAt) <= now.getTime() ? `in the next minute (it renews ${when})` : formatInstant(next.dueAt, user.timeZone, now);
3524
+ }
3525
+ async function createRenewal(d, user, input) {
3526
+ const type = d.types.renewal;
3527
+ if (!type)
3528
+ return { ok: false, error: "Renewals are not available on this bot." };
3529
+ const plan = renewalPlan(d, user, input);
3530
+ if (!plan.ok)
3531
+ return plan;
3532
+ const capped = await liveTaskCap(d, user);
3533
+ if (capped)
3534
+ return { ok: false, error: capped };
3535
+ const { name, currency, note, schedule } = plan;
3536
+ const now = d.clock.now();
3537
+ const config = { amount: plan.amount, currency, ...note ? { note } : {} };
3538
+ const actor = { userId: user.id, admin: user.admin };
3539
+ let created;
3540
+ try {
3541
+ created = await createTask(d.store, actor, user, { type, title: name, config, schedule }, now);
3542
+ } catch (err) {
3543
+ if (err instanceof ScheduleError)
3544
+ return { ok: false, error: `That schedule does not work: ${err.message}.` };
3545
+ throw err;
3546
+ }
3547
+ const { task } = created;
3548
+ const next = await firstAskIfDue(d, task, user, schedule) ?? created.next;
3549
+ const cadence = describeSchedule(schedule, user, user.timeZone, now);
3550
+ return {
3551
+ ok: true,
3552
+ task,
3553
+ text: clip([
3554
+ `Renewal \`${task.id}\` set: ${name}, ${money(plan.amount, currency)}, ${cadence}.`,
3555
+ `First ask: ${askText(next, plan.days, user, now)}. Each ask has Keep, Cancel and Renewed buttons; paid a different amount? Answer with \`/task decide\` instead of a button.`
3556
+ ].join(`
3557
+ `))
3558
+ };
3559
+ }
3560
+ async function addRenewal(d, user, input) {
3561
+ return said(await createRenewal(d, user, input));
3371
3562
  }
3372
3563
  async function decideRenewal(d, user, input) {
3373
3564
  const task = await visibleTask(d.store, { userId: user.id, admin: false }, input.taskId.trim());
@@ -3517,7 +3708,7 @@ ${body}` : body);
3517
3708
  },
3518
3709
  {
3519
3710
  name: "register",
3520
- build: (b) => b.setDescription("Sign up for reminders, or change your hour and time zone").addIntegerOption((o) => o.setName("hour").setDescription("Hour (0-23) a reminder without a time arrives; default 9").setMinValue(0).setMaxValue(23)).addStringOption((o) => o.setName("zone").setDescription("Your time zone, e.g. America/New_York (the default)").setMaxLength(64)),
3711
+ build: (b) => b.setDescription("Sign up for reminders, or change your hour and time zone").addIntegerOption((o) => o.setName("hour").setDescription("Hour (0-23) a reminder without a time arrives; default 9").setMinValue(0).setMaxValue(23)).addStringOption((o) => o.setName("zone").setDescription("Your time zone, e.g. America/New_York (the default)").setMaxLength(MAX_ZONE)),
3521
3712
  handle: (interaction) => run(interaction, "admitted", (d, user) => {
3522
3713
  const hour = interaction.options.getInteger("hour");
3523
3714
  const zone = interaction.options.getString("zone");
@@ -3530,7 +3721,7 @@ ${body}` : body);
3530
3721
  },
3531
3722
  {
3532
3723
  name: "remind",
3533
- build: (b) => b.setDescription("Set a reminder, sent to you by DM").addStringOption((o) => o.setName("text").setDescription("What to remind you of").setRequired(true).setMaxLength(MAX_REMINDER_TEXT)).addStringOption((o) => o.setName("when").setDescription('When: "in 20 minutes", "tomorrow 9am", "fri at 17:30"').setMaxLength(100)).addStringOption((o) => o.setName("repeat").setDescription("Repeat it (default: once)").addChoices({ name: "once", value: "none" }, { name: "daily", value: "day" }, { name: "weekly", value: "week" }, { name: "monthly", value: "month" })),
3724
+ build: (b) => b.setDescription("Set a reminder, sent to you by DM").addStringOption((o) => o.setName("text").setDescription("What to remind you of").setRequired(true).setMaxLength(MAX_REMINDER_TEXT)).addStringOption((o) => o.setName("when").setDescription('When: "in 20 minutes", "tomorrow 9am", "fri at 17:30"').setMaxLength(MAX_WHEN)).addStringOption((o) => o.setName("repeat").setDescription("Repeat it (default: once)").addChoices({ name: "once", value: "none" }, { name: "daily", value: "day" }, { name: "weekly", value: "week" }, { name: "monthly", value: "month" })),
3534
3725
  handle: (interaction) => run(interaction, "registered", (d, user) => {
3535
3726
  const when = interaction.options.getString("when");
3536
3727
  return remind(d, user, {
@@ -3542,7 +3733,7 @@ ${body}` : body);
3542
3733
  },
3543
3734
  {
3544
3735
  name: "renewal",
3545
- build: (b) => b.setDescription("Track a subscription, domain or warranty: I ask before each renewal").addStringOption((o) => o.setName("name").setDescription("What renews, e.g. Netflix").setRequired(true).setMaxLength(100)).addNumberOption((o) => o.setName("amount").setDescription("What one period costs").setRequired(true).setMinValue(0)).addStringOption((o) => o.setName("currency").setDescription("Currency code, e.g. USD").setRequired(true).setMinLength(3).setMaxLength(3)).addStringOption((o) => o.setName("renews").setDescription("The next renewal or expiry date, YYYY-MM-DD").setRequired(true).setMaxLength(10)).addStringOption((o) => o.setName("unit").setDescription("How it renews (default: yearly)").addChoices({ name: "yearly", value: "year" }, { name: "monthly", value: "month" }, { name: "weekly", value: "week" }, { name: "daily", value: "day" })).addIntegerOption((o) => o.setName("every").setDescription("Every how many of those, e.g. 2 for every two years (default 1)").setMinValue(1).setMaxValue(MAX_EVERY)).addIntegerOption((o) => o.setName("lead").setDescription(`Days before the date to ask (default ${DEFAULT_LEAD_DAYS})`).setMinValue(0).setMaxValue(MAX_LEAD_DAYS)).addStringOption((o) => o.setName("note").setDescription("Carried on every ask: where to cancel, which account").setMaxLength(MAX_NOTE)),
3736
+ build: (b) => b.setDescription("Track a subscription, domain or warranty: I ask before each renewal").addStringOption((o) => o.setName("name").setDescription("What renews, e.g. Netflix").setRequired(true).setMaxLength(MAX_TITLE)).addNumberOption((o) => o.setName("amount").setDescription("What one period costs").setRequired(true).setMinValue(0)).addStringOption((o) => o.setName("currency").setDescription("Currency code, e.g. USD").setRequired(true).setMinLength(CURRENCY_LENGTH).setMaxLength(CURRENCY_LENGTH)).addStringOption((o) => o.setName("renews").setDescription("The next renewal or expiry date, YYYY-MM-DD").setRequired(true).setMaxLength(DATE_LENGTH)).addStringOption((o) => o.setName("unit").setDescription("How it renews (default: yearly)").addChoices({ name: "yearly", value: "year" }, { name: "monthly", value: "month" }, { name: "weekly", value: "week" }, { name: "daily", value: "day" })).addIntegerOption((o) => o.setName("every").setDescription("Every how many of those, e.g. 2 for every two years (default 1)").setMinValue(1).setMaxValue(MAX_EVERY)).addIntegerOption((o) => o.setName("lead").setDescription(`Days before the date to ask (default ${DEFAULT_LEAD_DAYS})`).setMinValue(0).setMaxValue(MAX_LEAD_DAYS)).addStringOption((o) => o.setName("note").setDescription("Carried on every ask: where to cancel, which account").setMaxLength(MAX_NOTE)),
3546
3737
  handle: (interaction) => run(interaction, "registered", (d, user) => {
3547
3738
  const every = interaction.options.getInteger("every");
3548
3739
  const lead = interaction.options.getInteger("lead");
@@ -3562,7 +3753,7 @@ ${body}` : body);
3562
3753
  },
3563
3754
  {
3564
3755
  name: "price",
3565
- build: (b) => b.setDescription("Track a product's price: I DM you when it drops").addStringOption((o) => o.setName("url").setDescription("The product page").setRequired(true).setMaxLength(1000)).addStringOption((o) => o.setName("name").setDescription("What to call it (default: the page address)").setMaxLength(100)).addIntegerOption((o) => o.setName("hours").setDescription(`Hours between checks (default ${DEFAULT_POLL_HOURS})`).setMinValue(1).setMaxValue(MAX_POLL_HOURS)).addNumberOption((o) => o.setName("drop").setDescription("Alert on a drop of at least this percent (default 10)").setMinValue(1).setMaxValue(90)).addStringOption((o) => o.setName("baseline").setDescription("Measure the drop from which price seen (default: the last)").addChoices({ name: "the last", value: "last" }, { name: "the first", value: "first" }, { name: "the highest", value: "peak" })).addStringOption((o) => o.setName("near").setDescription("The words just before the price, if I cannot find it on my own").setMaxLength(MAX_NEAR)),
3756
+ build: (b) => b.setDescription("Track a product's price: I DM you when it drops").addStringOption((o) => o.setName("url").setDescription("The product page").setRequired(true).setMaxLength(MAX_URL)).addStringOption((o) => o.setName("name").setDescription("What to call it (default: the page address)").setMaxLength(MAX_TITLE)).addIntegerOption((o) => o.setName("hours").setDescription(`Hours between checks (default ${DEFAULT_POLL_HOURS})`).setMinValue(1).setMaxValue(MAX_POLL_HOURS)).addNumberOption((o) => o.setName("drop").setDescription("Alert on a drop of at least this percent (default 10)").setMinValue(1).setMaxValue(90)).addStringOption((o) => o.setName("baseline").setDescription("Measure the drop from which price seen (default: the last)").addChoices({ name: "the last", value: "last" }, { name: "the first", value: "first" }, { name: "the highest", value: "peak" })).addStringOption((o) => o.setName("near").setDescription("The words just before the price, if I cannot find it on my own").setMaxLength(MAX_NEAR)),
3566
3757
  handle: (interaction) => run(interaction, "registered", (d, user) => {
3567
3758
  const name = interaction.options.getString("name");
3568
3759
  const hours = interaction.options.getInteger("hours");
@@ -3586,7 +3777,7 @@ ${body}` : body);
3586
3777
  },
3587
3778
  {
3588
3779
  name: "task",
3589
- build: (b) => b.setDescription("Answer, look back at, or share one of your tasks").addSubcommand((s) => s.setName("done").setDescription("Mark the task's latest reminder done").addStringOption((o) => o.setName("task").setDescription("The task id from /tasks, e.g. t3").setRequired(true).setMaxLength(24))).addSubcommand((s) => s.setName("snooze").setDescription("Snooze the task's latest reminder (default: an hour)").addStringOption((o) => o.setName("task").setDescription("The task id from /tasks, e.g. t3").setRequired(true).setMaxLength(24)).addStringOption((o) => o.setName("until").setDescription('Until when: "in 2h", "tomorrow 9am"').setMaxLength(100))).addSubcommand((s) => s.setName("decide").setDescription("Answer a renewal's latest ask, with what you paid if it changed").addStringOption((o) => o.setName("task").setDescription("The task id from /tasks, e.g. t3").setRequired(true).setMaxLength(24)).addStringOption((o) => o.setName("choice").setDescription("Keep it, cancel it, or it has renewed").setRequired(true).addChoices({ name: "keep", value: "keep" }, { name: "cancel", value: "cancel" }, { name: "renewed", value: "renewed" })).addNumberOption((o) => o.setName("amount").setDescription("What you paid this time, if not the usual").setMinValue(0))).addSubcommand((s) => s.setName("history").setDescription("Every run of the task, how it was answered, and its changes").addStringOption((o) => o.setName("task").setDescription("The task id from /tasks, e.g. t3").setRequired(true).setMaxLength(24))).addSubcommand((s) => s.setName("resume").setDescription("Resume a task paused because someone could not be DMed, without them").addStringOption((o) => o.setName("task").setDescription("The task id from /tasks, e.g. t3").setRequired(true).setMaxLength(24))).addSubcommand((s) => s.setName("share").setDescription("Ask someone to receive this task's messages too").addStringOption((o) => o.setName("task").setDescription("The task id from /tasks, e.g. t3").setRequired(true).setMaxLength(24)).addUserOption((o) => o.setName("user").setDescription("Who to invite").setRequired(true))),
3780
+ build: (b) => b.setDescription("Answer, look back at, or share one of your tasks").addSubcommand((s) => s.setName("done").setDescription("Mark the task's latest reminder done").addStringOption((o) => o.setName("task").setDescription("The task id from /tasks, e.g. t3").setRequired(true).setMaxLength(MAX_TASK_ID))).addSubcommand((s) => s.setName("snooze").setDescription("Snooze the task's latest reminder (default: an hour)").addStringOption((o) => o.setName("task").setDescription("The task id from /tasks, e.g. t3").setRequired(true).setMaxLength(MAX_TASK_ID)).addStringOption((o) => o.setName("until").setDescription('Until when: "in 2h", "tomorrow 9am"').setMaxLength(MAX_WHEN))).addSubcommand((s) => s.setName("decide").setDescription("Answer a renewal's latest ask, with what you paid if it changed").addStringOption((o) => o.setName("task").setDescription("The task id from /tasks, e.g. t3").setRequired(true).setMaxLength(MAX_TASK_ID)).addStringOption((o) => o.setName("choice").setDescription("Keep it, cancel it, or it has renewed").setRequired(true).addChoices({ name: "keep", value: "keep" }, { name: "cancel", value: "cancel" }, { name: "renewed", value: "renewed" })).addNumberOption((o) => o.setName("amount").setDescription("What you paid this time, if not the usual").setMinValue(0))).addSubcommand((s) => s.setName("history").setDescription("Every run of the task, how it was answered, and its changes").addStringOption((o) => o.setName("task").setDescription("The task id from /tasks, e.g. t3").setRequired(true).setMaxLength(MAX_TASK_ID))).addSubcommand((s) => s.setName("resume").setDescription("Resume a paused task; one paused for failed DMs goes on without them").addStringOption((o) => o.setName("task").setDescription("The task id from /tasks, e.g. t3").setRequired(true).setMaxLength(MAX_TASK_ID))).addSubcommand((s) => s.setName("share").setDescription("Ask someone to receive this task's messages too").addStringOption((o) => o.setName("task").setDescription("The task id from /tasks, e.g. t3").setRequired(true).setMaxLength(MAX_TASK_ID)).addUserOption((o) => o.setName("user").setDescription("Who to invite").setRequired(true))),
3590
3781
  handle: (interaction) => {
3591
3782
  const sub = interaction.options.getSubcommand();
3592
3783
  const taskId = interaction.options.getString("task", true);
@@ -3610,7 +3801,7 @@ ${body}` : body);
3610
3801
  case "history":
3611
3802
  return historyWithSeries(d, user, taskId);
3612
3803
  case "resume":
3613
- return resumeTask(d, user, taskId);
3804
+ return said(await resumeTask(d, user, taskId));
3614
3805
  case "share": {
3615
3806
  const pending = await prepareShare(d, user, taskId, { discordId: shareWith?.id ?? "", membership: membership ?? "unknown" });
3616
3807
  if (typeof pending === "string")
@@ -4237,6 +4428,162 @@ class SqliteStore {
4237
4428
  }
4238
4429
  }
4239
4430
 
4431
+ // plugins/tracker/src/edit.ts
4432
+ async function owned(d, user, taskId, type) {
4433
+ const task = await ownLiveTask(d, user, taskId);
4434
+ if (!task || task.type !== type)
4435
+ return NO_SUCH_TASK;
4436
+ if (!editable(task))
4437
+ return FINISHED;
4438
+ return task;
4439
+ }
4440
+ function same(a, b) {
4441
+ return JSON.stringify(a) === JSON.stringify(b);
4442
+ }
4443
+ async function applyEdit(d, task, user, patch) {
4444
+ const fields = Object.keys(patch);
4445
+ if (fields.length === 0)
4446
+ return task;
4447
+ const at = d.clock.now().toISOString();
4448
+ const updated = await d.store.updateTask(task.id, { ...patch, at });
4449
+ await d.store.addTaskEvent({ taskId: task.id, actorId: user.id, kind: "edited", detail: fields.join(", "), at });
4450
+ return updated;
4451
+ }
4452
+ async function nextRun(d, task) {
4453
+ const queued = await d.store.listOccurrences({ taskId: task.id, status: "queued" });
4454
+ return queued[0] ?? null;
4455
+ }
4456
+ function savedText(task, user, next, now, what) {
4457
+ const cadence = task.schedule ? describeSchedule(task.schedule, user, user.timeZone, now) : "no schedule";
4458
+ const when = task.status === "paused" ? "paused" : next ? formatInstant(next.dueAt, user.timeZone, now) : "nothing scheduled";
4459
+ return clip(`Saved \`${task.id}\` ${what}: ${cadence}. Next: ${when}.`);
4460
+ }
4461
+ var blank = (v) => v === undefined || v.trim() === "";
4462
+ async function editReminder(d, user, taskId, input) {
4463
+ const task = await owned(d, user, taskId, "reminder");
4464
+ if (typeof task === "string")
4465
+ return { ok: false, error: task };
4466
+ const config = task.config ?? {};
4467
+ const text = blank(input.text) ? String(config.text ?? "") : input.text;
4468
+ const repeat = input.repeat ?? repeatOf(task.schedule);
4469
+ const plan = reminderPlan(d, user, { text, repeat, ...input.when !== undefined ? { when: input.when } : {} }, task.schedule);
4470
+ if (!plan.ok)
4471
+ return plan;
4472
+ let updated = config.text === plan.text ? task : await applyEdit(d, task, user, { title: clip(plan.text, MAX_TITLE), config: { ...config, text: plan.text } });
4473
+ if (!same(plan.schedule, task.schedule))
4474
+ updated = await rescheduleKeepingSnoozes(d, updated, user, plan.schedule, user.id);
4475
+ return { ok: true, task: updated, text: savedText(updated, user, await nextRun(d, updated), d.clock.now(), "(reminder)") };
4476
+ }
4477
+ var DAY_MS3 = 24 * 60 * 60 * 1000;
4478
+ function periodDateFrom(s, date, owner) {
4479
+ const ctx = { zone: owner.timeZone, preferredHour: owner.preferredHour };
4480
+ let after = new Date(Date.parse(`${date}T00:00:00Z`) - ((s.leadDays ?? 0) + 2) * DAY_MS3);
4481
+ for (let i = 0;i < 1000; i++) {
4482
+ const due = nextDue(s, after, ctx);
4483
+ if (!due)
4484
+ return null;
4485
+ const on = periodDate(s, due, owner.timeZone);
4486
+ if (on >= date)
4487
+ return on;
4488
+ after = due;
4489
+ }
4490
+ return null;
4491
+ }
4492
+ function renewalDate(task, owner, now) {
4493
+ const s = task.schedule;
4494
+ if (s?.kind !== "period")
4495
+ return "";
4496
+ const w = wallClock(now, owner.timeZone);
4497
+ const today = `${w.year}-${String(w.month).padStart(2, "0")}-${String(w.day).padStart(2, "0")}`;
4498
+ if (s.anchor >= today)
4499
+ return s.anchor;
4500
+ return periodDateFrom(s, today, owner) ?? s.anchor;
4501
+ }
4502
+ async function askedAbout(d, task, owner, date) {
4503
+ const old = task.schedule;
4504
+ if (old?.kind !== "period")
4505
+ return false;
4506
+ const fired = (await d.store.listOccurrences({ taskId: task.id })).filter((o) => o.status !== "queued" && !o.dedupeKey.startsWith(SNOOZE_PREFIX));
4507
+ return fired.some((o) => periodDate(old, new Date(o.dueAt), owner.timeZone) === date);
4508
+ }
4509
+ async function editRenewal(d, user, taskId, input) {
4510
+ const task = await owned(d, user, taskId, "renewal");
4511
+ if (typeof task === "string")
4512
+ return { ok: false, error: task };
4513
+ if (task.schedule?.kind !== "period")
4514
+ return { ok: false, error: FINISHED };
4515
+ const old = task.schedule;
4516
+ const now = d.clock.now();
4517
+ const config = task.config;
4518
+ const state = task.state;
4519
+ const carried = state && typeof state.amount === "number" ? state.amount : null;
4520
+ const note = input.note ?? config.note;
4521
+ const plan = renewalPlan(d, user, {
4522
+ name: blank(input.name) ? task.title : input.name,
4523
+ amount: input.amount ?? carried ?? config.amount,
4524
+ currency: blank(input.currency) ? config.currency : input.currency,
4525
+ renews: blank(input.renews) ? renewalDate(task, user, now) : input.renews,
4526
+ every: input.every ?? old.every,
4527
+ unit: input.unit ?? old.unit,
4528
+ lead: input.lead ?? old.leadDays ?? 0,
4529
+ ...note !== undefined ? { note } : {}
4530
+ });
4531
+ if (!plan.ok)
4532
+ return plan;
4533
+ const sameShape = plan.schedule.every === old.every && plan.schedule.unit === old.unit && plan.schedule.leadDays === (old.leadDays ?? 0);
4534
+ const schedule = sameShape && periodDateFrom(old, plan.schedule.anchor, user) === plan.schedule.anchor ? old : plan.schedule;
4535
+ const problems = scheduleProblems(schedule);
4536
+ if (problems.length > 0)
4537
+ return { ok: false, error: `That schedule does not work: ${problems.join("; ")}.` };
4538
+ const patch = {};
4539
+ if (plan.name !== task.title)
4540
+ patch.title = plan.name;
4541
+ const newAmount = plan.amount !== (carried ?? config.amount);
4542
+ const nextConfig = { amount: newAmount ? plan.amount : config.amount, currency: plan.currency, ...plan.note ? { note: plan.note } : {} };
4543
+ if (!same(nextConfig, config))
4544
+ patch.config = nextConfig;
4545
+ if (newAmount && carried !== null)
4546
+ patch.state = { ...state, amount: plan.amount };
4547
+ let updated = await applyEdit(d, task, user, patch);
4548
+ let next = await nextRun(d, updated);
4549
+ if (!same(schedule, old)) {
4550
+ const asked = await askedAbout(d, task, user, schedule.anchor);
4551
+ updated = await rescheduleKeepingSnoozes(d, updated, user, schedule, user.id);
4552
+ next = (asked ? null : await firstAskIfDue(d, updated, user, schedule)) ?? await nextRun(d, updated);
4553
+ }
4554
+ const ask = updated.status === "paused" ? "paused" : askText(next, plan.days, user, now);
4555
+ const cadence = describeSchedule(schedule, user, user.timeZone, now);
4556
+ return { ok: true, task: updated, text: clip(`Saved \`${updated.id}\` (renewal): ${cadence}. Next ask: ${ask}.`) };
4557
+ }
4558
+ async function editPrice(d, user, taskId, input) {
4559
+ const task = await owned(d, user, taskId, "price");
4560
+ if (typeof task === "string")
4561
+ return { ok: false, error: task };
4562
+ const config = task.config;
4563
+ const schedule = task.schedule;
4564
+ const plan = priceSettingsPlan({
4565
+ name: input.name ?? task.title,
4566
+ hours: input.hours ?? (schedule?.kind === "poll" ? schedule.every : undefined),
4567
+ drop: input.drop ?? config.dropPercent ?? DEFAULT_DROP_PERCENT,
4568
+ baseline: input.baseline ?? config.baseline ?? DEFAULT_BASELINE
4569
+ });
4570
+ if (!plan.ok)
4571
+ return plan;
4572
+ const patch = {};
4573
+ const title = plan.name || titleFor(config.url);
4574
+ if (title !== task.title)
4575
+ patch.title = title;
4576
+ const nextConfig = { ...config, dropPercent: plan.drop, baseline: plan.baseline };
4577
+ if (!same(nextConfig, config))
4578
+ patch.config = nextConfig;
4579
+ let updated = await applyEdit(d, task, user, patch);
4580
+ if (schedule?.kind === "poll" && (schedule.every !== plan.hours || schedule.unit !== "hour")) {
4581
+ const next = { ...schedule, every: plan.hours, unit: "hour" };
4582
+ updated = await rescheduleKeepingSnoozes(d, updated, user, next, user.id);
4583
+ }
4584
+ return { ok: true, task: updated, text: savedText(updated, user, await nextRun(d, updated), d.clock.now(), "(price)") };
4585
+ }
4586
+
4240
4587
  // node_modules/@rackbops/styles/rackbops-noir/bundle.css
4241
4588
  var bundle_default = `:where([data-rb-style], [data-rb-style] *), :where([data-rb-style], [data-rb-style] *):before, :where([data-rb-style], [data-rb-style] *):after {
4242
4589
  box-sizing: border-box;
@@ -5405,6 +5752,8 @@ var LAYOUT_CSS = `
5405
5752
  .tr-main { max-width: 60rem; margin: 0 auto; padding: var(--rb-space-4); }
5406
5753
  .tr-main > section { margin-bottom: var(--rb-space-5); }
5407
5754
  .tr-stack { display: grid; gap: var(--rb-space-3); max-width: 28rem; }
5755
+ .tr-row { display: flex; flex-wrap: wrap; align-items: center; gap: var(--rb-space-2); }
5756
+ .tr-row form { margin: 0; }
5408
5757
  .tr-foot { margin-top: var(--rb-space-5); }
5409
5758
  `;
5410
5759
  var STYLESHEET = `${THEME_CSS}
@@ -5506,6 +5855,28 @@ function readCookie(request, name) {
5506
5855
  function cookie(name, value, o) {
5507
5856
  return `${name}=${value}; Path=${o.base}/; Max-Age=${o.maxAgeSeconds}; HttpOnly; Secure; SameSite=${o.sameSite}`;
5508
5857
  }
5858
+ async function readBody(request, max) {
5859
+ const declared = request.headers.get("content-length");
5860
+ if (declared !== null && !(/^[0-9]{1,12}$/.test(declared) && Number(declared) <= max))
5861
+ return null;
5862
+ if (!request.body)
5863
+ return "";
5864
+ const reader = request.body.getReader();
5865
+ const chunks = [];
5866
+ let size = 0;
5867
+ for (;; ) {
5868
+ const { done, value } = await reader.read();
5869
+ if (done)
5870
+ break;
5871
+ size += value.byteLength;
5872
+ if (size > max) {
5873
+ await reader.cancel().catch(() => {});
5874
+ return null;
5875
+ }
5876
+ chunks.push(value);
5877
+ }
5878
+ return Buffer.concat(chunks).toString("utf8");
5879
+ }
5509
5880
 
5510
5881
  // plugins/tracker/src/web/pages.ts
5511
5882
  function name3(u) {
@@ -5551,30 +5922,39 @@ function cadence(entry, viewer, now) {
5551
5922
  return "once";
5552
5923
  return describeSchedule(s, entry.from ?? viewer, viewer.timeZone, now);
5553
5924
  }
5554
- function tasksPage(v, data, now) {
5925
+ function newLinks(v) {
5926
+ return html`<p class="tr-row">
5927
+ <a class="rb-btn rb-btn--primary rb-btn--sm" href="${v.base}/new/reminder">New reminder</a>
5928
+ <a class="rb-btn rb-btn--ghost rb-btn--sm" href="${v.base}/new/renewal">New renewal</a>
5929
+ <a class="rb-btn rb-btn--ghost rb-btn--sm" href="${v.base}/new/price">New price tracker</a>
5930
+ </p>`;
5931
+ }
5932
+ function tasksPage(v, data, now, flash = null) {
5555
5933
  const rows = data.entries.map((e) => html`<tr>
5556
5934
  <td><a class="rb-link" href="${v.base}/tasks/${e.task.id}">${e.task.title}</a></td>
5557
5935
  <td>${e.from ? name3(e.from) : "you"}</td>
5558
5936
  <td>${when2(e, v.user, now)}</td>
5559
5937
  <td>${cadence(e, v.user, now)}</td>
5560
5938
  </tr>`);
5561
- const active = data.entries.length === 0 ? html`<p class="rb-muted">You have no active tasks. <code>/remind</code> in Discord sets one.</p>` : html`<div class="rb-table-scroll"><table class="rb-table">
5939
+ const active = data.entries.length === 0 ? html`<p class="rb-muted">You have no active tasks.</p>` : html`<div class="rb-table-scroll"><table class="rb-table">
5562
5940
  <thead><tr><th scope="col">Task</th><th scope="col">From</th><th scope="col">Next</th><th scope="col">Repeats</th></tr></thead>
5563
5941
  <tbody>${rows}</tbody>
5564
5942
  </table></div>`;
5565
5943
  const paused = data.paused.length === 0 ? null : html`<section>
5566
5944
  <h2>Paused</h2>
5567
- <ul>${data.paused.map((p) => html`<li><a class="rb-link" href="${v.base}/tasks/${p.task.id}">${p.task.title}</a>${p.held.length > 0 ? html` -- I could not DM ${p.held.join(", ")}; <code>/task resume ${p.task.id}</code> goes on without them.` : null}</li>`)}</ul>
5945
+ <ul>${data.paused.map((p) => html`<li><a class="rb-link" href="${v.base}/tasks/${p.task.id}">${p.task.title}</a>${p.held.length > 0 ? html` -- I could not DM ${p.held.join(", ")}; Resume on its page goes on without them.` : null}</li>`)}</ul>
5568
5946
  </section>`;
5569
5947
  return framed(v, "My tasks", html`<section>
5570
5948
  <h1>My tasks</h1>
5949
+ ${flash}
5571
5950
  <p class="rb-muted">Times are in ${v.user.timeZone}.</p>
5951
+ ${newLinks(v)}
5572
5952
  ${active}
5573
5953
  </section>
5574
5954
  ${paused}
5575
5955
  <p class="rb-muted tr-foot">${ADMIN_DISCLOSURE}</p>`);
5576
5956
  }
5577
- function historyPage(v, h) {
5957
+ function historyPage(v, h, extra = {}) {
5578
5958
  const runs = h.runs.length === 0 ? html`<p class="rb-muted">No runs yet.</p>` : html`<div class="rb-table-scroll"><table class="rb-table">
5579
5959
  <thead><tr><th scope="col">Due</th><th scope="col">Status</th><th scope="col">Answers</th></tr></thead>
5580
5960
  <tbody>${h.runs.map((r) => html`<tr>
@@ -5586,7 +5966,9 @@ function historyPage(v, h) {
5586
5966
  return framed(v, h.task.title, html`<section>
5587
5967
  <p><a class="rb-link" href="${v.base}/">My tasks</a></p>
5588
5968
  <h1>${h.task.title}</h1>
5589
- <p>${h.task.type}, ${h.task.status}, ${h.cadence}. Next: ${h.next ?? "nothing scheduled"}.</p>
5969
+ ${extra.flash ?? null}
5970
+ <p>${h.task.type}, ${h.task.status === "archived" ? "deleted" : h.task.status}, ${h.cadence}. Next: ${h.task.status === "active" ? h.next ?? "nothing scheduled" : "nothing, while it is not active"}.</p>
5971
+ ${extra.controls ?? null}
5590
5972
  </section>
5591
5973
  <section>
5592
5974
  <h2>Runs</h2>
@@ -5614,7 +5996,7 @@ ${form.error ? html`<div class="rb-alert rb-alert--danger" role="alert"><p class
5614
5996
  </div>
5615
5997
  <div class="rb-field">
5616
5998
  <label class="rb-label" for="zone">Time zone</label>
5617
- <input class="rb-input" id="zone" name="zone" value="${form.zone}" maxlength="64" required${form.error ? html` aria-invalid="true"` : null}>
5999
+ <input class="rb-input" id="zone" name="zone" value="${form.zone}" maxlength="${MAX_ZONE}" required${form.error ? html` aria-invalid="true"` : null}>
5618
6000
  <p class="rb-field__help">An IANA name, such as America/New_York or Europe/London.</p>
5619
6001
  </div>
5620
6002
  <div><button class="rb-btn rb-btn--primary" type="submit">Save</button></div>
@@ -5629,6 +6011,390 @@ function errorPage(base, title, message) {
5629
6011
  return page({ base, title, body: html`<section class="rb-card"><h1>${title}</h1><p>${message}</p></section>` });
5630
6012
  }
5631
6013
 
6014
+ // plugins/tracker/src/web/editor-pages.ts
6015
+ var EDITOR_TYPES = ["reminder", "renewal", "price"];
6016
+ var REPEAT_OPTIONS = [["none", "once"], ["day", "daily"], ["week", "weekly"], ["month", "monthly"]];
6017
+ var UNIT_OPTIONS = [["year", "yearly"], ["month", "monthly"], ["week", "weekly"], ["day", "daily"]];
6018
+ var BASELINE_OPTIONS = [["last", "the last price seen"], ["first", "the first price seen"], ["peak", "the highest price seen"]];
6019
+ var PRICE_SETTINGS = [
6020
+ { name: "name", label: "Name", kind: "text", maxlength: MAX_TITLE, help: "Leave it empty to use the page's address." },
6021
+ { name: "hours", label: "Hours between checks", kind: "number", min: 1, max: MAX_POLL_HOURS, help: `1 to ${MAX_POLL_HOURS}; 12 when empty.` },
6022
+ { name: "drop", label: "Alert on a drop of at least (percent)", kind: "text", decimal: true, help: "1 to 90; 10 when empty." },
6023
+ { name: "baseline", label: "Measure the drop from", kind: "select", options: BASELINE_OPTIONS }
6024
+ ];
6025
+ function fieldsFor(type, mode) {
6026
+ switch (type) {
6027
+ case "reminder":
6028
+ return [
6029
+ { name: "text", label: "Remind me to", kind: "text", required: true, maxlength: MAX_REMINDER_TEXT },
6030
+ {
6031
+ name: "when",
6032
+ label: "When",
6033
+ kind: "text",
6034
+ maxlength: MAX_WHEN,
6035
+ help: mode === "new" ? 'For example "in 20 minutes", "tomorrow 9am", "fri at 17:30". A repeating one with none starts today at your preferred hour.' : "Leave it empty to keep the time it has, unless you change how it repeats."
6036
+ },
6037
+ { name: "repeat", label: "Repeat", kind: "select", options: REPEAT_OPTIONS }
6038
+ ];
6039
+ case "renewal":
6040
+ return [
6041
+ { name: "name", label: "What renews", kind: "text", required: true, maxlength: MAX_TITLE, help: "A subscription, a domain, a warranty." },
6042
+ { name: "amount", label: "What one period costs", kind: "text", required: true, decimal: true },
6043
+ { name: "currency", label: "Currency", kind: "text", required: true, maxlength: CURRENCY_LENGTH, help: "A three-letter code, such as USD or EUR." },
6044
+ { name: "renews", label: "Next renewal or expiry date", kind: "date", required: true },
6045
+ { name: "unit", label: "Renews", kind: "select", options: UNIT_OPTIONS },
6046
+ { name: "every", label: "Every how many of those", kind: "number", min: 1, max: MAX_EVERY, help: "1 when empty." },
6047
+ { name: "lead", label: "Days before the date to ask", kind: "number", min: 0, max: MAX_LEAD_DAYS, help: "7 when empty." },
6048
+ { name: "note", label: "Note", kind: "text", maxlength: MAX_NOTE, help: "Carried on every ask: where to cancel, which account." }
6049
+ ];
6050
+ case "price":
6051
+ return mode === "edit" ? PRICE_SETTINGS : [
6052
+ { name: "url", label: "Product page", kind: "url", required: true, maxlength: MAX_URL },
6053
+ ...PRICE_SETTINGS,
6054
+ { name: "near", label: "Words just before the price", kind: "text", maxlength: MAX_NEAR, help: "Only if I cannot find the price on my own." }
6055
+ ];
6056
+ }
6057
+ }
6058
+ function prose(text) {
6059
+ return html`${text.split("`").map((part, i) => i % 2 === 1 ? html`<code>${part}</code>` : part)}`;
6060
+ }
6061
+ function field(f, values) {
6062
+ const value = values[f.name] ?? "";
6063
+ const help = f.help ? html`<p class="rb-field__help">${f.help}</p>` : null;
6064
+ if (f.kind === "select") {
6065
+ return html`<div class="rb-field">
6066
+ <label class="rb-label" for="${f.name}">${f.label}</label>
6067
+ <select class="rb-select" id="${f.name}" name="${f.name}">${(f.options ?? []).map(([v, label]) => html`<option value="${v}"${v === value ? html` selected` : null}>${label}</option>`)}</select>
6068
+ ${help}
6069
+ </div>`;
6070
+ }
6071
+ const type = f.kind === "number" ? "number" : f.kind === "date" ? "date" : f.kind === "url" ? "url" : "text";
6072
+ return html`<div class="rb-field">
6073
+ <label class="rb-label" for="${f.name}">${f.label}</label>
6074
+ <input class="rb-input" id="${f.name}" name="${f.name}" type="${type}" value="${value}"${f.maxlength ? html` maxlength="${f.maxlength}"` : null}${f.min !== undefined ? html` min="${f.min}"` : null}${f.max !== undefined ? html` max="${f.max}"` : null}${f.decimal ? html` inputmode="decimal"` : null}${f.required ? html` required` : null}>
6075
+ ${help}
6076
+ </div>`;
6077
+ }
6078
+ function alert(error) {
6079
+ return error ? html`<div class="rb-alert rb-alert--danger" role="alert"><p class="rb-alert__title">Not saved</p><p>${prose(error)}</p></div>` : null;
6080
+ }
6081
+ function taskHref(v, id, action = "") {
6082
+ return `${v.base}/tasks/${encodeURIComponent(id)}${action ? `/${action}` : ""}`;
6083
+ }
6084
+ var NOUN = { reminder: "reminder", renewal: "renewal", price: "price tracker" };
6085
+ function newTaskPage(v, type, values, error) {
6086
+ const intro = type === "price" ? html`<p class="rb-muted">I read the page once now; nothing is made unless I find a price in it.</p>` : null;
6087
+ return framed(v, `New ${NOUN[type]}`, html`<section>
6088
+ <p><a class="rb-link" href="${v.base}/">My tasks</a></p>
6089
+ <h1>New ${NOUN[type]}</h1>
6090
+ ${intro}
6091
+ ${alert(error)}
6092
+ <form method="post" action="${v.base}/new/${type}" class="tr-stack">
6093
+ <input type="hidden" name="csrf" value="${v.csrf}">
6094
+ ${fieldsFor(type, "new").map((f) => field(f, values))}
6095
+ <div><button class="rb-btn rb-btn--primary" type="submit">Create</button></div>
6096
+ </form>
6097
+ </section>`);
6098
+ }
6099
+ function editTaskPage(v, task, values, o) {
6100
+ const type = task.type;
6101
+ return framed(v, `Edit ${task.title}`, html`<section>
6102
+ <p><a class="rb-link" href="${taskHref(v, task.id)}">Back to the task</a></p>
6103
+ <h1>Edit ${task.title}</h1>
6104
+ <p class="rb-muted">Now: ${o.now}.${task.status === "paused" ? " Paused." : ""}</p>
6105
+ ${o.page ? html`<p class="rb-muted">Page: ${o.page}. To track another page, make a new price tracker.</p>` : null}
6106
+ ${alert(o.error)}
6107
+ <form method="post" action="${taskHref(v, task.id, "edit")}" class="tr-stack">
6108
+ <input type="hidden" name="csrf" value="${v.csrf}">
6109
+ ${fieldsFor(type, "edit").map((f) => field(f, values))}
6110
+ <div><button class="rb-btn rb-btn--primary" type="submit">Save</button></div>
6111
+ </form>
6112
+ </section>`);
6113
+ }
6114
+ function confirmDeletePage(v, task) {
6115
+ return framed(v, `Delete ${task.title}`, html`<section class="rb-card">
6116
+ <h1>Delete ${task.title}?</h1>
6117
+ <p>It stops at once and leaves your lists; nothing more is sent about it. Its history is kept, and an admin of this tracker can still see it.</p>
6118
+ <form method="post" action="${taskHref(v, task.id, "delete")}" class="tr-row">
6119
+ <input type="hidden" name="csrf" value="${v.csrf}">
6120
+ <input type="hidden" name="confirm" value="yes">
6121
+ <button class="rb-btn rb-btn--danger" type="submit">Delete</button>
6122
+ <a class="rb-btn rb-btn--ghost" href="${taskHref(v, task.id)}">Keep it</a>
6123
+ </form>
6124
+ </section>`);
6125
+ }
6126
+ function post(v, task, action, label, style) {
6127
+ return html`<form method="post" action="${taskHref(v, task.id, action)}"><input type="hidden" name="csrf" value="${v.csrf}"><button class="rb-btn ${style} rb-btn--sm" type="submit">${label}</button></form>`;
6128
+ }
6129
+ function ownerControls(v, task) {
6130
+ if (task.status === "archived")
6131
+ return null;
6132
+ const live = task.status === "active" || task.status === "paused";
6133
+ const editor = EDITOR_TYPES.includes(task.type);
6134
+ return html`<div class="tr-row">
6135
+ ${live && editor ? html`<a class="rb-btn rb-btn--primary rb-btn--sm" href="${taskHref(v, task.id, "edit")}">Edit</a>` : null}
6136
+ ${task.status === "active" ? post(v, task, "pause", "Pause", "rb-btn--ghost") : null}
6137
+ ${task.status === "paused" ? post(v, task, "resume", "Resume", "rb-btn--accent") : null}
6138
+ ${post(v, task, "delete", "Delete", "rb-btn--danger")}
6139
+ </div>`;
6140
+ }
6141
+ var NOTICES = {
6142
+ created: "Created.",
6143
+ saved: "Saved.",
6144
+ paused: "Paused: nothing is sent until you resume it.",
6145
+ resumed: "Resumed.",
6146
+ deleted: "Deleted. Its history is kept."
6147
+ };
6148
+ function notice(key, error) {
6149
+ if (error)
6150
+ return html`<div class="rb-alert rb-alert--danger" role="alert"><p>${prose(error)}</p></div>`;
6151
+ const text = key !== null && Object.hasOwn(NOTICES, key) ? NOTICES[key] : undefined;
6152
+ return text ? html`<div class="rb-alert rb-alert--success" role="status"><p class="rb-alert__title">${text}</p></div>` : null;
6153
+ }
6154
+
6155
+ // plugins/tracker/src/web/editor.ts
6156
+ var READING = "I am still reading the page of your last new price tracker; try again when it is done.";
6157
+ var GONE = "You are no longer on this tracker's list.";
6158
+ function asViewer(e, fn) {
6159
+ return e.queue(async () => {
6160
+ const user = await e.d.store.getUser(e.v.user.id);
6161
+ return user ? fn(user) : { ok: false, error: GONE };
6162
+ });
6163
+ }
6164
+ var str2 = (form, name) => form.get(name) ?? "";
6165
+ var opt = (form, name) => str2(form, name).trim() === "" ? undefined : str2(form, name);
6166
+ function int(form, name) {
6167
+ const raw = str2(form, name).trim();
6168
+ if (raw === "")
6169
+ return;
6170
+ return /^[0-9]{1,9}$/.test(raw) ? Number(raw) : Number.NaN;
6171
+ }
6172
+ function decimal(form, name) {
6173
+ const raw = str2(form, name).trim();
6174
+ if (raw === "")
6175
+ return;
6176
+ return /^[0-9]{1,12}(\.[0-9]{1,6})?$/.test(raw) ? Number(raw) : Number.NaN;
6177
+ }
6178
+ function typed(type, mode, form) {
6179
+ return Object.fromEntries(fieldsFor(type, mode).map((f) => [f.name, str2(form, f.name)]));
6180
+ }
6181
+ function reminderInput(form) {
6182
+ const when = opt(form, "when");
6183
+ return { text: str2(form, "text"), ...when !== undefined ? { when } : {}, repeat: str2(form, "repeat") || "none" };
6184
+ }
6185
+ function renewalInput(form) {
6186
+ const every = int(form, "every");
6187
+ const lead = int(form, "lead");
6188
+ const unit = opt(form, "unit");
6189
+ const note = opt(form, "note");
6190
+ return {
6191
+ name: str2(form, "name"),
6192
+ amount: decimal(form, "amount") ?? Number.NaN,
6193
+ currency: str2(form, "currency"),
6194
+ renews: str2(form, "renews"),
6195
+ ...every !== undefined ? { every } : {},
6196
+ ...lead !== undefined ? { lead } : {},
6197
+ ...unit !== undefined ? { unit } : {},
6198
+ ...note !== undefined ? { note } : {}
6199
+ };
6200
+ }
6201
+ function priceSettings(form) {
6202
+ const name = opt(form, "name");
6203
+ const hours = int(form, "hours");
6204
+ const drop = decimal(form, "drop");
6205
+ const baseline = opt(form, "baseline");
6206
+ return {
6207
+ ...name !== undefined ? { name } : {},
6208
+ ...hours !== undefined ? { hours } : {},
6209
+ ...drop !== undefined ? { drop } : {},
6210
+ ...baseline !== undefined ? { baseline } : {}
6211
+ };
6212
+ }
6213
+ function reminderEdit(form) {
6214
+ const text = opt(form, "text");
6215
+ const when = opt(form, "when");
6216
+ const repeat = opt(form, "repeat");
6217
+ return { ...text !== undefined ? { text } : {}, ...when !== undefined ? { when } : {}, ...repeat !== undefined ? { repeat } : {} };
6218
+ }
6219
+ function renewalEdit(form) {
6220
+ const name = opt(form, "name");
6221
+ const amount = decimal(form, "amount");
6222
+ const currency = opt(form, "currency");
6223
+ const renews = opt(form, "renews");
6224
+ const every = int(form, "every");
6225
+ const lead = int(form, "lead");
6226
+ const unit = opt(form, "unit");
6227
+ return {
6228
+ ...name !== undefined ? { name } : {},
6229
+ ...amount !== undefined ? { amount } : {},
6230
+ ...currency !== undefined ? { currency } : {},
6231
+ ...renews !== undefined ? { renews } : {},
6232
+ ...every !== undefined ? { every } : {},
6233
+ ...lead !== undefined ? { lead } : {},
6234
+ ...unit !== undefined ? { unit } : {},
6235
+ ...form.has("note") ? { note: str2(form, "note") } : {}
6236
+ };
6237
+ }
6238
+ function priceEdit(form) {
6239
+ const { name: _name, ...rest } = priceSettings(form);
6240
+ return { ...rest, ...form.has("name") ? { name: str2(form, "name") } : {} };
6241
+ }
6242
+ var DEFAULTS = {
6243
+ reminder: { repeat: "none" },
6244
+ renewal: { unit: "year", every: "1", lead: "7" },
6245
+ price: { hours: "12", drop: "10", baseline: "last" }
6246
+ };
6247
+ function newGet(e, type) {
6248
+ return htmlResponse(newTaskPage(e.v, type, type === "renewal" ? { ...DEFAULTS.renewal, currency: "USD" } : DEFAULTS[type]));
6249
+ }
6250
+ async function newPost(e, type, form) {
6251
+ let made;
6252
+ if (type === "reminder")
6253
+ made = await asViewer(e, (u) => createReminder(e.d, u, reminderInput(form)));
6254
+ else if (type === "renewal")
6255
+ made = await asViewer(e, (u) => createRenewal(e.d, u, renewalInput(form)));
6256
+ else {
6257
+ const near = opt(form, "near");
6258
+ const input = { url: str2(form, "url"), ...priceSettings(form), ...near !== undefined ? { near } : {} };
6259
+ const who = e.v.user.id;
6260
+ if (e.reading.has(who))
6261
+ made = { ok: false, error: READING };
6262
+ else {
6263
+ e.reading.add(who);
6264
+ try {
6265
+ const started = await asViewer(e, (u) => startPrice(e.d, u, input));
6266
+ if (!started.ok)
6267
+ made = started;
6268
+ else {
6269
+ const { pending } = started;
6270
+ const seen = await previewPrice(e.d, pending);
6271
+ made = await asViewer(e, (u) => finishPrice(e.d, u, pending, seen));
6272
+ }
6273
+ } finally {
6274
+ e.reading.delete(who);
6275
+ }
6276
+ }
6277
+ }
6278
+ if (!made.ok)
6279
+ return htmlResponse(newTaskPage(e.v, type, typed(type, "new", form), made.error), 400);
6280
+ return redirect(`${taskHref(e.v, made.task.id)}?done=created`);
6281
+ }
6282
+ async function taskPage(e, id, done, error) {
6283
+ const view = await loadHistory(e.d, e.v.user, id);
6284
+ if (!view)
6285
+ return htmlResponse(notFoundPage(e.v.base, e.v), 404);
6286
+ const controls = view.task.ownerId === e.v.user.id ? ownerControls(e.v, view.task) : null;
6287
+ return htmlResponse(historyPage(e.v, view, { controls, flash: notice(done, error) }), error ? 400 : 200);
6288
+ }
6289
+ function current(d, user, task) {
6290
+ const now = d.clock.now();
6291
+ if (task.type === "reminder")
6292
+ return { text: String(task.config?.text ?? ""), when: "", repeat: repeatOf(task.schedule) };
6293
+ if (task.type === "renewal" && task.schedule?.kind === "period") {
6294
+ const s = task.schedule;
6295
+ const c = task.config;
6296
+ const carried = task.state?.amount;
6297
+ const renews = renewalDate(task, user, now);
6298
+ return {
6299
+ name: task.title,
6300
+ amount: String(typeof carried === "number" ? carried : c.amount),
6301
+ currency: c.currency,
6302
+ renews,
6303
+ unit: s.unit,
6304
+ every: String(s.every),
6305
+ lead: String(s.leadDays ?? 0),
6306
+ note: c.note ?? ""
6307
+ };
6308
+ }
6309
+ if (task.type !== "price")
6310
+ return {};
6311
+ const c = task.config;
6312
+ const every = task.schedule?.kind === "poll" ? task.schedule.every : 12;
6313
+ return { name: task.title, hours: String(every), drop: String(c.dropPercent ?? 10), baseline: c.baseline ?? "last" };
6314
+ }
6315
+ function editPage(e, task, values, error) {
6316
+ const now = task.schedule ? describeSchedule(task.schedule, e.v.user, e.v.user.timeZone, e.d.clock.now()) : "no schedule";
6317
+ const page = task.type === "price" ? String(task.config.url) : undefined;
6318
+ return editTaskPage(e.v, task, values, { now, ...page !== undefined ? { page } : {}, ...error !== undefined ? { error } : {} });
6319
+ }
6320
+ async function editableTask(e, id) {
6321
+ const task = await ownLiveTask(e.d, e.v.user, id);
6322
+ return task && (task.type === "reminder" || task.type === "renewal" || task.type === "price") ? task : null;
6323
+ }
6324
+ async function editGet(e, id) {
6325
+ const task = await editableTask(e, id);
6326
+ if (!task)
6327
+ return htmlResponse(notFoundPage(e.v.base, e.v), 404);
6328
+ if (task.status !== "active" && task.status !== "paused")
6329
+ return redirect(taskHref(e.v, task.id));
6330
+ return htmlResponse(editPage(e, task, current(e.d, e.v.user, task)));
6331
+ }
6332
+ async function editPost(e, id, form) {
6333
+ const task = await editableTask(e, id);
6334
+ if (!task)
6335
+ return htmlResponse(notFoundPage(e.v.base, e.v), 404);
6336
+ const saved = task.type === "reminder" ? await asViewer(e, (u) => editReminder(e.d, u, task.id, reminderEdit(form))) : task.type === "renewal" ? await asViewer(e, (u) => editRenewal(e.d, u, task.id, renewalEdit(form))) : await asViewer(e, (u) => editPrice(e.d, u, task.id, priceEdit(form)));
6337
+ if (saved.ok)
6338
+ return redirect(`${taskHref(e.v, task.id)}?done=saved`);
6339
+ if (saved.error === NO_SUCH_TASK)
6340
+ return htmlResponse(notFoundPage(e.v.base, e.v), 404);
6341
+ return htmlResponse(editPage(e, task, typed(task.type, "edit", form), saved.error), 400);
6342
+ }
6343
+ async function actionPost(e, id, action, form) {
6344
+ const task = await ownLiveTask(e.d, e.v.user, id);
6345
+ if (!task)
6346
+ return htmlResponse(notFoundPage(e.v.base, e.v), 404);
6347
+ if (action === "delete" && str2(form, "confirm") !== "yes")
6348
+ return htmlResponse(confirmDeletePage(e.v, task));
6349
+ const act = action === "pause" ? pauseTask : action === "resume" ? resumeTask : deleteTask;
6350
+ const done = await asViewer(e, (u) => act(e.d, u, task.id));
6351
+ if (!done.ok)
6352
+ return done.error === NO_SUCH_TASK ? htmlResponse(notFoundPage(e.v.base, e.v), 404) : taskPage(e, task.id, null, done.error);
6353
+ if (action === "delete")
6354
+ return redirect(`${e.v.base}/?done=deleted`);
6355
+ return redirect(`${taskHref(e.v, task.id)}?done=${action === "pause" ? "paused" : "resumed"}`);
6356
+ }
6357
+
6358
+ // plugins/tracker/src/web/routes.ts
6359
+ var TASK_PATH = /^\/tasks\/([^/]+)$/;
6360
+ var TASK_ACTION = /^\/tasks\/([^/]+)\/(edit|pause|resume|delete)$/;
6361
+ var NEW_PATH = /^\/new\/([a-z]+)$/;
6362
+ function route(path) {
6363
+ if (path === "/")
6364
+ return { kind: "tasks" };
6365
+ if (path === "/settings")
6366
+ return { kind: "settings" };
6367
+ if (path === "/logout")
6368
+ return { kind: "logout" };
6369
+ const created = NEW_PATH.exec(path);
6370
+ if (created) {
6371
+ const type = created[1];
6372
+ return EDITOR_TYPES.includes(type) ? { kind: "new", type } : null;
6373
+ }
6374
+ const one = TASK_PATH.exec(path) ?? TASK_ACTION.exec(path);
6375
+ if (!one)
6376
+ return null;
6377
+ let id;
6378
+ try {
6379
+ id = decodeURIComponent(one[1] ?? "");
6380
+ } catch {
6381
+ id = "";
6382
+ }
6383
+ const action = one[2];
6384
+ if (action === undefined)
6385
+ return { kind: "task", id };
6386
+ if (action === "edit")
6387
+ return { kind: "edit", id };
6388
+ return { kind: "act", id, action };
6389
+ }
6390
+ function methodsOf(r) {
6391
+ if (r.kind === "logout" || r.kind === "act")
6392
+ return "POST";
6393
+ if (r.kind === "settings" || r.kind === "edit" || r.kind === "new")
6394
+ return "GET, POST";
6395
+ return "GET";
6396
+ }
6397
+
5632
6398
  // plugins/tracker/src/web/sessions.ts
5633
6399
  var SESSION_TTL_MS = 7 * 24 * 60 * 60 * 1000;
5634
6400
 
@@ -5670,8 +6436,7 @@ var MEMBER_CHECK_TIMEOUT_MS = 3000;
5670
6436
  var MEMBER_RETRY_MS = 60 * 1000;
5671
6437
  var LEFT_SERVER = `You are signed out. ${NOT_MEMBER}`;
5672
6438
  var RECHECK_FAILED = "You are signed out: I could not check that you are still a member of this tracker's server.";
5673
- var MAX_FORM_BYTES = 8 * 1024;
5674
- var TASK_PATH = /^\/tasks\/([^/]+)$/;
6439
+ var MAX_FORM_BYTES = 32 * 1024;
5675
6440
  function plain(status, text, extra = {}) {
5676
6441
  return new Response(text, {
5677
6442
  status,
@@ -5696,6 +6461,7 @@ function createWebHandler(w) {
5696
6461
  const clear = (name) => cookie(name, "", { base, maxAgeSeconds: 0, sameSite: "Lax" });
5697
6462
  const inFlight = new Map;
5698
6463
  const failedAt = new Map;
6464
+ const reading = new Set;
5699
6465
  async function authenticate(d, request) {
5700
6466
  const id = readCookie(request, SESSION_COOKIE);
5701
6467
  if (!id)
@@ -5765,10 +6531,8 @@ function createWebHandler(w) {
5765
6531
  const type = request.headers.get("content-type") ?? "";
5766
6532
  if (!type.toLowerCase().startsWith("application/x-www-form-urlencoded"))
5767
6533
  return null;
5768
- const body = await request.text();
5769
- if (Buffer.byteLength(body) > MAX_FORM_BYTES)
5770
- return null;
5771
- return new URLSearchParams(body);
6534
+ const body = await readBody(request, MAX_FORM_BYTES);
6535
+ return body === null ? null : new URLSearchParams(body);
5772
6536
  }
5773
6537
  function forbidden() {
5774
6538
  return htmlResponse(errorPage(base, "Refused", "That form was stale or came from somewhere else. Go back, reload the page and try again."), 403);
@@ -5806,7 +6570,7 @@ function createWebHandler(w) {
5806
6570
  let error = null;
5807
6571
  if (!/^([0-9]|1[0-9]|2[0-3])$/.test(hour))
5808
6572
  error = "The preferred hour must be a whole hour from 0 to 23.";
5809
- else if (zone === "" || zone.length > 64)
6573
+ else if (zone === "" || zone.length > MAX_ZONE)
5810
6574
  error = "Give a time zone, such as America/New_York.";
5811
6575
  else {
5812
6576
  error = await w.queue(async () => {
@@ -5844,45 +6608,52 @@ function createWebHandler(w) {
5844
6608
  return only("GET") ?? htmlResponse(signInHelpPage(base, url.searchParams.get("out") === "1" ? "You are signed out." : undefined));
5845
6609
  if (path === "/login")
5846
6610
  return method === "GET" ? loginGet(d, url) : loginPost(d, request);
5847
- const task = TASK_PATH.exec(path);
5848
- const route = path === "/" ? "tasks" : path === "/settings" ? "settings" : path === "/logout" ? "logout" : task ? "history" : null;
5849
- if (route === null)
6611
+ const r = route(path);
6612
+ if (r === null)
5850
6613
  return htmlResponse(notFoundPage(base), 404);
5851
- const wrong = route === "logout" ? only("POST") : route === "settings" ? null : only("GET");
5852
- if (wrong)
5853
- return wrong;
6614
+ const allowed = methodsOf(r);
6615
+ if (!allowed.split(", ").includes(method))
6616
+ return plain(405, "Method not allowed", { Allow: allowed });
5854
6617
  const auth = await authenticate(d, request);
5855
6618
  if (auth.kind === "signed-out")
5856
6619
  return htmlResponse(signInHelpPage(base, auth.note), 403, { "Set-Cookie": clear(SESSION_COOKIE) });
5857
6620
  if (auth.kind !== "ok")
5858
6621
  return redirect(`${base}/signin`, auth.kind === "stale" ? [clear(SESSION_COOKIE)] : []);
5859
6622
  const v = { base, user: auth.user, csrf: auth.session.csrf };
6623
+ const e = { d, v, queue: w.queue, reading };
5860
6624
  if (method === "POST") {
5861
6625
  const form = await readForm(request);
5862
6626
  if (!form)
5863
6627
  return plain(400, "Bad request");
5864
6628
  if (!safeEqual(auth.session.csrf, form.get("csrf") ?? ""))
5865
6629
  return forbidden();
5866
- if (route === "logout") {
5867
- d.sessions.delete(auth.id);
5868
- return redirect(`${base}/signin?out=1`, [clear(SESSION_COOKIE)]);
6630
+ switch (r.kind) {
6631
+ case "logout":
6632
+ d.sessions.delete(auth.id);
6633
+ return redirect(`${base}/signin?out=1`, [clear(SESSION_COOKIE)]);
6634
+ case "settings":
6635
+ return settingsPost(d, v, form);
6636
+ case "new":
6637
+ return newPost(e, r.type, form);
6638
+ case "edit":
6639
+ return editPost(e, r.id, form);
6640
+ case "act":
6641
+ return actionPost(e, r.id, r.action, form);
6642
+ default:
6643
+ return plain(405, "Method not allowed", { Allow: allowed });
5869
6644
  }
5870
- return settingsPost(d, v, form);
5871
6645
  }
5872
6646
  const now = d.clock.now();
5873
- switch (route) {
6647
+ const done = url.searchParams.get("done");
6648
+ switch (r.kind) {
5874
6649
  case "tasks":
5875
- return htmlResponse(tasksPage(v, await loadTaskList(d, auth.user), now));
5876
- case "history": {
5877
- let id;
5878
- try {
5879
- id = decodeURIComponent(task?.[1] ?? "");
5880
- } catch {
5881
- return htmlResponse(notFoundPage(base, v), 404);
5882
- }
5883
- const view = await loadHistory(d, auth.user, id);
5884
- return view ? htmlResponse(historyPage(v, view)) : htmlResponse(notFoundPage(base, v), 404);
5885
- }
6650
+ return htmlResponse(tasksPage(v, await loadTaskList(d, auth.user), now, notice(done)));
6651
+ case "task":
6652
+ return taskPage(e, r.id, done);
6653
+ case "edit":
6654
+ return editGet(e, r.id);
6655
+ case "new":
6656
+ return newGet(e, r.type);
5886
6657
  default:
5887
6658
  return htmlResponse(settingsPage(v, { hour: String(auth.user.preferredHour), zone: auth.user.timeZone, saved: url.searchParams.get("saved") === "1" }));
5888
6659
  }
@@ -6059,7 +6830,8 @@ function createPlugin(host, options = {}) {
6059
6830
  log: host.log,
6060
6831
  notifier: dm ? createDmNotifier({ store: openedStore, claims: openedClaims, dm, clock, log: host.log, health: delivery }) : NO_DM,
6061
6832
  logins: new LoginLinks(opened),
6062
- sessions: new Sessions(opened)
6833
+ sessions: new Sessions(opened),
6834
+ webEditor: webOrigin !== null
6063
6835
  };
6064
6836
  if (guildId === null)
6065
6837
  host.log.warn("TRACKER_GUILD_ID is unset: no membership gate, only the admission list");