Odysseus v1.0

This commit is contained in:
pewdiepie-archdaemon
2026-05-31 23:58:26 +09:00
commit e5c99a5eee
421 changed files with 271349 additions and 0 deletions

View 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
View 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);
}