Skip to main content

max / goingson

24.4 KB · 746 lines History Blame Raw
1 /**
2 * GoingsOn - Utility Functions
3 * Common utilities used across the application
4 */
5
6 (function() {
7 'use strict';
8
9 // ============ HTML Escaping ============
10
11 /**
12 * Escape HTML special characters to prevent XSS
13 * @param {string} text - Text to escape
14 * @returns {string} - Escaped text safe for innerHTML
15 */
16 function escapeHtml(text) {
17 const div = document.createElement('div');
18 div.textContent = text || '';
19 return div.innerHTML;
20 }
21
22 /**
23 * Escape string for use in JavaScript attribute values (onclick, etc.)
24 * Prevents XSS when IDs or other values are used in inline handlers
25 * @param {string} str - String to escape
26 * @returns {string} - Escaped string safe for JS attribute values
27 */
28 function escapeAttr(str) {
29 if (str == null) return '';
30 return String(str)
31 .replace(/\\/g, '\\\\')
32 .replace(/'/g, "\\'")
33 .replace(/"/g, '\\"')
34 .replace(/\n/g, '\\n')
35 .replace(/\r/g, '\\r');
36 }
37
38 /**
39 * Human-friendly prefixes for machine-readable API error codes.
40 * Backend sends structured ApiError { code, message, details }.
41 */
42 const ERROR_CODE_LABELS = {
43 NOT_FOUND: 'Not found',
44 VALIDATION_ERROR: 'Invalid input',
45 DATABASE_ERROR: 'Database error',
46 BAD_REQUEST: 'Bad request',
47 AUTH_ERROR: 'Authentication failed',
48 PARSE_ERROR: 'Could not parse input',
49 INTERNAL_ERROR: 'Something went wrong',
50 CONFLICT: 'Conflict',
51 EXTERNAL_SERVICE_ERROR: 'Service error',
52 };
53
54 /**
55 * Actionable hints per error code — helps users understand what to do next.
56 */
57 const ERROR_CODE_HINTS = {
58 VALIDATION_ERROR: 'Check that all required fields are filled in correctly.',
59 AUTH_ERROR: 'Check your credentials or reconnect your account in Settings.',
60 PARSE_ERROR: 'Try a simpler format — e.g. "tomorrow 3pm" or "2026-12-25".',
61 EXTERNAL_SERVICE_ERROR: 'The remote service may be temporarily unavailable. Try again in a moment.',
62 CONFLICT: 'This item was modified elsewhere. Reload and try again.',
63 };
64
65 /**
66 * Extract error message from various error types.
67 * Handles: plain strings, Error objects, and structured ApiError objects
68 * from the Tauri backend ({ code, message, details }).
69 * @param {Error|string|object} err - Error object or string
70 * @param {string} fallback - Fallback message if extraction fails
71 * @returns {string} - Human-readable error message
72 */
73 function getErrorMessage(err, fallback) {
74 // Tauri returns errors as strings (legacy) or JSON strings (ApiError)
75 if (typeof err === 'string') {
76 // Try to parse as JSON ApiError
77 try {
78 const parsed = JSON.parse(err);
79 if (parsed && parsed.code && parsed.message) {
80 return humanizeApiError(parsed);
81 }
82 } catch (_) { /* not JSON, use as-is */ }
83 return err;
84 }
85
86 // Structured ApiError object (code + message)
87 if (err && err.code && err.message && typeof err.code === 'string') {
88 return humanizeApiError(err);
89 }
90
91 // Standard Error object
92 if (err && err.message) return err.message;
93
94 return fallback || 'An error occurred';
95 }
96
97 /**
98 * Convert a structured ApiError into a user-friendly string.
99 * Strips internal prefixes like "Failed to ..." and UUID resource IDs.
100 * Appends actionable hints when available.
101 * @param {{code: string, message: string, details?: object}} apiErr
102 * @returns {string}
103 */
104 function humanizeApiError(apiErr) {
105 const label = ERROR_CODE_LABELS[apiErr.code];
106 const hint = ERROR_CODE_HINTS[apiErr.code];
107 let msg = apiErr.message;
108
109 // Strip UUID suffixes from not-found messages (e.g. "task not found: 550e8400-...")
110 if (apiErr.code === 'NOT_FOUND' && apiErr.details?.resource) {
111 const resource = apiErr.details.resource;
112 const capitalized = resource.charAt(0).toUpperCase() + resource.slice(1);
113 return `${capitalized} not found`;
114 }
115
116 // For database/internal errors, hide the raw detail and show the friendly label
117 if (apiErr.code === 'DATABASE_ERROR' || apiErr.code === 'INTERNAL_ERROR') {
118 return label || msg;
119 }
120
121 let result = label ? `${label}: ${msg}` : msg;
122 if (hint) result += ` ${hint}`;
123 return result;
124 }
125
126 // ============ Input Validation ============
127
128 /**
129 * Validation rules for form inputs
130 */
131 const ValidationRules = {
132 // Text input limits
133 NAME_MAX: 100,
134 DESCRIPTION_MAX: 500,
135 TITLE_MAX: 200,
136 EMAIL_SUBJECT_MAX: 200,
137 SEARCH_MAX: 200,
138 TAG_MAX: 50,
139 LOCATION_MAX: 200,
140
141 // Patterns
142 EMAIL_PATTERN: /^[^\s@]+@[^\s@]+\.[^\s@]+$/,
143 TAG_PATTERN: /^[a-zA-Z0-9_-]+$/,
144 };
145
146 /**
147 * Add validation attributes to a dynamically created input
148 * @param {string} type - Input type: 'name', 'description', 'title', 'email', 'tags', 'location'
149 * @returns {string} - HTML attributes string
150 */
151 function getValidationAttrs(type) {
152 switch (type) {
153 case 'name':
154 return `maxlength="${ValidationRules.NAME_MAX}" required`;
155 case 'description':
156 return `maxlength="${ValidationRules.DESCRIPTION_MAX}"`;
157 case 'title':
158 return `maxlength="${ValidationRules.TITLE_MAX}" required`;
159 case 'email':
160 return `type="email" maxlength="${ValidationRules.EMAIL_SUBJECT_MAX}"`;
161 case 'tags':
162 return `maxlength="${ValidationRules.TAG_MAX * 10}" pattern="[a-zA-Z0-9_,\\s-]*" title="Tags should be comma-separated words"`;
163 case 'location':
164 return `maxlength="${ValidationRules.LOCATION_MAX}"`;
165 default:
166 return '';
167 }
168 }
169
170 /**
171 * Validate a string against a maximum length
172 * @param {string} value - Value to validate
173 * @param {number} maxLength - Maximum allowed length
174 * @returns {boolean} - True if valid
175 */
176 function validateLength(value, maxLength) {
177 return !value || value.length <= maxLength;
178 }
179
180 /**
181 * Validate an email address
182 * @param {string} email - Email to validate
183 * @returns {boolean} - True if valid
184 */
185 function validateEmail(email) {
186 return !email || ValidationRules.EMAIL_PATTERN.test(email);
187 }
188
189 /**
190 * Show validation error on a form field
191 * @param {HTMLElement} input - The input element
192 * @param {string} message - Error message to display
193 */
194 function showFieldError(input, message) {
195 input.setAttribute('aria-invalid', 'true');
196
197 // Find or create error element
198 let errorEl = input.parentElement.querySelector('.form-error');
199 if (!errorEl) {
200 errorEl = document.createElement('div');
201 errorEl.className = 'form-error';
202 errorEl.id = `${input.id || input.name}-error`;
203 input.parentElement.appendChild(errorEl);
204 }
205
206 errorEl.textContent = message;
207 errorEl.classList.add('visible');
208 input.setAttribute('aria-describedby', errorEl.id);
209 }
210
211 /**
212 * Clear validation error on a form field
213 * @param {HTMLElement} input - The input element
214 */
215 function clearFieldError(input) {
216 input.setAttribute('aria-invalid', 'false');
217 input.removeAttribute('aria-describedby');
218
219 const errorEl = input.parentElement.querySelector('.form-error');
220 if (errorEl) {
221 errorEl.classList.remove('visible');
222 }
223 }
224
225 /**
226 * Clear all validation errors in a form
227 * @param {HTMLFormElement} form - The form element
228 */
229 function clearAllFieldErrors(form) {
230 form.querySelectorAll('[aria-invalid="true"]').forEach(input => {
231 clearFieldError(input);
232 });
233 }
234
235 /**
236 * Validate a form and show inline errors
237 * @param {HTMLFormElement} form - The form to validate
238 * @returns {boolean} - True if all fields are valid
239 */
240 function validateForm(form) {
241 let isValid = true;
242
243 // Clear previous errors
244 form.querySelectorAll('[aria-invalid]').forEach(input => {
245 clearFieldError(input);
246 });
247
248 let firstInvalidInput = null;
249
250 // Validate required fields
251 form.querySelectorAll('[required]').forEach(input => {
252 if (!input.value.trim()) {
253 showFieldError(input, 'This field is required');
254 isValid = false;
255 if (!firstInvalidInput) firstInvalidInput = input;
256 }
257 });
258
259 // Validate email fields
260 form.querySelectorAll('input[type="email"]').forEach(input => {
261 if (input.value && !validateEmail(input.value)) {
262 showFieldError(input, 'Please enter a valid email address (e.g. name@example.com)');
263 isValid = false;
264 if (!firstInvalidInput) firstInvalidInput = input;
265 }
266 });
267
268 // Validate maxlength (for browsers that don't enforce it)
269 form.querySelectorAll('[maxlength]').forEach(input => {
270 const maxLength = parseInt(input.getAttribute('maxlength'));
271 if (input.value.length > maxLength) {
272 showFieldError(input, `Maximum ${maxLength} characters (currently ${input.value.length})`);
273 isValid = false;
274 if (!firstInvalidInput) firstInvalidInput = input;
275 }
276 });
277
278 // Scroll to and focus first invalid field
279 if (firstInvalidInput) {
280 firstInvalidInput.scrollIntoView({ behavior: 'smooth', block: 'center' });
281 firstInvalidInput.focus();
282 }
283
284 return isValid;
285 }
286
287 // ============ Email Reader Mode ============
288
289 /**
290 * Format email body for reader mode display.
291 * - Strips HTML if present (for emails that weren't processed by backend)
292 * - Escapes remaining HTML for XSS protection
293 * - Converts extracted links in [url] format to clickable links
294 * - Detects and styles quoted text (lines starting with >)
295 * @param {string} body - Raw email body text (may contain HTML)
296 * @returns {string} - HTML-safe formatted body
297 */
298 function formatEmailBody(body) {
299 if (!body) return '';
300
301 // Check if body contains HTML that needs stripping
302 let text = body;
303 if (body.includes('<html') || body.includes('<body') || body.includes('<div') ||
304 body.includes('<table') || body.includes('<p>') || body.includes('<br')) {
305 text = stripHtmlForReaderMode(body);
306 }
307
308 // Escape any remaining HTML for XSS protection
309 let escaped = escapeHtml(text);
310
311 // Convert [url] patterns to clickable links
312 // Pattern: text [https://...] or text [http://...]
313 escaped = escaped.replace(
314 /\[((https?:\/\/)[^\]]+)\]/g,
315 '<a href="$1" class="email-link" target="_blank" rel="noopener noreferrer">$1</a>'
316 );
317
318 // Also detect bare URLs that weren't wrapped
319 // Match URLs that aren't already inside an href or anchor tag
320 escaped = escaped.replace(
321 /(?<!href="|">)(https?:\/\/[^\s<>\[\]"]+)/g,
322 '<a href="$1" class="email-link" target="_blank" rel="noopener noreferrer">$1</a>'
323 );
324
325 // Collapse quoted blocks (> lines and "On ... wrote:" attribution)
326 const lines = escaped.split('\n');
327 const result = [];
328 let i = 0;
329
330 while (i < lines.length) {
331 const trimmed = lines[i].trimStart();
332 const isQuote = trimmed.startsWith('&gt;') || trimmed.startsWith('>');
333
334 // Check for "On ... wrote:" attribution line followed by quotes
335 const isAttribution = /^On .+ wrote:$/.test(trimmed);
336
337 if (isAttribution || isQuote) {
338 // Collect the full quoted block
339 const quoteLines = [];
340 if (isAttribution) {
341 quoteLines.push(lines[i]);
342 i++;
343 }
344 while (i < lines.length) {
345 const t = lines[i].trimStart();
346 if (t.startsWith('&gt;') || t.startsWith('>') || t === '') {
347 quoteLines.push(lines[i]);
348 i++;
349 // Allow blank lines within quotes but stop at two+ consecutive blanks
350 if (t === '' && i < lines.length) {
351 const next = lines[i].trimStart();
352 if (!next.startsWith('&gt;') && !next.startsWith('>') && next !== '') {
353 break;
354 }
355 }
356 } else {
357 break;
358 }
359 }
360 // Trim trailing blank lines from the block
361 while (quoteLines.length > 0 && quoteLines[quoteLines.length - 1].trim() === '') {
362 quoteLines.pop();
363 }
364 if (quoteLines.length > 0) {
365 const id = 'quote-' + Math.random().toString(36).slice(2, 8);
366 result.push(`<div class="email-quote-toggle" onclick="document.getElementById('${id}').classList.toggle('hidden'); this.textContent = this.textContent === '··· Show quoted text' ? '··· Hide quoted text' : '··· Show quoted text'">··· Show quoted text</div>`);
367 result.push(`<div id="${id}" class="email-quote-block hidden">${quoteLines.join('\n')}</div>`);
368 }
369 } else {
370 result.push(lines[i]);
371 i++;
372 }
373 }
374
375 return result.join('\n');
376 }
377
378 /**
379 * Strip HTML tags and convert to readable plain text.
380 * Similar to backend strip_html but for client-side fallback.
381 * @param {string} html - HTML content
382 * @returns {string} - Plain text
383 */
384 function stripHtmlForReaderMode(html) {
385 // Use DOMParser to safely parse HTML without executing event handlers
386 // (unlike innerHTML, DOMParser does not fire onerror, onload, etc.)
387 const parser = new DOMParser();
388 const doc = parser.parseFromString(html, 'text/html');
389 const temp = doc.body;
390
391 // Remove script and style elements
392 const scripts = temp.querySelectorAll('script, style, head');
393 scripts.forEach(el => el.remove());
394
395 // Convert links to "text [url]" format before extracting text
396 const links = temp.querySelectorAll('a[href]');
397 links.forEach(link => {
398 const href = link.getAttribute('href');
399 const text = link.textContent.trim();
400 if (href && !href.startsWith('#') && !href.startsWith('javascript:')) {
401 // Only add URL if it's different from the text
402 if (href !== text && !text.includes(href)) {
403 link.textContent = `${text} [${href}]`;
404 }
405 }
406 });
407
408 // Convert <br> and block elements to newlines
409 temp.querySelectorAll('br').forEach(br => br.replaceWith('\n'));
410 temp.querySelectorAll('p, div, tr, li, h1, h2, h3, h4, h5, h6').forEach(el => {
411 el.prepend(document.createTextNode('\n'));
412 el.append(document.createTextNode('\n'));
413 });
414
415 // Convert list items to bullets
416 temp.querySelectorAll('li').forEach(li => {
417 li.prepend(document.createTextNode(''));
418 });
419
420 // Get text content
421 let text = temp.textContent || temp.innerText || '';
422
423 // Clean up whitespace
424 text = text
425 .replace(/\r\n/g, '\n') // Normalize line endings
426 .replace(/\n{3,}/g, '\n\n') // Max 2 consecutive newlines
427 .replace(/[ \t]+/g, ' ') // Collapse spaces
428 .replace(/^ +| +$/gm, '') // Trim each line
429 .trim();
430
431 return text;
432 }
433
434 // ============ Debounce ============
435
436 /**
437 * Create a debounced version of a function that delays execution
438 * until after the specified wait time has elapsed since the last call.
439 * @param {Function} fn - Function to debounce
440 * @param {number} wait - Milliseconds to wait (default: 500)
441 * @returns {Function} - Debounced function
442 */
443 function debounce(fn, wait = 500) {
444 let timeoutId = null;
445 return function(...args) {
446 clearTimeout(timeoutId);
447 timeoutId = setTimeout(() => fn.apply(this, args), wait);
448 };
449 }
450
451 // ============ Error Display ============
452
453 /**
454 * Display an error message in a container element
455 * @param {HTMLElement} container - DOM element to display error in
456 * @param {Error|string|object} err - Error object or string
457 * @param {string} fallback - Fallback message if extraction fails
458 */
459 function showError(container, err, fallback) {
460 const msg = getErrorMessage(err, fallback);
461 container.innerHTML = `<div class="error-state">${escapeHtml(msg)}</div>`;
462 }
463
464 // ============ Email Address Parsing ============
465
466 /**
467 * Parse an email address from various formats:
468 * - "Jane Smith <jane@example.com>"
469 * - "<jane@example.com>"
470 * - "jane@example.com"
471 * @param {string} from - Raw email address string
472 * @returns {{ name: string|null, email: string|null }} - Parsed name and email
473 */
474 function parseEmailAddress(from) {
475 if (!from) return { name: null, email: null };
476
477 // Match "Name <email>" or "<email>"
478 const match = from.match(/^(?:"?([^"<]*?)"?\s*)?<([^>]+)>$/);
479 if (match) {
480 return {
481 name: match[1]?.trim() || null,
482 email: match[2]?.trim() || null,
483 };
484 }
485
486 // Plain email address
487 const trimmed = from.trim();
488 if (trimmed.includes('@')) {
489 return { name: null, email: trimmed };
490 }
491
492 return { name: trimmed || null, email: null };
493 }
494
495 // ============ Date Formatting ============
496
497 /**
498 * Format a Date as YYYY-MM-DD for API calls.
499 * @param {Date} date - Date to format
500 * @returns {string} - Date string in YYYY-MM-DD format
501 */
502 function formatDateForApi(date) {
503 const year = date.getFullYear();
504 const month = String(date.getMonth() + 1).padStart(2, '0');
505 const day = String(date.getDate()).padStart(2, '0');
506 return `${year}-${month}-${day}`;
507 }
508
509 /**
510 * Convert a Date to a local ISO string (YYYY-MM-DDTHH:MM) for datetime-local inputs.
511 * Accounts for timezone offset so the displayed time matches local time.
512 * @param {Date} date - Date to convert
513 * @returns {string} - Local ISO string (e.g., "2026-04-06T14:30")
514 */
515 function toLocalISOString(date) {
516 return new Date(date.getTime() - date.getTimezoneOffset() * 60000).toISOString().slice(0, 16);
517 }
518
519 /**
520 * Format a Date as a human-readable display string.
521 * @param {Date} date - Date to format
522 * @returns {string} - Localized date string (e.g., "Monday, April 15, 2026")
523 */
524 function formatDateDisplay(date) {
525 const options = { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric' };
526 return date.toLocaleDateString(undefined, options);
527 }
528
529 // ============ Auto-Grow Textareas ============
530
531 /**
532 * Make a textarea auto-grow to fit its content.
533 * Sets height to scrollHeight on each input event.
534 * Call once after the textarea is in the DOM.
535 * @param {HTMLTextAreaElement} textarea - The textarea element
536 */
537 function autoGrow(textarea) {
538 if (!textarea) return;
539
540 function resize() {
541 textarea.style.height = 'auto';
542 textarea.style.height = textarea.scrollHeight + 'px';
543 }
544
545 textarea.addEventListener('input', resize);
546 // Initial size
547 resize();
548 }
549
550 // ============ Natural Date Parsing ============
551
552 /**
553 * Parse natural language date expressions into YYYY-MM-DDTHH:MM format.
554 * Accepts: "today", "tomorrow", "yesterday", "next monday", "friday",
555 * "friday 3pm", "next week", "in 3 days", "dec 25", "2026-12-25",
556 * "2026-12-25 3pm", ISO format.
557 * @param {string} str - Natural date string
558 * @returns {string|null} - ISO datetime string or null if unparseable
559 */
560 function parseNaturalDate(str) {
561 if (!str) return null;
562 const input = str.trim().toLowerCase();
563 if (!input) return null;
564
565 // Already ISO format (YYYY-MM-DDTHH:MM or YYYY-MM-DD HH:MM)
566 if (/^\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}/.test(str.trim())) {
567 const d = new Date(str.trim());
568 if (!isNaN(d)) return toLocalISOString(d);
569 }
570
571 // YYYY-MM-DD with optional time
572 const dateMatch = input.match(/^(\d{4}-\d{2}-\d{2})(?:\s+(\d{1,2})(?::(\d{2}))?\s*(am|pm)?)?$/);
573 if (dateMatch) {
574 const d = new Date(dateMatch[1] + 'T12:00:00');
575 if (!isNaN(d)) {
576 if (dateMatch[2]) applyTime(d, dateMatch[2], dateMatch[3], dateMatch[4]);
577 else d.setHours(9, 0, 0, 0);
578 return toLocalISOString(d);
579 }
580 }
581
582 const now = new Date();
583
584 // Relative: today, tomorrow, yesterday
585 if (input === 'today') { now.setHours(9, 0, 0, 0); return toLocalISOString(now); }
586 if (input === 'tomorrow') { now.setDate(now.getDate() + 1); now.setHours(9, 0, 0, 0); return toLocalISOString(now); }
587 if (input === 'yesterday') { now.setDate(now.getDate() - 1); now.setHours(9, 0, 0, 0); return toLocalISOString(now); }
588
589 // "next week" = next Monday 9am
590 if (input === 'next week') {
591 const daysUntilMon = (8 - now.getDay()) % 7 || 7;
592 now.setDate(now.getDate() + daysUntilMon);
593 now.setHours(9, 0, 0, 0);
594 return toLocalISOString(now);
595 }
596
597 // "in N days"
598 const inDaysMatch = input.match(/^in\s+(\d+)\s+days?$/);
599 if (inDaysMatch) {
600 now.setDate(now.getDate() + parseInt(inDaysMatch[1]));
601 now.setHours(9, 0, 0, 0);
602 return toLocalISOString(now);
603 }
604
605 // Weekday names: "friday", "next friday", "friday 3pm"
606 const days = ['sunday','monday','tuesday','wednesday','thursday','friday','saturday'];
607 const dayMatch = input.match(/^(?:next\s+)?(sunday|monday|tuesday|wednesday|thursday|friday|saturday)(?:\s+(\d{1,2})(?::(\d{2}))?\s*(am|pm)?)?$/);
608 if (dayMatch) {
609 const targetDay = days.indexOf(dayMatch[1]);
610 const isNext = input.startsWith('next');
611 let diff = targetDay - now.getDay();
612 if (diff <= 0 || isNext) diff += 7;
613 if (isNext && diff <= 7) diff += 7;
614 now.setDate(now.getDate() + diff);
615 if (dayMatch[2]) applyTime(now, dayMatch[2], dayMatch[3], dayMatch[4]);
616 else now.setHours(9, 0, 0, 0);
617 return toLocalISOString(now);
618 }
619
620 // Month + day: "dec 25", "december 25", "jan 5 3pm"
621 const months = ['january','february','march','april','may','june','july','august','september','october','november','december'];
622 const monthAbbrs = ['jan','feb','mar','apr','may','jun','jul','aug','sep','oct','nov','dec'];
623 const monthMatch = input.match(/^([a-z]+)\s+(\d{1,2})(?:\s+(\d{1,2})(?::(\d{2}))?\s*(am|pm)?)?$/);
624 if (monthMatch) {
625 let monthIdx = monthAbbrs.indexOf(monthMatch[1]);
626 if (monthIdx === -1) monthIdx = months.indexOf(monthMatch[1]);
627 if (monthIdx !== -1) {
628 const d = new Date(now.getFullYear(), monthIdx, parseInt(monthMatch[2]));
629 if (d < now) d.setFullYear(d.getFullYear() + 1);
630 if (monthMatch[3]) applyTime(d, monthMatch[3], monthMatch[4], monthMatch[5]);
631 else d.setHours(9, 0, 0, 0);
632 return toLocalISOString(d);
633 }
634 }
635
636 return null;
637 }
638
639 function applyTime(date, hours, minutes, ampm) {
640 let h = parseInt(hours);
641 const m = minutes ? parseInt(minutes) : 0;
642 if (ampm === 'pm' && h < 12) h += 12;
643 if (ampm === 'am' && h === 12) h = 0;
644 date.setHours(h, m, 0, 0);
645 }
646
647 // ============ Tag Normalization ============
648
649 /**
650 * Normalize a comma-separated tag string.
651 * Splits on commas, trims whitespace, lowercases, filters empty, deduplicates.
652 * @param {string} tagString - Raw tag string
653 * @returns {string[]} - Array of clean tags
654 */
655 function normalizeTags(tagString) {
656 if (!tagString) return [];
657 const seen = new Set();
658 return tagString.split(',')
659 .map(t => t.trim().toLowerCase())
660 .filter(t => {
661 if (!t || seen.has(t)) return false;
662 seen.add(t);
663 return true;
664 });
665 }
666
667 // ============ Date Parse Preview ============
668
669 /**
670 * Live preview callback for natural language date fields.
671 * Use as onInput in form field definitions.
672 * @param {string} value - Current input value
673 * @param {HTMLElement} previewEl - Element to show parsed result
674 */
675 function dateParsePreview(value, previewEl) {
676 if (!value || !value.trim()) {
677 previewEl.textContent = '';
678 return;
679 }
680 const parsed = parseNaturalDate(value);
681 if (parsed) {
682 const d = new Date(parsed);
683 const display = d.toLocaleDateString(undefined, {
684 weekday: 'long', year: 'numeric', month: 'long', day: 'numeric',
685 hour: 'numeric', minute: '2-digit',
686 });
687 previewEl.textContent = display;
688 previewEl.style.color = 'var(--accent-primary)';
689 } else {
690 // Only show "not recognized" if it doesn't look like an ISO date being typed
691 if (value.trim().length > 2) {
692 previewEl.textContent = 'Date not recognized';
693 previewEl.style.color = 'var(--text-secondary)';
694 } else {
695 previewEl.textContent = '';
696 }
697 }
698 }
699
700 // ============ Populate GoingsOn.utils Namespace ============
701
702 GoingsOn.utils = {
703 // HTML escaping
704 escapeHtml,
705 escapeAttr,
706 getErrorMessage,
707 showError,
708
709 // Email formatting
710 formatEmailBody,
711
712 // Date formatting
713 formatDateForApi,
714 formatDateDisplay,
715 toLocalISOString,
716
717 // Natural date parsing
718 parseNaturalDate,
719 dateParsePreview,
720
721 // Tag normalization
722 normalizeTags,
723
724 // Debounce
725 debounce,
726
727 // Email address parsing
728 parseEmailAddress,
729
730 // Auto-grow
731 autoGrow,
732
733 // Validation
734 ValidationRules,
735 getValidationAttrs,
736 validateLength,
737 validateEmail,
738 showFieldError,
739 clearFieldError,
740 clearAllFieldErrors,
741 validateForm,
742 };
743
744 })();
745
746