feat: Phases 4-7 — Full Feature Expansion (26 features)
Phase 4 — New Widget Types: - Clock/Weather, System Stats, RSS/Feed, Calendar, Markdown, Metric/Counter, Link Group, Camera/Stream widgets - Backend services with caching for each data source - Full creation form with dynamic config fields per type Phase 5 — Visual & Styling Enhancements: - Glassmorphism card style (solid/glass/outline) - Board-level themes with per-board hue/saturation - Animated SVG status rings replacing static dots - Card size options (compact/medium/large) - Custom CSS injection (admin + per-board, sanitized) - Wallpaper backgrounds with blur/overlay/parallax Phase 6 — Functional Features: - Favorites bar with drag-and-drop reordering - Recent apps tracking with privacy toggle - Uptime dashboard page (/status, guest-accessible) - Notifications system (Discord/Slack/Telegram/HTTP webhooks) - App tags with filtering in board view - Multi-URL app cards with expandable sub-links - Personal API tokens with scoped permissions - Audit log with retention and admin viewer Phase 7 — Quality of Life: - Onboarding wizard (5-step first-launch setup) - App URL health preview with favicon/title detection - Board templates (4 built-in + custom import/export) - Keyboard shortcut overlay (j/k nav, 1-9 boards, ? help) 212 files changed, 15641 insertions, 980 deletions. Build, lint, type check, and 222 tests all pass.
This commit is contained in:
@@ -0,0 +1,238 @@
|
||||
/**
|
||||
* Calendar service — fetches and parses iCal (.ics) files.
|
||||
* Uses lightweight hand-parsing of VEVENT blocks (no heavy dependencies).
|
||||
*/
|
||||
|
||||
const CACHE_TTL_MS = 30 * 60 * 1000; // 30 minutes
|
||||
const FETCH_TIMEOUT_MS = 10_000;
|
||||
const DEFAULT_DAYS_AHEAD = 14;
|
||||
|
||||
interface CacheEntry {
|
||||
readonly data: string; // raw ical text
|
||||
readonly expiresAt: number;
|
||||
}
|
||||
|
||||
export interface CalendarEvent {
|
||||
readonly summary: string;
|
||||
readonly start: string;
|
||||
readonly end: string;
|
||||
readonly location: string | null;
|
||||
readonly calendarLabel: string;
|
||||
readonly calendarColor: string;
|
||||
}
|
||||
|
||||
export interface CalendarSource {
|
||||
readonly url: string;
|
||||
readonly color?: string;
|
||||
readonly label?: string;
|
||||
}
|
||||
|
||||
const cache = new Map<string, CacheEntry>();
|
||||
|
||||
function getCached(key: string): string | null {
|
||||
const entry = cache.get(key);
|
||||
if (!entry) return null;
|
||||
if (Date.now() > entry.expiresAt) {
|
||||
cache.delete(key);
|
||||
return null;
|
||||
}
|
||||
return entry.data;
|
||||
}
|
||||
|
||||
function setCache(key: string, data: string): void {
|
||||
cache.set(key, {
|
||||
data,
|
||||
expiresAt: Date.now() + CACHE_TTL_MS
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse an iCal date string (YYYYMMDD, YYYYMMDDTHHmmssZ, YYYYMMDDTHHmmss).
|
||||
*/
|
||||
function parseIcalDate(dateStr: string): Date | null {
|
||||
if (!dateStr) return null;
|
||||
|
||||
// Remove TZID parameter prefix if present
|
||||
const clean = dateStr.replace(/^.*:/, '').trim();
|
||||
|
||||
// All-day event: YYYYMMDD
|
||||
if (/^\d{8}$/.test(clean)) {
|
||||
const year = parseInt(clean.substring(0, 4), 10);
|
||||
const month = parseInt(clean.substring(4, 6), 10) - 1;
|
||||
const day = parseInt(clean.substring(6, 8), 10);
|
||||
return new Date(year, month, day);
|
||||
}
|
||||
|
||||
// DateTime: YYYYMMDDTHHmmss or YYYYMMDDTHHmmssZ
|
||||
const dtMatch = clean.match(/^(\d{4})(\d{2})(\d{2})T(\d{2})(\d{2})(\d{2})(Z?)$/);
|
||||
if (dtMatch) {
|
||||
const [, year, month, day, hour, minute, second, utc] = dtMatch;
|
||||
if (utc === 'Z') {
|
||||
return new Date(
|
||||
Date.UTC(
|
||||
parseInt(year, 10),
|
||||
parseInt(month, 10) - 1,
|
||||
parseInt(day, 10),
|
||||
parseInt(hour, 10),
|
||||
parseInt(minute, 10),
|
||||
parseInt(second, 10)
|
||||
)
|
||||
);
|
||||
}
|
||||
return new Date(
|
||||
parseInt(year, 10),
|
||||
parseInt(month, 10) - 1,
|
||||
parseInt(day, 10),
|
||||
parseInt(hour, 10),
|
||||
parseInt(minute, 10),
|
||||
parseInt(second, 10)
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract a property value from an iCal VEVENT block.
|
||||
* Handles folded lines (continuation lines starting with space/tab).
|
||||
*/
|
||||
function extractProperty(block: string, property: string): string {
|
||||
// Match property with optional parameters (e.g., DTSTART;TZID=...:value)
|
||||
const regex = new RegExp(`^${property}[;:](.*)$`, 'im');
|
||||
const match = block.match(regex);
|
||||
if (!match) return '';
|
||||
|
||||
const value = match[1];
|
||||
|
||||
// If the property had parameters (;PARAM=value:actualValue), extract just the value
|
||||
if (property === 'DTSTART' || property === 'DTEND') {
|
||||
// Keep the full string — parseIcalDate handles TZID prefix
|
||||
return value.trim();
|
||||
}
|
||||
|
||||
return value.trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse VEVENT blocks from iCal text.
|
||||
*/
|
||||
function parseVEvents(
|
||||
icalText: string
|
||||
): Array<{ summary: string; start: string; end: string; location: string }> {
|
||||
const events: Array<{ summary: string; start: string; end: string; location: string }> = [];
|
||||
|
||||
// Unfold continuation lines (RFC 5545: lines starting with space/tab are continuations)
|
||||
const unfolded = icalText.replace(/\r?\n[ \t]/g, '');
|
||||
|
||||
const eventRegex = /BEGIN:VEVENT([\s\S]*?)END:VEVENT/gi;
|
||||
let match: RegExpExecArray | null;
|
||||
|
||||
while ((match = eventRegex.exec(unfolded)) !== null) {
|
||||
const block = match[1];
|
||||
const summary = extractProperty(block, 'SUMMARY');
|
||||
const dtStart = extractProperty(block, 'DTSTART');
|
||||
const dtEnd = extractProperty(block, 'DTEND');
|
||||
const location = extractProperty(block, 'LOCATION');
|
||||
|
||||
const startDate = parseIcalDate(dtStart);
|
||||
if (!startDate) continue;
|
||||
|
||||
const endDate = parseIcalDate(dtEnd);
|
||||
|
||||
events.push({
|
||||
summary: summary || 'Untitled Event',
|
||||
start: startDate.toISOString(),
|
||||
end: endDate ? endDate.toISOString() : startDate.toISOString(),
|
||||
location: location || ''
|
||||
});
|
||||
}
|
||||
|
||||
return events;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch iCal text from a URL.
|
||||
*/
|
||||
async function fetchIcalText(url: string): Promise<string> {
|
||||
const cached = getCached(url);
|
||||
if (cached) return cached;
|
||||
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
|
||||
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
signal: controller.signal,
|
||||
headers: {
|
||||
'User-Agent': 'WebAppLauncher/1.0',
|
||||
Accept: 'text/calendar, application/ics'
|
||||
}
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Calendar source returned ${response.status}`);
|
||||
}
|
||||
|
||||
const text = await response.text();
|
||||
setCache(url, text);
|
||||
return text;
|
||||
} catch (err) {
|
||||
if (err instanceof DOMException && err.name === 'AbortError') {
|
||||
throw new Error('Calendar request timed out');
|
||||
}
|
||||
throw err;
|
||||
} finally {
|
||||
clearTimeout(timeoutId);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch and parse events from multiple iCal URLs, merged and sorted by start time.
|
||||
*/
|
||||
export async function fetchCalendarEvents(
|
||||
sources: readonly CalendarSource[],
|
||||
daysAhead?: number
|
||||
): Promise<readonly CalendarEvent[]> {
|
||||
const days = daysAhead ?? DEFAULT_DAYS_AHEAD;
|
||||
const now = new Date();
|
||||
const cutoff = new Date(now.getTime() + days * 24 * 60 * 60 * 1000);
|
||||
|
||||
const allEvents: CalendarEvent[] = [];
|
||||
|
||||
const results = await Promise.allSettled(
|
||||
sources.map(async (source) => {
|
||||
const icalText = await fetchIcalText(source.url);
|
||||
const events = parseVEvents(icalText);
|
||||
|
||||
return events
|
||||
.filter((event) => {
|
||||
const start = new Date(event.start);
|
||||
return start >= now && start <= cutoff;
|
||||
})
|
||||
.map((event) => ({
|
||||
summary: event.summary,
|
||||
start: event.start,
|
||||
end: event.end,
|
||||
location: event.location || null,
|
||||
calendarLabel: source.label ?? 'Calendar',
|
||||
calendarColor: source.color ?? '#6366f1'
|
||||
}));
|
||||
})
|
||||
);
|
||||
|
||||
for (const result of results) {
|
||||
if (result.status === 'fulfilled') {
|
||||
allEvents.push(...result.value);
|
||||
}
|
||||
}
|
||||
|
||||
// Sort by start time ascending
|
||||
return [...allEvents].sort((a, b) => new Date(a.start).getTime() - new Date(b.start).getTime());
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear the calendar cache.
|
||||
*/
|
||||
export function clearCache(): void {
|
||||
cache.clear();
|
||||
}
|
||||
Reference in New Issue
Block a user