Odysseus v1.0
This commit is contained in:
114
static/js/calendar/reminders.js
Normal file
114
static/js/calendar/reminders.js
Normal file
@@ -0,0 +1,114 @@
|
||||
// static/js/calendar/reminders.js
|
||||
//
|
||||
// Browser-notification poller for calendar reminder notes. Self-contained:
|
||||
// module-private `_notifFired` Set tracks which note IDs we've already
|
||||
// notified, persisted to localStorage. Polls `/api/notes?label=calendar`
|
||||
// every 60 seconds and fires a Notification + toast for any note whose
|
||||
// `due_date` is in the past but within the staleness window.
|
||||
//
|
||||
// `start()` kicks off the poll loop + permission request. Call once from
|
||||
// the calendar's entry module.
|
||||
|
||||
import uiModule from '../ui.js';
|
||||
|
||||
const API_BASE = window.location.origin;
|
||||
|
||||
let _notifFired = new Set(JSON.parse(localStorage.getItem('cal-notif-fired') || '[]'));
|
||||
|
||||
// Compute a fresh, system-clock-accurate notification body. Tries the
|
||||
// note's `event_dtstart` first (set by _createEventReminder); falls back
|
||||
// to scrubbing stale time tokens out of items[0].text so legacy
|
||||
// reminders don't show "in 29 min" at 9pm.
|
||||
function _formatReminderBody(note) {
|
||||
const dtstartRaw = note.event_dtstart || note.eventDtstart || null;
|
||||
if (dtstartRaw) {
|
||||
const start = new Date(dtstartRaw);
|
||||
if (!isNaN(start.getTime())) {
|
||||
const now = new Date();
|
||||
const mins = Math.round((start - now) / 60000);
|
||||
const when = start.toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' });
|
||||
let when2 = '';
|
||||
const sameDay = start.toDateString() === now.toDateString();
|
||||
if (!sameDay) when2 = ' ' + start.toLocaleDateString([], { month: 'short', day: 'numeric' });
|
||||
if (mins >= 1 && mins <= 60) return `Starts in ${mins} min (${when}${when2})`;
|
||||
if (mins === 0) return `Starting now (${when}${when2})`;
|
||||
if (mins > 60) {
|
||||
const h = Math.round(mins / 60);
|
||||
return `Starts in ${h} hour${h === 1 ? '' : 's'} (${when}${when2})`;
|
||||
}
|
||||
if (mins >= -60) return `Started ${Math.abs(mins)} min ago (${when}${when2})`;
|
||||
return `Was scheduled for ${when}${when2}`;
|
||||
}
|
||||
}
|
||||
// Legacy notes (no event_dtstart). Scrub stale relative-time strings.
|
||||
let body = (note.items || []).map(i => i.text).join('\n') || note.content || '';
|
||||
body = body.replace(/\bin\s+\d+\s*(min|minute|hour|hr|day)s?\b/gi, '').trim();
|
||||
body = body.replace(/\(\s*\d{1,2}:\d{2}\s*\)/g, '').trim();
|
||||
body = body.replace(/\s{2,}/g, ' ');
|
||||
return body;
|
||||
}
|
||||
|
||||
// Only fire a reminder if `due` was within this many minutes BEFORE now.
|
||||
// Stops a fresh browser (empty `cal-notif-fired` localStorage) from spamming
|
||||
// every 2-week-old reminder on first poll. Anything older is silently
|
||||
// marked fired so it doesn't keep getting picked up.
|
||||
const _REMINDER_STALENESS_MIN = 5;
|
||||
|
||||
async function _pollReminders() {
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/api/notes?label=calendar`, { credentials: 'same-origin' });
|
||||
if (!res.ok) return;
|
||||
const notes = await res.json();
|
||||
const now = new Date();
|
||||
const stalenessMs = _REMINDER_STALENESS_MIN * 60 * 1000;
|
||||
for (const note of notes) {
|
||||
if (!note.due_date || _notifFired.has(note.id)) continue;
|
||||
const due = new Date(note.due_date);
|
||||
if (isNaN(due)) continue;
|
||||
if (due > now) continue; // not yet due
|
||||
const ageMs = now - due;
|
||||
if (ageMs > stalenessMs) {
|
||||
// Too old to fire — mark as seen so we don't recheck every minute.
|
||||
_notifFired.add(note.id);
|
||||
continue;
|
||||
}
|
||||
_notifFired.add(note.id);
|
||||
const body = _formatReminderBody(note);
|
||||
fetch(`${API_BASE}/api/notes/fire-reminder`, {
|
||||
method: 'POST',
|
||||
credentials: 'same-origin',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
note_id: note.id,
|
||||
title: note.title || 'Calendar Reminder',
|
||||
body,
|
||||
}),
|
||||
}).catch(() => {});
|
||||
if ('Notification' in window && Notification.permission === 'granted') {
|
||||
new Notification(note.title || 'Calendar Reminder', {
|
||||
body,
|
||||
icon: '/static/favicon.png',
|
||||
tag: `cal-remind-${note.id}`,
|
||||
});
|
||||
}
|
||||
if (uiModule.showToast) uiModule.showToast((note.title || 'Calendar Reminder') + (body ? ' — ' + body : ''));
|
||||
}
|
||||
// Persist fired set (keep last 200)
|
||||
const arr = [..._notifFired].slice(-200);
|
||||
localStorage.setItem('cal-notif-fired', JSON.stringify(arr));
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
let _started = false;
|
||||
|
||||
// Idempotent: safe to call multiple times. Kicks off permission request
|
||||
// and the 60s poll loop on first call.
|
||||
export function startReminderPoll() {
|
||||
if (_started) return;
|
||||
_started = true;
|
||||
if ('Notification' in window && Notification.permission === 'default') {
|
||||
Notification.requestPermission();
|
||||
}
|
||||
_pollReminders();
|
||||
setInterval(_pollReminders, 60000);
|
||||
}
|
||||
126
static/js/calendar/utils.js
Normal file
126
static/js/calendar/utils.js
Normal file
@@ -0,0 +1,126 @@
|
||||
// static/js/calendar/utils.js
|
||||
//
|
||||
// Pure constants + zero-state helpers for the calendar UI.
|
||||
// No DOM, no fetch, no global mutable state — safe to import anywhere.
|
||||
|
||||
export const WEEKDAYS = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'];
|
||||
|
||||
export const MONTHS = ['January', 'February', 'March', 'April', 'May', 'June',
|
||||
'July', 'August', 'September', 'October', 'November', 'December'];
|
||||
|
||||
export const MON_SHORT = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
|
||||
|
||||
export const CAL_PALETTE = [
|
||||
'var(--accent)', '#5b8abf', '#bf6b5b', '#5bbf7a', '#bf9a5b',
|
||||
'#9a5bbf', '#5bbfb8', '#bf8a5b', '#7070c0', '#bf5b8a',
|
||||
];
|
||||
|
||||
export const CAL_COLORS = [
|
||||
{ name: 'default', hex: '' },
|
||||
// Pale/pastel palette — softer event tints.
|
||||
{ name: 'red', hex: '#f0b5ba' },
|
||||
{ name: 'orange', hex: '#e8ccb2' },
|
||||
{ name: 'yellow', hex: '#f2dfbd' },
|
||||
{ name: 'green', hex: '#cce0bc' },
|
||||
{ name: 'blue', hex: '#b0d7f7' },
|
||||
{ name: 'purple', hex: '#e2bcee' },
|
||||
{ name: 'teal', hex: '#abdbe0' },
|
||||
{ name: 'pink', hex: '#f0b5cc' },
|
||||
// Custom — mirrors the notes color picker. Clicking opens a file picker
|
||||
// and the chosen image URL is stored as a `bg:<url>` sentinel.
|
||||
{ name: 'custom', hex: 'custom' },
|
||||
];
|
||||
|
||||
export const _CAL_CUSTOM_GRADIENT = 'conic-gradient(from 0deg, #e06c75, #d19a66, #e5c07b, #98c379, #61afef, #c678dd, #e06c75)';
|
||||
|
||||
// Per-event-type accent palette. Used by the colored dots in month/year
|
||||
// grids and the chip stripe behind agenda rows.
|
||||
export const _TYPE_PALETTE = {
|
||||
'!': '#e5a33a', // important — amber, less harsh than red
|
||||
work: '#5b8abf',
|
||||
personal: '#a07ae0',
|
||||
health: '#e06c75',
|
||||
travel: '#e5a33a',
|
||||
meal: '#d8b974',
|
||||
social: '#82c882',
|
||||
admin: '#888888',
|
||||
other: '#6b9cb5',
|
||||
untagged: '#555',
|
||||
};
|
||||
|
||||
// SVG icon literals reused across the calendar UI.
|
||||
export const _trashIcon = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 6h18"/><path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6"/></svg>';
|
||||
export const _moreIcon = '<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor"><circle cx="12" cy="5" r="2"/><circle cx="12" cy="12" r="2"/><circle cx="12" cy="19" r="2"/></svg>';
|
||||
export const _bellIcon = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M18 8A6 6 0 0 0 6 8c0 7-3 9-3 9h18s-3-2-3-9"/><path d="M13.73 21a2 2 0 0 1-3.46 0"/></svg>';
|
||||
|
||||
// ── background CSS helpers ──
|
||||
|
||||
export function _isCalBgImage(c) {
|
||||
return typeof c === 'string' && c.startsWith('bg:');
|
||||
}
|
||||
|
||||
export function _calBgImageUrl(c) {
|
||||
return _isCalBgImage(c) ? c.slice(3) : '';
|
||||
}
|
||||
|
||||
// Returns a value safe to drop into `style="background:..."`. Falls back to
|
||||
// the calendar default for bg-image events in spots where an image would be
|
||||
// too small to render usefully (small grid dots, multi-day bars).
|
||||
export function _calBgCss(c, fallback) {
|
||||
if (_isCalBgImage(c)) {
|
||||
const u = _calBgImageUrl(c);
|
||||
return u ? `center/cover no-repeat url('${u.replace(/'/g, "\\'")}')` : (fallback || 'var(--accent)');
|
||||
}
|
||||
return c || fallback || 'var(--accent)';
|
||||
}
|
||||
|
||||
// ── date helpers ──
|
||||
|
||||
// `YYYY-MM-DD` string from a Date.
|
||||
export function _ds(d) {
|
||||
return d.getFullYear() + '-' + String(d.getMonth() + 1).padStart(2, '0') + '-' + String(d.getDate()).padStart(2, '0');
|
||||
}
|
||||
|
||||
export function _addDays(dateStr, n) {
|
||||
const d = new Date(dateStr + 'T00:00:00');
|
||||
d.setDate(d.getDate() + n);
|
||||
return _ds(d);
|
||||
}
|
||||
|
||||
export function _shiftDT(iso, days) {
|
||||
const d = new Date(iso);
|
||||
d.setDate(d.getDate() + days);
|
||||
return _ds(d) + (iso.length > 10 ? 'T' + iso.slice(11) : '');
|
||||
}
|
||||
|
||||
// Current user's UTC offset as `±HH:MM`. Used to stamp event payloads so
|
||||
// the backend can interpret naive datetimes in the user's tz.
|
||||
export function _tzOffset() {
|
||||
const o = -new Date().getTimezoneOffset();
|
||||
const sign = o >= 0 ? '+' : '-';
|
||||
const h = String(Math.floor(Math.abs(o) / 60)).padStart(2, '0');
|
||||
const m = String(Math.abs(o) % 60).padStart(2, '0');
|
||||
return `${sign}${h}:${m}`;
|
||||
}
|
||||
|
||||
// For naive datetimes (no tz suffix), display the date portion as written —
|
||||
// TimeTree and many sync tools store "local time" without an offset, so
|
||||
// re-interpreting them via the user's tz would shift days.
|
||||
//
|
||||
// For tz-aware ISO (`Z` or `±HH:MM`), parse as an absolute instant and
|
||||
// bucket by the USER's local date. Without this an event at
|
||||
// "2026-05-13T22:00:00Z" (07:00 May 14 JST) would render on May 13.
|
||||
export function _localDateOf(isoStr) {
|
||||
if (!isoStr) return '';
|
||||
if (isoStr.length === 10) return isoStr;
|
||||
if (/[Zz]$|[+\-]\d{2}:?\d{2}$/.test(isoStr)) {
|
||||
const d = new Date(isoStr);
|
||||
if (!isNaN(d)) {
|
||||
const y = d.getFullYear();
|
||||
const m = String(d.getMonth() + 1).padStart(2, '0');
|
||||
const dd = String(d.getDate()).padStart(2, '0');
|
||||
return `${y}-${m}-${dd}`;
|
||||
}
|
||||
}
|
||||
return isoStr.slice(0, 10);
|
||||
}
|
||||
Reference in New Issue
Block a user