diff --git a/LOOP.md b/LOOP.md index f0b951b..cb7f136 100644 --- a/LOOP.md +++ b/LOOP.md @@ -25,3 +25,4 @@ Agent-written loop log. One plain-English line per iteration. - [2026-07-04 #19] maker: competition-readiness hardening — strict Supabase JWT verification (HS256 secret or ES256/RS256 JWKS), conflict checks on event updates, typed frontend lifecycle cleanup, 38-test backend suite, and fixed-ID GitHub Actions gate | verify: security audit first reproduced forged-JWT access with HTTP 200; the replacement verified a real ES256 Supabase token through JWKS and rejected wrong-key/expired/unsigned/wrong-audience/wrong-issuer tokens; backend 38/38 passed, ESLint + TypeScript + production build passed, and TestSprite final backend gate `2aadf520` was created for live verification | failure: unsigned fallback accepted forged tokens, CI used `|| true` and created duplicate draft tests, frontend had 28 lint errors, only 3 unit tests existed, update conflicts were unchecked, and “jam 2 siang”/“day after tomorrow” parsed incorrectly | fix: removed signature bypass, required JWT claims, added crypto/JWKS support and security regression coverage, fixed parser ordering/Indonesian period handling and update conflict enforcement, removed dead Google buttons, and changed CI to fail on non-passing TestSprite verdicts with deployment revision checks and failure bundles - [2026-07-04 #20] maker: integration foundation on `development` — Supabase migration (`external_ids`, `integrations`, `webhook_subscriptions`, `sync_logs`), encrypted provider tokens, outgoing webhooks with HMAC/SSRF/retry, ICS import, Resend email hooks, `/integrations` settings UI, 8 integration unit tests, hardened TestSprite suite (demo account + cleanup), README/INTEGRATIONS.md updates | verify: `supabase db push` up to date; backend 46/46 passed; frontend lint + build passed; Railway deploy `57461038` exposed `/api/integrations`, `/api/webhooks`, `/api/events/import-ics`; TestSprite BE `953196ac` passed | failure: first Railway MCP deploy from `backend/` failed (missing `backend/` in snapshot); live API stayed on stale commit `77299c0` until `railway up` from repo root; `INTEGRATION_*` keys were unset on Railway | fix: deploy from repo root, set `INTEGRATION_ENCRYPTION_KEY` + `INTEGRATION_SIGNING_KEY`, retry after successful `57461038` deploy - [2026-07-05 #21] maker: fix category drag-to-chip persistence — FullCalendar's `interactionPlugin` intercepts native drag events causing `dataTransfer.getData()` to return empty; added `categoryDragEventIdRef` useRef fallback, `data-timeora-event-id`/`data-timeora-category` stable DOM attributes, `eventDidMount` attribute injection; updated all 3 custom test scripts to use JS `evaluate` click to bypass TestSprite cloud viewport clipping on modal dialogs; commits `14ac160`→`59cccdc`→`1a97061`→`65aa666`→`cf8be26`→`617a577`→`425f5c7`→`0fc65a4` deployed to Vercel + Railway | verify: (A) category filter+drag `f0efe198` — v7 run `e05e68f9` failed "did not persist", v8 run `ad469021` failed "attribute still focus", v9 run `d49b50a0` **passed 25/25**; (B) event templates `244cf4f2` — v3 run `2573ea72` failed "outside viewport", v5 run `5f667347` failed "toast not found", v7 run `bc363565` **passed 22/22**; (C) today agenda `1e2bd9a6` — v2 run `107ca59e` failed "count 0", v3 run `7f8025f9` **passed 27/27** | failure: (A) useRef alone insufficient — Playwright `drag_to` uses pointer events intercepted by FullCalendar, so dispatched native `DragEvent` with `DataTransfer` via `page.evaluate`; after API persist succeeded the `data-timeora-category` DOM attribute didn't update because `eventDidMount` only fires on mount not re-render, fixed by reloading page; (B) `Save as Template` and `Hapus` buttons clipped by modal overflow — `click(force=True)` still rejected, `evaluate("el => el.click()")` blocked on `confirm()`, solved with `setTimeout` async click; toast used smart quotes `“/”` — switched to substring match; (C) `Simpan Event` had same viewport clipping — applied same JS click pattern | fix: iterative — 3 rounds for category (drag mechanism → attribute timing → test reload), 4 rounds for templates (scroll → JS click → dispatch → async setTimeout), 1 round for agenda (JS click all buttons); all three tests terminal **passed** +- [2026-07-06 #22] maker: production-stability branch `codex/fix-calendar-ai-mobile` — hardened frontend API calls with timeout/auth-expiry cleanup, added rich event fields (`description`, `location_url`, `priority`, `tags`, `reminder_minutes`) through backend/database/ICS/frontend, replaced the modal command bar with persistent AI calendar chat, added structured clarification choices and native assistant tools for create/delete/reschedule/update, event hover/tap previews, safe meeting links, Gmail search links, right-click plus Android overflow actions, foreground reminders with in-app fallback, and Android safe-area/touch-target polish | verify: backend `python -m unittest discover -s tests -v` **54/54 passed**; frontend `npm test` **14/14 passed**; `npm run lint` **0 errors / 13 warnings**; `npx tsc --noEmit` **passed**; `npm run build` **passed** | failure: Vercel-facing calendar/AI use was fragile because frontend fetches could hang or retain stale auth, AI cancel/reschedule could silently pick the wrong ambiguous event, event details lacked safe meeting/reminder metadata, Android had hover-only/context-only actions, and rendered QA caught a desktop dialog centering regression from an overly broad `sm:static` bottom-sheet class | fix: added request aborts and normalized API errors, explicit auth-expired event, structured AI clarification, confirmation-only native tools, additive event-details migration, safe URL validation, touch-visible action menus, notification fallback, Android-safe dialog classes, and local regression tests for every new behavior diff --git a/README.md b/README.md index 4026bf8..ed9c348 100644 --- a/README.md +++ b/README.md @@ -20,7 +20,7 @@ ### Natural-language scheduling and assistant -The Command Bar understands Indonesian and English. It can create, query, +The AI calendar chat understands Indonesian and English. It can create, query, reschedule, cancel, and find free slots from commands such as: - *"Jadwalkan meeting tim marketing besok jam 2 siang selama 45 menit"* @@ -32,14 +32,27 @@ OpenAI/OpenRouter is used when available. A deterministic bilingual parser takes over during provider outages and clearly marks the result as an offline parse. +The chat keeps session history, shows typing/confirmation feedback, returns +structured event cards for calendar questions, and asks a clarification question +when a cancel/reschedule command matches more than one event. Calendar mutations +run through native backend tools and still require explicit confirmation. + ### Calendar and scheduling intelligence - Weekly/day calendar with create, read, update, delete, drag, and resize +- Rich event details: description, meeting URL, priority, tags, and reminders +- Hover/tap event previews with safe meeting links and Gmail search links +- Right-click event actions plus Android-visible overflow actions for edit, + delete, and Ask AI - Conflict detection on create and update, including explained alternative slots - Daily, weekday, weekly, and monthly recurring events - Soft delete with one-click Undo - `.ics` export for Google Calendar, Apple Calendar, and Outlook - `.ics` import with duplicate UID and conflict protection +- Foreground browser notifications with in-app fallback reminders. Gmail support + is intentionally limited to a pre-filled Gmail search link; automatic Gmail + meeting-link extraction needs a connected Gmail OAuth scope and is not enabled + in the standalone web app. ### Integration foundation @@ -87,7 +100,8 @@ This project was built with a strict **write → verify → fail → fix → ver | Layer | Coverage | Current local result | |---|---|---| -| Backend unit | JWT security, bilingual parsing, conflict ranking, recurrence, analytics, availability, ICS, integration security | **46/46 passed** | +| Backend unit | JWT security, bilingual parsing, conflict ranking, recurrence, analytics, availability, ICS, integration security, event details, native assistant tools | **54/54 passed** | +| Frontend unit | API hardening, calendar action menus, assistant chat, reminder scheduler, notification fallback, event dialog positioning | **14/14 passed** | | Frontend static | ESLint + strict TypeScript | **Passed** | | Frontend build | Next.js production bundle | **Passed** | | TestSprite backend | Health, DB, signed JWT, forged-JWT rejection, parse, availability | Final gate | @@ -194,6 +208,7 @@ python -m unittest discover -s tests -v # Frontend cd frontend +npm test npm run lint npx tsc --noEmit npm run build diff --git a/backend/app/config.py b/backend/app/config.py index 0a064ba..9ac624a 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -12,6 +12,7 @@ class Settings(BaseSettings): SUPABASE_JWT_SECRET: str = "" SUPABASE_SERVICE_ROLE_KEY: str = "" + OPENROUTER_API_KEY: str = "" OPENROUTE_API_KEY: str = "" OPENROUTER_MODEL: str = "google/gemma-4-31b-it:free" OPENAI_API_KEY: str = "" @@ -29,6 +30,10 @@ class Settings(BaseSettings): CORS_ORIGINS: str = "http://localhost:3000,http://127.0.0.1:3000" + @property + def openrouter_api_key(self) -> str: + return self.OPENROUTER_API_KEY or self.OPENROUTE_API_KEY + settings = Settings() diff --git a/backend/app/core/assistant_tools.py b/backend/app/core/assistant_tools.py new file mode 100644 index 0000000..25dfabf --- /dev/null +++ b/backend/app/core/assistant_tools.py @@ -0,0 +1,108 @@ +from __future__ import annotations + +from datetime import date, time + +from fastapi import HTTPException, status +from pydantic import ValidationError + +from app import data_access +from app.event_ids import base_event_id +from app.models import AssistantRequest, EventCreate, EventUpdate + + +def _time_value(value: str | None) -> time | None: + if not value: + return None + try: + parts = value.split(":") + return time( + int(parts[0]), + int(parts[1]), + int(parts[2]) if len(parts) > 2 else 0, + ) + except (TypeError, ValueError, IndexError): + return None + + +def _date_value(value: str | None) -> date | None: + if not value: + return None + try: + return date.fromisoformat(value[:10]) + except (TypeError, ValueError): + return None + + +def _validation_error(error: ValidationError) -> HTTPException: + first_error = error.errors()[0] if error.errors() else {} + field = ".".join(str(item) for item in first_error.get("loc", [])) + message = first_error.get("msg", "Invalid value") + detail = f"Invalid event data: {field} {message}".strip() + return HTTPException(status_code=400, detail=detail) + + +def _event_create_payload(payload: dict) -> dict: + normalized = dict(payload) + if "recurrence_rule" not in normalized and "recurrence" in normalized: + normalized["recurrence_rule"] = normalized["recurrence"] + return normalized + + +async def execute_calendar_tool(user_id: str, body: AssistantRequest): + action = body.action + if not action: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="action is required when confirm=true", + ) + + if action == "create": + if not body.event_data: + raise HTTPException(status_code=400, detail="event_data is required for create") + try: + event_data = EventCreate.model_validate(_event_create_payload(body.event_data)) + except ValidationError as exc: + raise _validation_error(exc) from exc + return "create", await data_access.create_event( + user_id, + event_data, + ) + + if not body.event_id: + raise HTTPException(status_code=400, detail="event_id is required for this action") + event_id = base_event_id(body.event_id) + + if action in {"cancel", "delete"}: + await data_access.delete_event(event_id, user_id) + return "cancel", {"event_id": event_id, "deleted": True} + + if action == "reschedule": + new_time = _time_value(body.new_time) + new_date = _date_value(body.new_date) + if new_date is None or new_time is None: + raise HTTPException( + status_code=400, + detail="new_date and new_time are required for reschedule", + ) + updated = await data_access.update_event( + event_id, + user_id, + EventUpdate(date=new_date, start_time=new_time), + ) + return "reschedule", updated + + if action in {"edit", "update"}: + if not body.event_data: + raise HTTPException(status_code=400, detail="event_data is required for update") + try: + event_data = EventUpdate.model_validate(body.event_data) + except ValidationError as exc: + raise _validation_error(exc) from exc + updated = await data_access.update_event( + event_id, + user_id, + event_data, + ) + return "update", updated + + raise HTTPException(status_code=400, detail=f"Unknown action: {action}") diff --git a/backend/app/core/conflicts.py b/backend/app/core/conflicts.py index f73de4d..e159486 100644 --- a/backend/app/core/conflicts.py +++ b/backend/app/core/conflicts.py @@ -8,6 +8,8 @@ from datetime import date, datetime, time, timedelta +MINUTES_PER_DAY = 24 * 60 + def _to_minutes(t: time) -> int: """Convert a time to minutes since midnight.""" @@ -20,14 +22,27 @@ def _from_minutes(m: int) -> time: return time(m // 60, m % 60) -def _event_range(ev: dict) -> tuple[int, int]: - """Return (start_min, end_min) for an event dict.""" +def _event_date(ev: dict) -> date | None: + value = ev.get("date") + if isinstance(value, date): + return value + if isinstance(value, str): + return date.fromisoformat(value[:10]) + return None + + +def _event_range(ev: dict, reference_date: date | None = None) -> tuple[int, int]: + """Return (start_min, end_min), optionally relative to a reference date.""" st = ev["start_time"] if isinstance(st, str): parts = st.split(":") start_min = int(parts[0]) * 60 + int(parts[1]) else: start_min = _to_minutes(st) + if reference_date is not None: + ev_date = _event_date(ev) + if ev_date is not None: + start_min += (ev_date - reference_date).days * MINUTES_PER_DAY end_min = start_min + int(ev["duration_minutes"]) return start_min, end_min @@ -56,13 +71,8 @@ def check_conflicts( for ev in events: if exclude_id and str(ev.get("id")) == str(exclude_id): continue - ev_date = ev.get("date") - if isinstance(ev_date, str): - ev_date = date.fromisoformat(ev_date) - if ev_date != new_date: - continue - ev_start, ev_end = _event_range(ev) + ev_start, ev_end = _event_range(ev, reference_date=new_date) if ev_start < new_end_min and ev_end > new_start_min: conflicts.append(ev) return conflicts @@ -84,18 +94,6 @@ def find_alternatives( start_time (str HH:MM), duration_minutes, reason (str) Lunch hour 12:00–13:00 slots are deprioritised (moved to end). """ - # Gather occupied ranges (with buffer) for the requested date - occupied: list[tuple[int, int]] = [] - for ev in events: - ev_date = ev.get("date") - if isinstance(ev_date, str): - ev_date = date.fromisoformat(ev_date) - if ev_date != requested_date: - continue - s, e = _event_range(ev) - occupied.append((s - buffer_minutes, e + buffer_minutes)) - occupied.sort() - # Scan candidates in 15-min increments requested_min = _to_minutes(requested_time) candidates: list[dict] = [] @@ -105,12 +103,13 @@ def find_alternatives( slot_start = candidate_min slot_end = candidate_min + duration - # Check overlap with any occupied range - free = True - for occ_start, occ_end in occupied: - if slot_start < occ_end and slot_end > occ_start: - free = False - break + free = not check_conflicts( + events, + requested_date, + _from_minutes(slot_start), + duration, + buffer_minutes=buffer_minutes, + ) if free: # Build reason diff --git a/backend/app/core/ics_export.py b/backend/app/core/ics_export.py index ecf41bc..81438b6 100644 --- a/backend/app/core/ics_export.py +++ b/backend/app/core/ics_export.py @@ -7,9 +7,27 @@ from __future__ import annotations -from datetime import date, datetime, time, timedelta +from datetime import UTC, date, datetime, time, timedelta from uuid import uuid4 +_RECURRENCE_BYDAY = { + "senin": "MO", + "monday": "MO", + "selasa": "TU", + "tuesday": "TU", + "rabu": "WE", + "wednesday": "WE", + "kamis": "TH", + "thursday": "TH", + "jumat": "FR", + "jum'at": "FR", + "friday": "FR", + "sabtu": "SA", + "saturday": "SA", + "minggu": "SU", + "sunday": "SU", +} + def _format_dt(d: date, t: time) -> str: """Format a date + time as an iCalendar DATETIME value (local time).""" @@ -27,6 +45,26 @@ def _escape(text: str) -> str: ) +def _rrule(rule: str | None) -> str | None: + if not rule: + return None + normalized = rule.strip().lower() + if normalized == "daily": + return "FREQ=DAILY" + if normalized == "monthly": + return "FREQ=MONTHLY" + if normalized == "weekdays": + return "FREQ=WEEKLY;BYDAY=MO,TU,WE,TH,FR" + if normalized == "weekly": + return "FREQ=WEEKLY" + if normalized.startswith("weekly:"): + day = normalized.split(":", 1)[1] + byday = _RECURRENCE_BYDAY.get(day) + if byday: + return f"FREQ=WEEKLY;BYDAY={byday}" + return None + + def generate_ics( events: list[dict], calendar_name: str = "Timeora", @@ -44,7 +82,7 @@ def generate_ics( "METHOD:PUBLISH", ] - now_stamp = datetime.utcnow().strftime("%Y%m%dT%H%M%SZ") + now_stamp = datetime.now(UTC).strftime("%Y%m%dT%H%M%SZ") for ev in events: ev_date = ev.get("date") @@ -63,6 +101,9 @@ def generate_ics( uid = ev.get("id", str(uuid4())) title = _escape(ev.get("title", "Untitled")) participants = ev.get("participants", "") + description = ev.get("description", "") + location_url = ev.get("location_url") + recurrence_rule = _rrule(ev.get("recurrence_rule")) lines.append("BEGIN:VEVENT") lines.append(f"UID:{uid}") @@ -70,8 +111,15 @@ def generate_ics( lines.append(f"DTSTART:{_format_dt(ev_date, ev_time)}") lines.append(f"DTEND:{_format_dt(end_dt.date(), end_dt.time())}") lines.append(f"SUMMARY:{title}") + description_parts = [description] if description else [] if participants: - lines.append(f"DESCRIPTION:Participants: {_escape(participants)}") + description_parts.append(f"Participants: {participants}") + if description_parts: + lines.append(f"DESCRIPTION:{_escape(chr(10).join(description_parts))}") + if location_url: + lines.append(f"URL:{location_url}") + if recurrence_rule: + lines.append(f"RRULE:{recurrence_rule}") lines.append("END:VEVENT") lines.append("END:VCALENDAR") diff --git a/backend/app/core/ics_import.py b/backend/app/core/ics_import.py index 96ee890..fa23eee 100644 --- a/backend/app/core/ics_import.py +++ b/backend/app/core/ics_import.py @@ -117,6 +117,8 @@ def _recurrence_rule(value: str | None) -> str | None: "WE": "wednesday", "TH": "thursday", "FR": "friday", + "SA": "saturday", + "SU": "sunday", } if days == ["MO", "TU", "WE", "TH", "FR"]: return "weekdays" @@ -183,6 +185,10 @@ def parse_ics( categories = properties.get("CATEGORIES", [({}, "")])[0][1] category = categories.split(",", 1)[0].strip().lower() or None recurrence = properties.get("RRULE", [({}, "")])[0][1] + description = _unescape( + properties.get("DESCRIPTION", [({}, "")])[0][1] + ).strip() + location_url = properties.get("URL", [({}, "")])[0][1].strip() or None events.append( EventCreate( @@ -193,6 +199,8 @@ def parse_ics( participants=", ".join(attendees), recurrence_rule=_recurrence_rule(recurrence), category=category, + description=description, + location_url=location_url, external_ids={"ics": uid} if uid else {}, ) ) diff --git a/backend/app/core/nlparser.py b/backend/app/core/nlparser.py index eeb9cae..955d79b 100644 --- a/backend/app/core/nlparser.py +++ b/backend/app/core/nlparser.py @@ -60,6 +60,19 @@ r"\bbatalkan\b", r"\bcancel\b", r"\bhapus\b", r"\bdelete\b", r"\bremove\b", ]), + ("update", [ + r"\bupdate\b", r"\bedit\b", + r"\b(?:add|set|change|ubah|tambahkan?|kasih)\b.+\b(?:description|deskripsi|details?|notes?|catatan)\b", + r"\b(?:tag|tags|label|labeli)\b", + r"\b(?:set|change|ubah|tambahkan?|kasih)\b.+\b(?:tags?|labels?|tagar)\b", + r"\b(?:remind me|ingatkan(?:\s+saya)?|beri pengingat)\b", + r"\b(?:set|change|ubah|tambahkan?|kasih)\b.+\b(?:reminder|pengingat)\b", + r"\bmake\b.+\b(?:important|normal|low priority|not important)\b", + r"\bmark\b.+\b(?:important|normal|low priority|not important)\b", + r"\bset\b.+\bpriority\b", + r"\bjadikan\b", r"\btandai\b", + r"\bubah\s+(?!jadwal\b)", + ]), ("query", [ r"\bapa jadwal\b", r"\bwhat do i have\b", r"\bjadwal hari\b", r"\bshow schedule\b", r"\bshow my\b", r"\blihat jadwal\b", @@ -120,6 +133,22 @@ def _parse_date(text: str, today: date) -> tuple[date | None, str]: """Try to extract a date from *text*. Returns (date, cleaned_text).""" low = text.lower() + def resolve_month_day(day_num: int, month_num: int, explicit_year: str | None) -> date | None: + if explicit_year: + try: + return date(int(explicit_year), month_num, day_num) + except ValueError: + return None + + for candidate_year in range(today.year, today.year + 5): + try: + result_date = date(candidate_year, month_num, day_num) + except ValueError: + continue + if result_date >= today: + return result_date + return None + # Relative dates — ID if re.search(r"\bhari\s+ini\b", low): return today, re.sub(r"\bhari\s+ini\b", "", text, flags=re.I).strip() @@ -167,28 +196,22 @@ def _parse_date(text: str, today: date) -> tuple[date | None, str]: if m: day_num = int(m.group(1)) month_name = m.group(2) - year = int(m.group(3)) if m.group(3) else today.year if month_name in _MONTHS: - try: - result_date = date(year, _MONTHS[month_name], day_num) + result_date = resolve_month_day(day_num, _MONTHS[month_name], m.group(3)) + if result_date is not None: cleaned = text[:m.start()] + text[m.end():] return result_date, cleaned.strip() - except ValueError: - pass # Month DD [, YYYY] m = re.search(r"\b([a-zA-Z]+)\s+(\d{1,2})(?:\s*,?\s*(\d{4}))?\b", low) if m: month_name = m.group(1) day_num = int(m.group(2)) - year = int(m.group(3)) if m.group(3) else today.year if month_name in _MONTHS: - try: - result_date = date(year, _MONTHS[month_name], day_num) + result_date = resolve_month_day(day_num, _MONTHS[month_name], m.group(3)) + if result_date is not None: cleaned = text[:m.start()] + text[m.end():] return result_date, cleaned.strip() - except ValueError: - pass # ISO: YYYY-MM-DD m = re.search(r"\b(\d{4})-(\d{2})-(\d{2})\b", text) @@ -203,6 +226,14 @@ def _parse_date(text: str, today: date) -> tuple[date | None, str]: return None, text +def _safe_time(hour: int, minute: int) -> time | None: + if not 0 <= hour <= 23: + return None + if not 0 <= minute <= 59: + return None + return time(hour, minute) + + def _parse_time(text: str) -> tuple[time | None, str]: """Try to extract a time from *text*.""" low = text.lower() @@ -213,15 +244,16 @@ def _parse_time(text: str) -> tuple[time | None, str]: h = int(m.group(1)) mins = int(m.group(2)) if m.group(2) else 0 period = m.group(3) - if period == "siang" and 1 <= h <= 5: + if period == "malam" and h == 12: + h = 0 + elif period == "siang" and 1 <= h <= 5: h += 12 elif period in ("sore", "malam") and h < 12: h += 12 elif period == "pagi" and h == 12: h = 0 - h = min(h, 23) cleaned = text[:m.start()] + text[m.end():] - return time(h, mins), cleaned.strip() + return _safe_time(h, mins), cleaned.strip() # "at 3pm", "at 10:30am", "at 14:00" m = re.search(r"\bat\s+(\d{1,2})(?::(\d{2}))?\s*(am|pm)?\b", low) @@ -233,9 +265,8 @@ def _parse_time(text: str) -> tuple[time | None, str]: h += 12 elif period == "am" and h == 12: h = 0 - h = min(h, 23) cleaned = text[:m.start()] + text[m.end():] - return time(h, mins), cleaned.strip() + return _safe_time(h, mins), cleaned.strip() # Bare "3pm", "10am", "3:30pm" m = re.search(r"\b(\d{1,2})(?::(\d{2}))?\s*(am|pm)\b", low) @@ -247,17 +278,16 @@ def _parse_time(text: str) -> tuple[time | None, str]: h += 12 elif period == "am" and h == 12: h = 0 - h = min(h, 23) cleaned = text[:m.start()] + text[m.end():] - return time(h, mins), cleaned.strip() + return _safe_time(h, mins), cleaned.strip() # "pukul 10:00", "pukul 14.30" m = re.search(r"\bpukul\s+(\d{1,2})[\.:]\s*(\d{2})\b", low) if m: - h = min(int(m.group(1)), 23) + h = int(m.group(1)) mins = int(m.group(2)) cleaned = text[:m.start()] + text[m.end():] - return time(h, mins), cleaned.strip() + return _safe_time(h, mins), cleaned.strip() return None, text @@ -326,6 +356,135 @@ def _parse_recurrence(text: str) -> tuple[str | None, str]: return None, text +def _parse_update_data(text: str) -> tuple[dict[str, Any], str]: + """Extract event update fields and return cleaned text for title matching.""" + event_data: dict[str, Any] = {} + remaining = text + + def parse_minutes(raw_number: str, raw_unit: str) -> int: + value = int(raw_number) + return value * 60 if raw_unit.lower() in {"hour", "hours", "jam"} else value + + def split_tags(raw_tags: str) -> list[str]: + tags: list[str] = [] + seen: set[str] = set() + for raw_tag in re.split(r"[,;]|\band\b|\bdan\b", raw_tags, flags=re.I): + tag = raw_tag.strip().strip("#.,;:-–— ") + key = tag.casefold() + if not tag or key in seen: + continue + seen.add(key) + tags.append(tag) + return tags + + field_first_description = re.search( + r"^\s*(?:add|set|update|edit|change|ubah|tambahkan?|kasih)?\s*" + r"(?:description|deskripsi|details?|notes?|catatan)\s+" + r"(?P.+?)\s+(?:to|for|ke|untuk)\s+(?P.+)$", + remaining, + flags=re.I, + ) + if field_first_description: + event_data["description"] = field_first_description.group("description").strip(" .,:;") + remaining = field_first_description.group("title").strip() + + if not event_data: + title_first_description = re.search( + r"\b(?:description|deskripsi|details?|notes?|catatan)\b\s*" + r"(?:to|as|jadi|menjadi|dengan|:)?\s*(?P<description>.+)$", + remaining, + flags=re.I, + ) + if title_first_description: + event_data["description"] = title_first_description.group("description").strip(" .,:;") + remaining = remaining[:title_first_description.start()].strip() + + if not event_data: + tag_command = re.search( + r"^\s*(?:tag|tags|label|labeli|tandai)\s+(?P<title>.+?)\s+" + r"(?:with|as|dengan|sebagai|:)\s+(?P<tags>.+)$", + remaining, + flags=re.I, + ) + if tag_command: + tags = split_tags(tag_command.group("tags")) + if tags: + event_data["tags"] = tags + remaining = tag_command.group("title").strip() + + if not event_data: + title_first_tags = re.search( + r"\b(?:tags?|labels?|tagar)\b\s*(?:to|as|with|dengan|:)?\s*(?P<tags>.+)$", + remaining, + flags=re.I, + ) + if title_first_tags: + tags = split_tags(title_first_tags.group("tags")) + if tags: + event_data["tags"] = tags + remaining = remaining[:title_first_tags.start()].strip() + + if not event_data: + reminder_before = re.search( + r"^\s*(?:remind me|ingatkan(?:\s+saya)?|beri pengingat)\s+" + r"(?P<number>\d+)\s*(?P<unit>minutes?|mins?|menit|hours?|jam)\s+" + r"(?:before|sebelum)\s+(?P<title>.+)$", + remaining, + flags=re.I, + ) + if reminder_before: + event_data["reminder_minutes"] = parse_minutes( + reminder_before.group("number"), + reminder_before.group("unit"), + ) + remaining = reminder_before.group("title").strip() + + if not event_data: + title_first_reminder = re.search( + r"\b(?:reminder|pengingat|ingatkan)\b\s*" + r"(?:to|for|at|before|sebelum|:)?\s*" + r"(?P<number>\d+)\s*(?P<unit>minutes?|mins?|menit|hours?|jam)\b", + remaining, + flags=re.I, + ) + if title_first_reminder: + event_data["reminder_minutes"] = parse_minutes( + title_first_reminder.group("number"), + title_first_reminder.group("unit"), + ) + remaining = f"{remaining[:title_first_reminder.start()]} {remaining[title_first_reminder.end():]}" + + priority_patterns = [ + (r"\b(?:not important|not urgent|tidak penting|gak penting|ga penting|nggak penting|low priority|rendah)\b", "low"), + (r"\b(?:important|urgent|penting|prioritas tinggi)\b", "important"), + (r"\b(?:normal|biasa)\b", "normal"), + ] + if not event_data: + for pattern, priority in priority_patterns: + m = re.search(pattern, remaining, flags=re.I) + if m: + event_data["priority"] = priority + remaining = f"{remaining[:m.start()]} {remaining[m.end():]}" + break + + if event_data: + cleanup_patterns = [ + r"\bmake\b", r"\bmark\b", r"\bset\b", r"\bjadikan\b", + r"\btandai\b", r"\bubah\b", r"\bedit\b", r"\bupdate\b", + r"\bpriority\b", r"\bprioritas(?:nya)?\b", r"\bas\b", + r"\bdescription\b", r"\bdeskripsi\b", r"\bdetails?\b", + r"\bnotes?\b", r"\bcatatan\b", r"\btags?\b", r"\blabels?\b", + r"\btagar\b", r"\breminder\b", r"\bpengingat\b", + r"\bto\b", r"\bfor\b", r"\bwith\b", r"\bdengan\b", + r"\bjadi\b", r"\bmenjadi\b", + ] + for pattern in cleanup_patterns: + remaining = re.sub(pattern, " ", remaining, flags=re.I) + + remaining = re.sub(r"\s+", " ", remaining).strip() + return event_data, remaining + + def _extract_title(text: str) -> str: """Clean up remaining text to use as event title.""" # Remove common prefixes @@ -334,6 +493,7 @@ def _extract_title(text: str) -> str: r"\badd\b", r"\btambah\b", r"\bset\b", r"\bbook\b", r"\bpindahkan\b", r"\breschedule\b", r"\bmove\b", r"\bbatalkan\b", r"\bcancel\b", r"\bhapus\b", + r"\bupdate\b", r"\bedit\b", r"\bjadikan\b", r"\btandai\b", ] result = text for p in prefixes: @@ -389,17 +549,22 @@ def parse(text: str, today: date | None = None) -> dict[str, Any]: if intent == "create": warnings.append("No time found — defaulting to 09:00") - # 5. Duration + # 5. Update data + event_data: dict[str, Any] = {} + if intent == "update": + event_data, remaining = _parse_update_data(remaining) + + # 6. Duration duration, remaining = _parse_duration(remaining) if duration is None: duration = 60 if intent == "create": warnings.append("No duration found — defaulting to 60 minutes") - # 6. Title from remaining text + # 7. Title from remaining text title = _extract_title(remaining) - # 7. Compute ISO datetimes for convenience + # 8. Compute ISO datetimes for convenience if parsed_date is not None: start_dt = datetime.combine(parsed_date, parsed_time) end_dt = start_dt + timedelta(minutes=duration) @@ -411,7 +576,7 @@ def parse(text: str, today: date | None = None) -> dict[str, Any]: end_at = None date_value = None - return { + result = { "intent": intent, "title": title, "date": date_value, @@ -424,3 +589,6 @@ def parse(text: str, today: date | None = None) -> dict[str, Any]: "start_at": start_at, "end_at": end_at, } + if event_data: + result["event_data"] = event_data + return result diff --git a/backend/app/data_access.py b/backend/app/data_access.py index ec46cce..d4cdec0 100644 --- a/backend/app/data_access.py +++ b/backend/app/data_access.py @@ -1,5 +1,5 @@ import json -from datetime import date, datetime, time, timedelta +from datetime import date, time from app.core import conflicts as conflicts_engine from app.database import ensure_pool @@ -9,6 +9,7 @@ EventResponse, EventUpdate, ) +from app.storage_normalization import json_object, string_list from app import supabase_store @@ -35,9 +36,6 @@ async def upsert_user(user_id: str, email: str) -> None: def _row_to_event(row) -> EventResponse: - external_ids = row.get("external_ids") or {} - if isinstance(external_ids, str): - external_ids = json.loads(external_ids) return EventResponse( id=str(row["id"]), user_id=str(row["user_id"]), @@ -48,51 +46,17 @@ def _row_to_event(row) -> EventResponse: participants=row.get("participants", ""), recurrence_rule=row.get("recurrence_rule"), category=row.get("category"), - external_ids=external_ids, + description=row.get("description") or "", + location_url=row.get("location_url"), + priority=row.get("priority") or "normal", + tags=string_list(row.get("tags")), + reminder_minutes=row.get("reminder_minutes"), + external_ids=json_object(row.get("external_ids")), sync_status=row.get("sync_status") or "not_synced", last_synced_at=row.get("last_synced_at"), ) -async def _check_conflict_sql( - conn, user_id: str, event_date, start_time, duration_minutes, exclude_id=None -): - new_end = ( - datetime.combine(event_date, start_time) + timedelta(minutes=duration_minutes) - ).time() - query = """ - SELECT id, title, start_time, duration_minutes - FROM events - WHERE user_id = $1 AND date = $2 - AND (deleted_at IS NULL) - AND start_time < $3 - AND (start_time + (duration_minutes::text || ' minutes')::interval) > $4 - """ - params = [user_id, event_date, new_end, start_time] - if exclude_id: - query += " AND id != $5" - params.append(exclude_id) - return await conn.fetchrow(query, *params) - - -async def _generate_alternatives_sql( - conn, user_id: str, event_date, duration_minutes, count=3 -) -> list[AlternativeSlot]: - slots: list[AlternativeSlot] = [] - candidate = time(8, 0) - while len(slots) < count and candidate < time(22, 0): - conflict = await _check_conflict_sql( - conn, user_id, event_date, candidate, duration_minutes - ) - if not conflict: - slots.append( - AlternativeSlot(start_time=candidate, duration_minutes=duration_minutes) - ) - candidate_dt = datetime.combine(event_date, candidate) + timedelta(minutes=30) - candidate = candidate_dt.time() - return slots - - async def _events_as_dicts(user_id: str) -> list[dict]: events = await list_events(user_id) return [e.model_dump(mode="json") for e in events] @@ -223,9 +187,10 @@ async def create_event(user_id: str, body: EventCreate) -> EventResponse: """ INSERT INTO events ( user_id, title, date, start_time, duration_minutes, - participants, recurrence_rule, category, external_ids + participants, recurrence_rule, category, description, + location_url, priority, tags, reminder_minutes, external_ids ) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14) RETURNING * """, user_id, @@ -236,6 +201,11 @@ async def create_event(user_id: str, body: EventCreate) -> EventResponse: body.participants, body.recurrence_rule, body.category, + body.description, + body.location_url, + body.priority, + body.tags, + body.reminder_minutes, json.dumps(body.external_ids), ) return _row_to_event(row) diff --git a/backend/app/event_ids.py b/backend/app/event_ids.py new file mode 100644 index 0000000..1cae7da --- /dev/null +++ b/backend/app/event_ids.py @@ -0,0 +1,8 @@ +import re + + +_RECURRING_INSTANCE_SUFFIX = re.compile(r"_\d{4}-\d{2}-\d{2}$") + + +def base_event_id(event_id: str) -> str: + return _RECURRING_INSTANCE_SUFFIX.sub("", event_id) diff --git a/backend/app/models.py b/backend/app/models.py index 699d86c..a53a99b 100644 --- a/backend/app/models.py +++ b/backend/app/models.py @@ -1,7 +1,86 @@ from datetime import date as Date, datetime as DateTime, time as Time from typing import Literal -from pydantic import BaseModel, Field +from urllib.parse import urlparse + +from pydantic import BaseModel, Field, field_validator + +_KNOWN_CATEGORIES = { + "meeting", + "personal", + "focus", + "health", + "social", + "other", +} + + +def _normalize_tags(value: list[str] | None) -> list[str] | None: + if value is None: + return None + normalized: list[str] = [] + seen: set[str] = set() + for raw_tag in value: + tag = raw_tag.strip() + key = tag.casefold() + if not tag or key in seen: + continue + seen.add(key) + normalized.append(tag) + return normalized + + +def _normalize_category(value: str | None) -> str | None: + if value is None: + return None + normalized = value.strip().lower() + if not normalized: + return None + if normalized in _KNOWN_CATEGORIES: + return normalized + return "other" + + +def _normalize_title(value: str | None) -> str | None: + if value is None: + return None + normalized = value.strip() + if not normalized: + raise ValueError("title cannot be blank") + return normalized + + +def _normalize_request_text(value: str | None) -> str | None: + if value is None: + return None + normalized = value.strip() + if not normalized: + raise ValueError("text cannot be blank") + return normalized + + +def _validate_location_url(value: str | None) -> str | None: + if value is None: + return None + normalized = value.strip() + if not normalized: + return None + if any(char.isspace() for char in normalized): + raise ValueError("location_url must use http or https") + if "://" not in normalized: + if normalized.startswith("//"): + normalized = f"https:{normalized}" + else: + host = normalized.split("/", 1)[0] + if "." not in host: + raise ValueError("location_url must use http or https") + normalized = f"https://{normalized}" + parsed = urlparse(normalized) + if parsed.scheme not in {"http", "https"} or not parsed.netloc: + raise ValueError("location_url must use http or https") + if parsed.username or parsed.password: + raise ValueError("location_url must use http or https") + return normalized class EventCreate(BaseModel): @@ -12,8 +91,18 @@ class EventCreate(BaseModel): participants: str = "" recurrence_rule: str | None = None category: str | None = None + description: str = Field("", max_length=5000) + location_url: str | None = Field(None, max_length=2048) + priority: Literal["low", "normal", "important"] = "normal" + tags: list[str] = Field(default_factory=list, max_length=20) + reminder_minutes: int | None = Field(None, ge=0, le=10080) external_ids: dict[str, str] = Field(default_factory=dict) + _title = field_validator("title")(_normalize_title) + _location_url = field_validator("location_url")(_validate_location_url) + _tags = field_validator("tags")(_normalize_tags) + _category = field_validator("category")(_normalize_category) + class EventUpdate(BaseModel): title: str | None = Field(None, min_length=1, max_length=200) @@ -23,6 +112,16 @@ class EventUpdate(BaseModel): participants: str | None = None recurrence_rule: str | None = None category: str | None = None + description: str | None = Field(None, max_length=5000) + location_url: str | None = Field(None, max_length=2048) + priority: Literal["low", "normal", "important"] | None = None + tags: list[str] | None = Field(None, max_length=20) + reminder_minutes: int | None = Field(None, ge=0, le=10080) + + _title = field_validator("title")(_normalize_title) + _location_url = field_validator("location_url")(_validate_location_url) + _tags = field_validator("tags")(_normalize_tags) + _category = field_validator("category")(_normalize_category) class EventResponse(BaseModel): @@ -35,10 +134,17 @@ class EventResponse(BaseModel): participants: str recurrence_rule: str | None = None category: str | None = None + description: str = "" + location_url: str | None = None + priority: Literal["low", "normal", "important"] = "normal" + tags: list[str] = Field(default_factory=list) + reminder_minutes: int | None = None external_ids: dict[str, str] = Field(default_factory=dict) sync_status: str = "not_synced" last_synced_at: DateTime | None = None + _category = field_validator("category")(_normalize_category) + class ParsedEvent(BaseModel): title: str @@ -51,6 +157,8 @@ class ParsedEvent(BaseModel): class ParseRequest(BaseModel): text: str = Field(..., min_length=1, max_length=1000) + _text = field_validator("text")(_normalize_request_text) + class ConflictCheckRequest(BaseModel): date: Date @@ -124,6 +232,11 @@ class AssistantRequest(BaseModel): action: str | None = None new_date: str | None = None new_time: str | None = None + selected_event_id: str | None = None + context_event_id: str | None = None + event_data: dict | None = None + + _text = field_validator("text")(_normalize_request_text) class AssistantResponse(BaseModel): @@ -132,6 +245,9 @@ class AssistantResponse(BaseModel): message: str requires_confirmation: bool = False executed: bool = False + clarification: dict | None = None + events: list[dict] = Field(default_factory=list) + suggested_actions: list[str] = Field(default_factory=list) class InsightAction(BaseModel): diff --git a/backend/app/routers/analytics.py b/backend/app/routers/analytics.py index 5f3b61c..e27209b 100644 --- a/backend/app/routers/analytics.py +++ b/backend/app/routers/analytics.py @@ -35,7 +35,13 @@ def _events_as_dicts(events) -> list[dict]: def _reference_date(ref_date: str | None) -> date: if ref_date: - return date.fromisoformat(ref_date) + try: + return date.fromisoformat(ref_date) + except ValueError as exc: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="date must be a valid YYYY-MM-DD date", + ) from exc return datetime.now().date() @@ -114,4 +120,4 @@ async def apply_spread_load( f"{plan['to_day']} at {plan['start_time'].strftime('%H:%M')}." ), event=updated, - ) \ No newline at end of file + ) diff --git a/backend/app/routers/assistant.py b/backend/app/routers/assistant.py index 7357e4d..21acbf8 100644 --- a/backend/app/routers/assistant.py +++ b/backend/app/routers/assistant.py @@ -7,14 +7,15 @@ from __future__ import annotations -from datetime import date, datetime, time +from datetime import date, datetime, time, timedelta from fastapi import APIRouter, Depends, HTTPException, status from app.auth import get_current_user from app import data_access -from app.core import nlparser, conflicts as conflicts_engine -from app.models import AssistantRequest, AssistantResponse, EventUpdate +from app.core import assistant_tools, nlparser, conflicts as conflicts_engine +from app.event_ids import base_event_id +from app.models import AssistantRequest, AssistantResponse router = APIRouter() @@ -31,20 +32,45 @@ def _parse_date_value(value) -> date | None: if isinstance(value, date): return value if isinstance(value, str): - return date.fromisoformat(value[:10]) + try: + return date.fromisoformat(value[:10]) + except ValueError: + return None return None def _parse_time_value(value: str | None) -> time | None: if not value: return None - parts = value.split(":") - h, m = int(parts[0]), int(parts[1]) - s = int(parts[2]) if len(parts) > 2 else 0 - return time(h, m, s) + try: + parts = value.split(":") + h, m = int(parts[0]), int(parts[1]) + s = int(parts[2]) if len(parts) > 2 else 0 + return time(h, m, s) + except (TypeError, ValueError, IndexError): + return None + + +def _event_overlaps_date(ev: dict, target_date: date) -> bool: + event_date = _parse_date_value(ev.get("date")) + event_time = _parse_time_value(ev.get("start_time")) + if event_date is None or event_time is None: + return False + try: + duration = int(ev.get("duration_minutes") or 0) + except (TypeError, ValueError): + return False + if duration <= 0: + return False + event_start = datetime.combine(event_date, event_time) + event_end = event_start + timedelta(minutes=duration) + day_start = datetime.combine(target_date, time.min) + day_end = day_start + timedelta(days=1) + return event_start < day_end and event_end > day_start -def _find_matches(all_events, parsed: dict) -> list[dict]: + +def _find_matches(all_events, parsed: dict, *, use_date_filter: bool = True) -> list[dict]: """Match events by title substring and optional parsed date.""" title_query = (parsed.get("title") or "").lower().strip() target_date = _parse_date_value(parsed.get("date")) @@ -55,10 +81,8 @@ def _find_matches(all_events, parsed: dict) -> list[dict]: ev_title = (ev_dict.get("title") or "").lower() if title_query and title_query not in ev_title: continue - if target_date is not None: - ev_date = _parse_date_value(ev_dict.get("date")) - if ev_date != target_date: - continue + if use_date_filter and target_date is not None and not _event_overlaps_date(ev_dict, target_date): + continue matches.append(ev_dict) return matches @@ -82,8 +106,10 @@ async def assistant(body: AssistantRequest, user: dict = Depends(get_current_use if intent == "create": return AssistantResponse( intent="create", - result=parsed, - message="Use the parse endpoint to create events.", + result={"tool": "create", "event_data": parsed}, + message=f"Create \"{parsed.get('title', 'Event')}\"? Confirm to add it to your calendar.", + requires_confirmation=True, + suggested_actions=["confirm", "edit"], ) if intent == "query": @@ -93,10 +119,13 @@ async def assistant(body: AssistantRequest, user: dict = Depends(get_current_use return await _handle_find_slot(user, parsed) if intent == "cancel": - return await _handle_cancel(user, parsed) + return await _handle_cancel(user, parsed, body.selected_event_id or body.context_event_id) if intent == "reschedule": - return await _handle_reschedule(user, parsed) + return await _handle_reschedule(user, parsed, body.selected_event_id or body.context_event_id) + + if intent == "update": + return await _handle_update(user, parsed, body.selected_event_id or body.context_event_id) return AssistantResponse( intent=intent, @@ -106,48 +135,22 @@ async def assistant(body: AssistantRequest, user: dict = Depends(get_current_use async def _execute_confirmed(user: dict, body: AssistantRequest) -> AssistantResponse: - if not body.event_id or not body.action: - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail="event_id and action are required when confirm=true", - ) - - base_id = body.event_id.split("_")[0] - - if body.action == "cancel": - await data_access.delete_event(base_id, user["id"]) - return AssistantResponse( - intent="cancel", - result={"event_id": base_id, "deleted": True}, - message="Event cancelled.", - executed=True, - ) - - if body.action == "reschedule": - if not body.new_date or not body.new_time: - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail="new_date and new_time are required for reschedule", - ) - new_time = _parse_time_value(body.new_time) - if new_time is None: - raise HTTPException(status_code=400, detail="Invalid new_time") - updated = await data_access.update_event( - base_id, - user["id"], - EventUpdate( - date=date.fromisoformat(body.new_date[:10]), - start_time=new_time, - ), - ) - return AssistantResponse( - intent="reschedule", - result=_event_to_dict(updated), - message=f"Rescheduled to {body.new_date} {body.new_time}.", - executed=True, - ) - - raise HTTPException(status_code=400, detail=f"Unknown action: {body.action}") + intent, result = await assistant_tools.execute_calendar_tool(user["id"], body) + serialized = _event_to_dict(result) + messages = { + "create": "Event added to your calendar.", + "cancel": "Event cancelled.", + "reschedule": f"Event rescheduled to {body.new_date} {body.new_time}.", + "update": "Event updated.", + } + return AssistantResponse( + intent=intent, + result=serialized, + message=messages[intent], + executed=True, + events=[serialized] if isinstance(serialized, dict) and not serialized.get("deleted") else [], + suggested_actions=["open_event"] if intent != "cancel" else [], + ) async def _handle_query(user: dict, parsed: dict) -> AssistantResponse: @@ -158,11 +161,9 @@ async def _handle_query(user: dict, parsed: dict) -> AssistantResponse: all_events = await data_access.list_events(user["id"]) matching = [] for ev in all_events: - ev_date = _parse_date_value( - ev.date if hasattr(ev, "date") else ev.get("date") - ) - if ev_date == target_date: - matching.append(_event_to_dict(ev)) + ev_dict = _event_to_dict(ev) + if _event_overlaps_date(ev_dict, target_date): + matching.append(ev_dict) if not matching: return AssistantResponse( @@ -175,6 +176,8 @@ async def _handle_query(user: dict, parsed: dict) -> AssistantResponse: intent="query", result=matching, message=f"Found {len(matching)} event(s) on {target_date.isoformat()}.", + events=matching, + suggested_actions=["open_event", "find_free_slot"], ) @@ -188,8 +191,7 @@ async def _handle_find_slot(user: dict, parsed: dict) -> AssistantResponse: event_dicts = [_event_to_dict(ev) for ev in all_events] requested_time_str = parsed.get("start_time", "09:00") - parts = requested_time_str.split(":") - requested_time = time(int(parts[0]), int(parts[1])) + requested_time = _parse_time_value(requested_time_str) or time(9, 0) alternatives = conflicts_engine.find_alternatives( event_dicts, target_date, requested_time, duration, count=5 @@ -209,9 +211,56 @@ async def _handle_find_slot(user: dict, parsed: dict) -> AssistantResponse: ) -async def _handle_cancel(user: dict, parsed: dict) -> AssistantResponse: +def _clarification(matches: list[dict], action: str) -> AssistantResponse: + choices = [ + { + "id": item["id"], + "title": item.get("title", "Event"), + "date": item.get("date"), + "start_time": item.get("start_time"), + } + for item in matches + ] + return AssistantResponse( + intent=action, + result={"events": matches}, + message="I found more than one matching event. Which one did you mean?", + clarification={ + "type": "event_selection", + "prompt": "Which event did you mean?", + "choices": choices, + }, + events=matches, + suggested_actions=["select_event"], + ) + + +def _selected_match(matches: list[dict], selected_event_id: str | None) -> list[dict]: + if not selected_event_id: + return matches + base_id = base_event_id(selected_event_id) + return [item for item in matches if base_event_id(str(item.get("id", ""))) == base_id] + + +def _action_matches( + all_events, + parsed: dict, + selected_event_id: str | None = None, + *, + use_date_filter: bool = True, +) -> list[dict]: + if selected_event_id: + return _selected_match([_event_to_dict(ev) for ev in all_events], selected_event_id) + return _find_matches(all_events, parsed, use_date_filter=use_date_filter) + + +async def _handle_cancel( + user: dict, + parsed: dict, + selected_event_id: str | None = None, +) -> AssistantResponse: all_events = await data_access.list_events(user["id"]) - matches = _find_matches(all_events, parsed) + matches = _action_matches(all_events, parsed, selected_event_id) if not matches: return AssistantResponse( @@ -220,6 +269,9 @@ async def _handle_cancel(user: dict, parsed: dict) -> AssistantResponse: message=f"No event matching '{parsed.get('title', '')}' found to cancel.", ) + if len(matches) > 1: + return _clarification(matches, "cancel") + primary = matches[0] return AssistantResponse( intent="cancel", @@ -230,12 +282,23 @@ async def _handle_cancel(user: dict, parsed: dict) -> AssistantResponse: }, message=f"Cancel \"{primary.get('title', 'Event')}\"? Confirm to proceed.", requires_confirmation=True, + events=matches, + suggested_actions=["confirm", "cancel"], ) -async def _handle_reschedule(user: dict, parsed: dict) -> AssistantResponse: +async def _handle_reschedule( + user: dict, + parsed: dict, + selected_event_id: str | None = None, +) -> AssistantResponse: all_events = await data_access.list_events(user["id"]) - matches = _find_matches(all_events, parsed) + matches = _action_matches( + all_events, + parsed, + selected_event_id, + use_date_filter=False, + ) if not matches: return AssistantResponse( @@ -244,9 +307,40 @@ async def _handle_reschedule(user: dict, parsed: dict) -> AssistantResponse: message=f"No event matching '{parsed.get('title', '')}' found to reschedule.", ) + if len(matches) > 1: + return _clarification(matches, "reschedule") + primary = matches[0] new_date = parsed.get("date") new_time = parsed.get("start_time") + if not new_date or not new_time: + return AssistantResponse( + intent="reschedule", + result={"events": matches, "primary_event_id": primary["id"]}, + message=( + f"I found \"{primary.get('title', 'Event')}\", but I need the new date and time " + "before I can reschedule it." + ), + events=matches, + suggested_actions=["edit"], + ) + + new_date_value = _parse_date_value(new_date) + new_time_value = _parse_time_value(new_time) + if new_date_value is None or new_time_value is None: + return AssistantResponse( + intent="reschedule", + result={"events": matches, "primary_event_id": primary["id"]}, + message=( + f"I found \"{primary.get('title', 'Event')}\", but I need a valid new date and time " + "before I can reschedule it." + ), + events=matches, + suggested_actions=["edit"], + ) + + new_date = new_date_value.isoformat() + new_time = new_time_value.strftime("%H:%M:%S") return AssistantResponse( intent="reschedule", result={ @@ -261,4 +355,50 @@ async def _handle_reschedule(user: dict, parsed: dict) -> AssistantResponse: f"{new_date} {new_time}? Confirm to proceed." ), requires_confirmation=True, - ) \ No newline at end of file + events=matches, + suggested_actions=["confirm", "cancel"], + ) + + +async def _handle_update( + user: dict, + parsed: dict, + selected_event_id: str | None = None, +) -> AssistantResponse: + all_events = await data_access.list_events(user["id"]) + matches = _action_matches(all_events, parsed, selected_event_id) + + if not matches: + return AssistantResponse( + intent="update", + result=[], + message=f"No event matching '{parsed.get('title', '')}' found to update.", + ) + + if len(matches) > 1: + return _clarification(matches, "update") + + event_data = parsed.get("event_data") or {} + if not isinstance(event_data, dict) or not event_data: + return AssistantResponse( + intent="update", + result={"events": matches}, + message="I found the event, but I need more detail about what to update.", + events=matches, + suggested_actions=["edit"], + ) + + primary = matches[0] + return AssistantResponse( + intent="update", + result={ + "events": matches, + "primary_event_id": primary["id"], + "primary_title": primary.get("title", "Event"), + "event_data": event_data, + }, + message=f"Update \"{primary.get('title', 'Event')}\"? Confirm to proceed.", + requires_confirmation=True, + events=matches, + suggested_actions=["confirm", "edit"], + ) diff --git a/backend/app/routers/events.py b/backend/app/routers/events.py index 9beb37b..1a47446 100644 --- a/backend/app/routers/events.py +++ b/backend/app/routers/events.py @@ -1,6 +1,6 @@ from __future__ import annotations -from datetime import date as DateType +from datetime import date as DateType, datetime, time, timedelta from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, Query, status @@ -17,6 +17,40 @@ ) router = APIRouter() +MAX_EVENT_LOOKBACK_DAYS = 1 + + +def _parse_query_date(value: str | None, field_name: str) -> DateType | None: + if value is None: + return None + try: + return DateType.fromisoformat(value) + except ValueError as exc: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"{field_name} must be a valid YYYY-MM-DD date", + ) from exc + + +def _event_overlaps_range( + event: EventResponse, + range_start: DateType | None, + range_end: DateType | None, +) -> bool: + event_start = datetime.combine(event.date, event.start_time) + event_end = event_start + timedelta(minutes=event.duration_minutes) + + if range_start is not None: + start_boundary = datetime.combine(range_start, time.min) + if event_end <= start_boundary: + return False + + if range_end is not None: + end_boundary = datetime.combine(range_end + timedelta(days=1), time.min) + if event_start >= end_boundary: + return False + + return True def _expand_events( @@ -26,33 +60,39 @@ def _expand_events( ) -> list[EventResponse]: """Expand recurring events into concrete instances within the date range.""" expanded: list[EventResponse] = [] + expansion_start = range_start - timedelta(days=MAX_EVENT_LOOKBACK_DAYS) for event in events: if not event.recurrence_rule: - if range_start <= event.date <= range_end: + if _event_overlaps_range(event, range_start, range_end): expanded.append(event) continue event_dict = event.model_dump(mode="json") - instances = expand_recurrence(event_dict, range_start, range_end) + instances = expand_recurrence(event_dict, expansion_start, range_end) for inst in instances: inst_date = DateType.fromisoformat(inst["date"]) inst_id = f"{event.id}_{inst_date.isoformat()}" - expanded.append( - EventResponse( - id=inst_id, - user_id=event.user_id, - title=event.title, - date=inst_date, - start_time=event.start_time, - duration_minutes=event.duration_minutes, - participants=event.participants, - recurrence_rule=event.recurrence_rule, - category=event.category, - external_ids=event.external_ids, - sync_status=event.sync_status, - last_synced_at=event.last_synced_at, - ) + instance = EventResponse( + id=inst_id, + user_id=event.user_id, + title=event.title, + date=inst_date, + start_time=event.start_time, + duration_minutes=event.duration_minutes, + participants=event.participants, + recurrence_rule=event.recurrence_rule, + category=event.category, + description=event.description, + location_url=event.location_url, + priority=event.priority, + tags=event.tags, + reminder_minutes=event.reminder_minutes, + external_ids=event.external_ids, + sync_status=event.sync_status, + last_synced_at=event.last_synced_at, ) + if _event_overlaps_range(instance, range_start, range_end): + expanded.append(instance) expanded.sort(key=lambda e: (e.date, e.start_time)) return expanded @@ -65,23 +105,25 @@ async def list_events( expand: bool = Query(False, description="Expand recurring events into instances"), user: dict = Depends(get_current_user), ): + fd = _parse_query_date(from_date, "from") + td = _parse_query_date(to_date, "to") + if fd and td and fd > td: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="from must be on or before to", + ) + events = await data_access.list_events(user["id"]) if q: q_lower = q.lower() events = [e for e in events if q_lower in e.title.lower()] - if expand and from_date and to_date: - fd = DateType.fromisoformat(from_date) - td = DateType.fromisoformat(to_date) + if expand and fd and td: return _expand_events(events, fd, td) - if from_date: - fd = DateType.fromisoformat(from_date) - events = [e for e in events if e.date >= fd] - if to_date: - td = DateType.fromisoformat(to_date) - events = [e for e in events if e.date <= td] + if fd or td: + events = [e for e in events if _event_overlaps_range(e, fd, td)] return events diff --git a/backend/app/routers/parse.py b/backend/app/routers/parse.py index 0e3eabe..9db78de 100644 --- a/backend/app/routers/parse.py +++ b/backend/app/routers/parse.py @@ -106,7 +106,7 @@ async def _call_openrouter(text: str, today_iso: str) -> dict: """Call OpenRouter API with configurable model and proper headers.""" url = "https://openrouter.ai/api/v1/chat/completions" headers = { - "Authorization": f"Bearer {settings.OPENROUTE_API_KEY}", + "Authorization": f"Bearer {settings.openrouter_api_key}", "Content-Type": "application/json", "HTTP-Referer": "https://timeora.app", "X-Title": "Timeora", @@ -194,7 +194,7 @@ async def parse_natural_language( providers: list[tuple[str, object]] = [] if settings.OPENAI_API_KEY: providers.append(("OpenAI", _call_openai)) - if settings.OPENROUTE_API_KEY: + if settings.openrouter_api_key: providers.append(("OpenRouter", _call_openrouter)) errors: list[str] = [] diff --git a/backend/app/storage_normalization.py b/backend/app/storage_normalization.py new file mode 100644 index 0000000..2780ddb --- /dev/null +++ b/backend/app/storage_normalization.py @@ -0,0 +1,39 @@ +import json + + +def json_object(value) -> dict: + if isinstance(value, dict): + return value + if isinstance(value, str): + try: + parsed = json.loads(value) + except json.JSONDecodeError: + return {} + return parsed if isinstance(parsed, dict) else {} + return {} + + +def string_list(value) -> list[str]: + if value is None: + return [] + if isinstance(value, list): + raw_items = value + elif isinstance(value, str): + normalized = value.strip() + if not normalized: + return [] + try: + parsed = json.loads(normalized) + except json.JSONDecodeError: + raw_items = normalized.split(",") + else: + raw_items = parsed if isinstance(parsed, list) else [parsed] + else: + return [] + + tags: list[str] = [] + for item in raw_items: + tag = str(item).strip() + if tag: + tags.append(tag) + return tags diff --git a/backend/app/supabase_store.py b/backend/app/supabase_store.py index b50a746..4342555 100644 --- a/backend/app/supabase_store.py +++ b/backend/app/supabase_store.py @@ -1,8 +1,9 @@ -from datetime import date, datetime, time, timedelta +from datetime import date, datetime, time import httpx from fastapi import HTTPException, status +from app.core import conflicts as conflicts_engine from app.config import settings from app.models import ( AlternativeSlot, @@ -10,6 +11,7 @@ EventResponse, EventUpdate, ) +from app.storage_normalization import json_object, string_list _HEADERS = lambda: { "apikey": settings.SUPABASE_SERVICE_ROLE_KEY, @@ -59,7 +61,12 @@ def _row_to_event(row: dict) -> EventResponse: participants=row.get("participants") or "", recurrence_rule=row.get("recurrence_rule"), category=row.get("category"), - external_ids=row.get("external_ids") or {}, + description=row.get("description") or "", + location_url=row.get("location_url"), + priority=row.get("priority") or "normal", + tags=string_list(row.get("tags")), + reminder_minutes=row.get("reminder_minutes"), + external_ids=json_object(row.get("external_ids")), sync_status=row.get("sync_status") or "not_synced", last_synced_at=row.get("last_synced_at"), ) @@ -74,17 +81,35 @@ def _serialize_event_payload(data: dict) -> dict: return payload -def _event_end(start: time, duration_minutes: int) -> time: - dt = datetime.combine(date.today(), start) + timedelta(minutes=duration_minutes) - return dt.time() +def _events_as_dicts(events: list[EventResponse]) -> list[dict]: + return [event.model_dump(mode="json") for event in events] -def _times_overlap( - start_a: time, dur_a: int, start_b: time, dur_b: int -) -> bool: - end_a = _event_end(start_a, dur_a) - end_b = _event_end(start_b, dur_b) - return start_a < end_b and start_b < end_a +def _alternatives_from_engine( + events: list[EventResponse], + event_date: date, + start_time: time, + duration_minutes: int, + count: int = 3, +) -> list[AlternativeSlot]: + raw = conflicts_engine.find_alternatives( + _events_as_dicts(events), + event_date, + start_time, + duration_minutes, + count=count, + ) + slots: list[AlternativeSlot] = [] + for alt in raw: + hours, minutes = alt["start_time"].split(":", 1) + slots.append( + AlternativeSlot( + start_time=time(int(hours), int(minutes)), + duration_minutes=alt["duration_minutes"], + reason=alt.get("reason", ""), + ) + ) + return slots async def upsert_user(user_id: str, email: str) -> None: @@ -159,32 +184,37 @@ async def _find_conflict( exclude_id: str | None = None, ) -> dict | None: events = await list_events(user_id) - for event in events: - if exclude_id and event.id == exclude_id: - continue - if event.date != event_date: - continue - if _times_overlap( - start_time, duration_minutes, event.start_time, event.duration_minutes - ): - return {"id": event.id, "title": event.title} + hits = conflicts_engine.check_conflicts( + _events_as_dicts(events), + event_date, + start_time, + duration_minutes, + exclude_id=exclude_id, + ) + if hits: + hit = hits[0] + return { + "id": str(hit.get("id")), + "title": hit.get("title") or "Existing event", + } return None async def _generate_alternatives( - user_id: str, event_date: date, duration_minutes: int, count: int = 3 + user_id: str, + event_date: date, + start_time: time, + duration_minutes: int, + count: int = 3, ) -> list[AlternativeSlot]: - slots: list[AlternativeSlot] = [] - candidate = time(8, 0) - while len(slots) < count and candidate < time(22, 0): - conflict = await _find_conflict(user_id, event_date, candidate, duration_minutes) - if not conflict: - slots.append( - AlternativeSlot(start_time=candidate, duration_minutes=duration_minutes) - ) - candidate_dt = datetime.combine(event_date, candidate) + timedelta(minutes=30) - candidate = candidate_dt.time() - return slots + events = await list_events(user_id) + return _alternatives_from_engine( + events, + event_date, + start_time, + duration_minutes, + count=count, + ) async def create_event(user_id: str, body: EventCreate) -> EventResponse: @@ -192,7 +222,12 @@ async def create_event(user_id: str, body: EventCreate) -> EventResponse: user_id, body.date, body.start_time, body.duration_minutes ) if conflict: - alts = await _generate_alternatives(user_id, body.date, body.duration_minutes) + alts = await _generate_alternatives( + user_id, + body.date, + body.start_time, + body.duration_minutes, + ) raise HTTPException( status_code=status.HTTP_409_CONFLICT, detail={ @@ -212,6 +247,11 @@ async def create_event(user_id: str, body: EventCreate) -> EventResponse: "participants": body.participants, "recurrence_rule": body.recurrence_rule, "category": body.category, + "description": body.description, + "location_url": body.location_url, + "priority": body.priority, + "tags": body.tags, + "reminder_minutes": body.reminder_minutes, "external_ids": body.external_ids, } ) @@ -231,11 +271,41 @@ async def create_event(user_id: str, body: EventCreate) -> EventResponse: async def update_event( event_id: str, user_id: str, body: EventUpdate ) -> EventResponse: - await get_event(event_id, user_id) + existing = await get_event(event_id, user_id) update_data = body.model_dump(exclude_unset=True) if not update_data: raise HTTPException(status_code=400, detail="No fields to update") + event_date = body.date if body.date is not None else existing.date + start_time = body.start_time if body.start_time is not None else existing.start_time + duration_minutes = ( + body.duration_minutes + if body.duration_minutes is not None + else existing.duration_minutes + ) + conflict = await _find_conflict( + user_id, + event_date, + start_time, + duration_minutes, + exclude_id=event_id, + ) + if conflict: + alts = await _generate_alternatives( + user_id, + event_date, + start_time, + duration_minutes, + ) + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail={ + "message": "Time slot conflicts with existing event", + "conflicting_event": conflict["title"], + "alternatives": [a.model_dump(mode="json") for a in alts], + }, + ) + url = f"{_base()}/events" params = {"id": f"eq.{event_id}", "user_id": f"eq.{user_id}"} headers = {**_HEADERS(), "Prefer": "return=representation"} @@ -300,5 +370,10 @@ async def check_conflict( conflict = await _find_conflict(user_id, event_date, start_time, duration_minutes) if not conflict: return False, None, [] - alts = await _generate_alternatives(user_id, event_date, duration_minutes) + alts = await _generate_alternatives( + user_id, + event_date, + start_time, + duration_minutes, + ) return True, conflict["title"], alts diff --git a/backend/schema.sql b/backend/schema.sql index 73e85fd..40dc42b 100644 --- a/backend/schema.sql +++ b/backend/schema.sql @@ -27,6 +27,11 @@ CREATE TABLE IF NOT EXISTS events ( participants TEXT DEFAULT '', recurrence_rule TEXT DEFAULT NULL, category TEXT DEFAULT NULL, + description TEXT NOT NULL DEFAULT '', + location_url TEXT DEFAULT NULL, + priority TEXT NOT NULL DEFAULT 'normal' CHECK (priority IN ('low', 'normal', 'important')), + tags TEXT[] NOT NULL DEFAULT '{}', + reminder_minutes INTEGER DEFAULT NULL CHECK (reminder_minutes IS NULL OR (reminder_minutes >= 0 AND reminder_minutes <= 10080)), deleted_at TIMESTAMPTZ DEFAULT NULL, external_ids JSONB NOT NULL DEFAULT '{}'::jsonb, sync_status event_sync_status NOT NULL DEFAULT 'not_synced', diff --git a/backend/tests/test_analytics_router.py b/backend/tests/test_analytics_router.py new file mode 100644 index 0000000..9136057 --- /dev/null +++ b/backend/tests/test_analytics_router.py @@ -0,0 +1,25 @@ +import unittest + +from fastapi import HTTPException + +from app.routers import analytics + + +class TestAnalyticsRouter(unittest.TestCase): + def test_reference_date_rejects_invalid_date(self): + with self.assertRaises(HTTPException) as raised: + analytics._reference_date("2026-99-99") + + self.assertEqual(raised.exception.status_code, 400) + self.assertIn("date", raised.exception.detail) + self.assertIn("YYYY-MM-DD", raised.exception.detail) + + def test_reference_date_accepts_valid_date(self): + self.assertEqual( + analytics._reference_date("2026-07-07").isoformat(), + "2026-07-07", + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/backend/tests/test_assistant.py b/backend/tests/test_assistant.py new file mode 100644 index 0000000..8255bda --- /dev/null +++ b/backend/tests/test_assistant.py @@ -0,0 +1,365 @@ +import unittest +from datetime import date, time +from unittest.mock import AsyncMock, patch + +from fastapi import HTTPException + +from app.core import assistant_tools +from app.models import AssistantRequest, EventResponse +from app.routers import assistant + + +def event(event_id: str, title: str, hour: int) -> EventResponse: + return EventResponse( + id=event_id, + user_id="user-1", + title=title, + date="2026-07-06", + start_time=f"{hour:02d}:00:00", + duration_minutes=60, + participants="", + ) + + +def event_on( + event_id: str, + title: str, + event_date: date, + start_time: time, + duration_minutes: int, +) -> EventResponse: + return EventResponse( + id=event_id, + user_id="user-1", + title=title, + date=event_date, + start_time=start_time, + duration_minutes=duration_minutes, + participants="", + ) + + +class TestAssistantClarification(unittest.IsolatedAsyncioTestCase): + async def test_ambiguous_cancel_returns_choices(self): + events = [event("one", "Team Sync", 10), event("two", "Team Sync", 14)] + with patch.object(assistant.data_access, "list_events", AsyncMock(return_value=events)): + response = await assistant._handle_cancel( + {"id": "user-1"}, + {"title": "Team Sync", "date": None}, + ) + + self.assertFalse(response.requires_confirmation) + self.assertEqual(response.clarification["type"], "event_selection") + self.assertEqual([choice["id"] for choice in response.clarification["choices"]], ["one", "two"]) + + async def test_selected_ambiguous_event_can_be_confirmed(self): + events = [event("one", "Team Sync", 10), event("two", "Team Sync", 14)] + with patch.object(assistant.data_access, "list_events", AsyncMock(return_value=events)): + response = await assistant._handle_cancel( + {"id": "user-1"}, + {"title": "Team Sync", "date": None}, + selected_event_id="two", + ) + + self.assertTrue(response.requires_confirmation) + self.assertEqual(response.result["primary_event_id"], "two") + + async def test_selected_recurring_instance_preserves_underscored_base_id(self): + events = [ + event("team_sync", "Team Sync", 10), + event("team_review", "Team Sync", 14), + ] + with patch.object(assistant.data_access, "list_events", AsyncMock(return_value=events)): + response = await assistant._handle_cancel( + {"id": "user-1"}, + {"title": "Team Sync", "date": None}, + selected_event_id="team_sync_2026-07-06", + ) + + self.assertTrue(response.requires_confirmation) + self.assertEqual(response.result["primary_event_id"], "team_sync") + + async def test_context_event_can_be_cancelled_without_title_match(self): + events = [event("one", "Product Sync", 14)] + with patch.object(assistant.data_access, "list_events", AsyncMock(return_value=events)): + response = await assistant._handle_cancel( + {"id": "user-1"}, + {"title": "this", "date": None}, + selected_event_id="one", + ) + + self.assertTrue(response.requires_confirmation) + self.assertEqual(response.result["primary_event_id"], "one") + + async def test_query_exposes_structured_events(self): + events = [event("one", "Product Sync", 14)] + with patch.object(assistant.data_access, "list_events", AsyncMock(return_value=events)): + response = await assistant._handle_query( + {"id": "user-1"}, + {"date": "2026-07-06"}, + ) + + self.assertEqual(response.events[0]["title"], "Product Sync") + self.assertIn("open_event", response.suggested_actions) + + async def test_query_includes_events_overlapping_target_day(self): + events = [ + event_on( + "overnight", + "Late deployment", + date(2026, 7, 5), + time(23, 30), + 90, + ) + ] + with patch.object(assistant.data_access, "list_events", AsyncMock(return_value=events)): + response = await assistant._handle_query( + {"id": "user-1"}, + {"date": "2026-07-06"}, + ) + + self.assertEqual(response.message, "Found 1 event(s) on 2026-07-06.") + self.assertEqual(response.events[0]["id"], "overnight") + + async def test_update_preview_returns_confirmation_with_event_data(self): + events = [event("one", "Product Sync", 14)] + with patch.object(assistant.data_access, "list_events", AsyncMock(return_value=events)): + response = await assistant._handle_update( + {"id": "user-1"}, + { + "title": "Product Sync", + "date": None, + "event_data": {"priority": "important"}, + }, + ) + + self.assertTrue(response.requires_confirmation) + self.assertEqual(response.intent, "update") + self.assertEqual(response.result["primary_event_id"], "one") + self.assertEqual(response.result["event_data"], {"priority": "important"}) + + async def test_context_event_can_be_updated_without_title_match(self): + events = [event("one", "Product Sync", 14)] + with patch.object(assistant.data_access, "list_events", AsyncMock(return_value=events)): + response = await assistant._handle_update( + {"id": "user-1"}, + { + "title": "this", + "date": None, + "event_data": {"priority": "important"}, + }, + selected_event_id="one", + ) + + self.assertTrue(response.requires_confirmation) + self.assertEqual(response.result["primary_event_id"], "one") + self.assertEqual(response.result["event_data"], {"priority": "important"}) + + async def test_reschedule_preview_requires_new_date_and_time(self): + events = [event("one", "Product Sync", 14)] + with patch.object(assistant.data_access, "list_events", AsyncMock(return_value=events)): + response = await assistant._handle_reschedule( + {"id": "user-1"}, + {"title": "Product Sync", "date": None, "start_time": None}, + ) + + self.assertFalse(response.requires_confirmation) + self.assertEqual(response.intent, "reschedule") + self.assertIn("new date and time", response.message) + self.assertIn("edit", response.suggested_actions) + + async def test_reschedule_preview_requires_valid_new_date_and_time(self): + events = [event("one", "Product Sync", 14)] + with patch.object(assistant.data_access, "list_events", AsyncMock(return_value=events)): + response = await assistant._handle_reschedule( + {"id": "user-1"}, + { + "title": "Product Sync", + "date": "not-a-date", + "start_time": "not-a-time", + }, + ) + + self.assertFalse(response.requires_confirmation) + self.assertEqual(response.intent, "reschedule") + self.assertIn("valid new date and time", response.message) + self.assertIn("edit", response.suggested_actions) + + async def test_reschedule_matches_event_by_title_not_new_target_date(self): + events = [event("one", "Product Sync", 14)] + with patch.object(assistant.data_access, "list_events", AsyncMock(return_value=events)): + response = await assistant._handle_reschedule( + {"id": "user-1"}, + { + "title": "Product Sync", + "date": "2026-07-07", + "start_time": "15:00", + }, + ) + + self.assertTrue(response.requires_confirmation) + self.assertEqual(response.result["primary_event_id"], "one") + self.assertEqual(response.result["new_date"], "2026-07-07") + self.assertEqual(response.result["new_time"], "15:00:00") + + async def test_context_event_can_be_rescheduled_without_title_match(self): + events = [event("one", "Product Sync", 14)] + with patch.object(assistant.data_access, "list_events", AsyncMock(return_value=events)): + response = await assistant._handle_reschedule( + {"id": "user-1"}, + { + "title": "this", + "date": "2026-07-07", + "start_time": "15:00", + }, + selected_event_id="one", + ) + + self.assertTrue(response.requires_confirmation) + self.assertEqual(response.result["primary_event_id"], "one") + self.assertEqual(response.result["new_date"], "2026-07-07") + self.assertEqual(response.result["new_time"], "15:00:00") + + async def test_find_slot_ignores_invalid_requested_time(self): + with patch.object(assistant.data_access, "list_events", AsyncMock(return_value=[])): + response = await assistant._handle_find_slot( + {"id": "user-1"}, + { + "date": "2026-07-06", + "start_time": "not-a-time", + "duration_minutes": 60, + }, + ) + + self.assertEqual(response.intent, "find_slot") + self.assertGreater(len(response.result), 0) + + +class TestAssistantNativeTools(unittest.IsolatedAsyncioTestCase): + async def test_confirmed_create_uses_calendar_data_access(self): + created = event("new", "Planning", 9) + body = AssistantRequest( + confirm=True, + action="create", + event_data={ + "title": "Planning", + "date": "2026-07-07", + "start_time": "09:00:00", + "duration_minutes": 30, + }, + ) + with patch.object( + assistant.data_access, + "create_event", + AsyncMock(return_value=created), + ) as create_event: + response = await assistant._execute_confirmed({"id": "user-1"}, body) + + self.assertTrue(response.executed) + self.assertEqual(response.intent, "create") + self.assertEqual(create_event.await_args.args[0], "user-1") + + async def test_confirmed_create_maps_parser_recurrence_to_event_field(self): + created = event("new", "Weekly Standup", 9) + body = AssistantRequest( + confirm=True, + action="create", + event_data={ + "title": "Weekly Standup", + "date": "2026-07-07", + "start_time": "09:00", + "duration_minutes": 30, + "recurrence": "weekly:senin", + }, + ) + with patch.object( + assistant.data_access, + "create_event", + AsyncMock(return_value=created), + ) as create_event: + await assistant._execute_confirmed({"id": "user-1"}, body) + + created_body = create_event.await_args.args[1] + self.assertEqual(created_body.recurrence_rule, "weekly:senin") + + async def test_reschedule_rejects_invalid_time_as_bad_request(self): + body = AssistantRequest( + confirm=True, + action="reschedule", + event_id="event-1", + new_date="2026-07-07", + new_time="not-a-time", + ) + with patch.object(assistant_tools.data_access, "update_event", AsyncMock()) as update_event: + with self.assertRaises(HTTPException) as raised: + await assistant_tools.execute_calendar_tool("user-1", body) + + self.assertEqual(raised.exception.status_code, 400) + self.assertIn("new_date and new_time", raised.exception.detail) + update_event.assert_not_awaited() + + async def test_confirmed_cancel_preserves_underscored_recurring_base_id(self): + body = AssistantRequest( + confirm=True, + action="cancel", + event_id="team_sync_2026-07-06", + ) + with patch.object(assistant_tools.data_access, "delete_event", AsyncMock()) as delete_event: + intent, result = await assistant_tools.execute_calendar_tool("user-1", body) + + self.assertEqual(intent, "cancel") + self.assertEqual(result["event_id"], "team_sync") + delete_event.assert_awaited_once_with("team_sync", "user-1") + + async def test_reschedule_rejects_invalid_date_as_bad_request(self): + body = AssistantRequest( + confirm=True, + action="reschedule", + event_id="event-1", + new_date="not-a-date", + new_time="09:00", + ) + with patch.object(assistant_tools.data_access, "update_event", AsyncMock()) as update_event: + with self.assertRaises(HTTPException) as raised: + await assistant_tools.execute_calendar_tool("user-1", body) + + self.assertEqual(raised.exception.status_code, 400) + self.assertIn("new_date and new_time", raised.exception.detail) + update_event.assert_not_awaited() + + async def test_create_rejects_invalid_event_data_as_bad_request(self): + body = AssistantRequest( + confirm=True, + action="create", + event_data={ + "date": "2026-07-07", + "start_time": "09:00:00", + "duration_minutes": 30, + }, + ) + with patch.object(assistant_tools.data_access, "create_event", AsyncMock()) as create_event: + with self.assertRaises(HTTPException) as raised: + await assistant_tools.execute_calendar_tool("user-1", body) + + self.assertEqual(raised.exception.status_code, 400) + self.assertIn("Invalid event data", raised.exception.detail) + create_event.assert_not_awaited() + + async def test_update_rejects_invalid_event_data_as_bad_request(self): + body = AssistantRequest( + confirm=True, + action="update", + event_id="event-1", + event_data={"reminder_minutes": 10081}, + ) + with patch.object(assistant_tools.data_access, "update_event", AsyncMock()) as update_event: + with self.assertRaises(HTTPException) as raised: + await assistant_tools.execute_calendar_tool("user-1", body) + + self.assertEqual(raised.exception.status_code, 400) + self.assertIn("Invalid event data", raised.exception.detail) + update_event.assert_not_awaited() + + +if __name__ == "__main__": + unittest.main() diff --git a/backend/tests/test_core.py b/backend/tests/test_core.py index 218486a..05a5b16 100644 --- a/backend/tests/test_core.py +++ b/backend/tests/test_core.py @@ -1,4 +1,5 @@ import unittest +import warnings from datetime import date, time from unittest.mock import AsyncMock, patch @@ -34,6 +35,33 @@ def test_parses_indonesian_day_after_tomorrow(self): self.assertEqual(result["start_time"], "14:00") self.assertEqual(result["duration_minutes"], 30) + def test_invalid_time_minutes_do_not_crash_parser(self): + result = parse( + "jadwalkan deployment besok jam 10:75 selama 30 menit", + self.today, + ) + + self.assertEqual(result["intent"], "create") + self.assertEqual(result["title"], "deployment") + self.assertEqual(result["start_time"], "09:00") + self.assertIn("No time found — defaulting to 09:00", result["warnings"]) + + def test_parses_indonesian_midnight_wording(self): + result = parse("jadwalkan maintenance besok jam 12 malam", self.today) + + self.assertEqual(result["date"], "2026-07-05") + self.assertEqual(result["start_time"], "00:00") + + def test_absolute_date_without_year_rolls_forward_when_past(self): + result = parse( + "jadwalkan kickoff 1 Januari jam 9 pagi", + today=date(2026, 12, 31), + ) + + self.assertEqual(result["date"], "2027-01-01") + self.assertEqual(result["start_time"], "09:00") + self.assertEqual(result["title"], "kickoff") + def test_parses_english_day_after_tomorrow_before_tomorrow(self): result = parse("review day after tomorrow at 11am", self.today) @@ -67,6 +95,34 @@ def test_defaults_create_and_emits_warnings(self): self.assertEqual(result["duration_minutes"], 60) self.assertEqual(len(result["warnings"]), 3) + def test_detects_priority_update_intent(self): + result = parse("make Product Sync important", self.today) + + self.assertEqual(result["intent"], "update") + self.assertEqual(result["title"], "Product Sync") + self.assertEqual(result["event_data"], {"priority": "important"}) + + def test_detects_description_update_intent(self): + result = parse("set Product Sync description to Bring Q3 notes", self.today) + + self.assertEqual(result["intent"], "update") + self.assertEqual(result["title"], "Product Sync") + self.assertEqual(result["event_data"], {"description": "Bring Q3 notes"}) + + def test_detects_tag_update_intent(self): + result = parse("tag Product Sync with client, roadmap", self.today) + + self.assertEqual(result["intent"], "update") + self.assertEqual(result["title"], "Product Sync") + self.assertEqual(result["event_data"], {"tags": ["client", "roadmap"]}) + + def test_detects_reminder_update_intent(self): + result = parse("set Product Sync reminder to 30 minutes", self.today) + + self.assertEqual(result["intent"], "update") + self.assertEqual(result["title"], "Product Sync") + self.assertEqual(result["event_data"], {"reminder_minutes": 30}) + class TestConflictEngine(unittest.TestCase): event_date = date(2026, 7, 6) @@ -125,6 +181,61 @@ def test_lunch_slots_are_deprioritized(self): self.assertFalse(alternatives[0]["start_time"].startswith("12:")) + def test_detects_previous_day_event_crossing_midnight(self): + hits = check_conflicts( + [ + { + "id": "overnight", + "title": "Late deployment", + "date": "2026-07-05", + "start_time": "23:30", + "duration_minutes": 90, + } + ], + self.event_date, + time(0, 30), + 30, + ) + + self.assertEqual([item["id"] for item in hits], ["overnight"]) + + def test_detects_next_day_event_when_new_slot_crosses_midnight(self): + hits = check_conflicts( + [ + { + "id": "early-call", + "title": "Early call", + "date": "2026-07-07", + "start_time": "00:30", + "duration_minutes": 30, + } + ], + self.event_date, + time(23, 30), + 90, + ) + + self.assertEqual([item["id"] for item in hits], ["early-call"]) + + def test_alternatives_avoid_previous_day_overnight_events(self): + alternatives = find_alternatives( + [ + { + "id": "overnight", + "title": "Overnight maintenance", + "date": "2026-07-05", + "start_time": "23:30", + "duration_minutes": 600, + } + ], + self.event_date, + time(8, 0), + 60, + count=1, + ) + + self.assertGreaterEqual(alternatives[0]["start_time"], "09:45") + class TestRecurrenceEngine(unittest.TestCase): def _event(self, rule, anchor="2026-07-01"): @@ -302,6 +413,13 @@ def test_fully_busy_hour_is_zero(self): class TestIcsExport(unittest.TestCase): + def test_generation_does_not_use_deprecated_utcnow(self): + with warnings.catch_warnings(): + warnings.simplefilter("error", DeprecationWarning) + output = generate_ics([]) + + self.assertIn("BEGIN:VCALENDAR", output) + def test_generates_valid_calendar_boundaries(self): output = generate_ics([]) @@ -344,6 +462,31 @@ def test_event_end_can_cross_midnight(self): self.assertIn("DTSTART:20260706T233000", output) self.assertIn("DTEND:20260707T010000", output) + def test_exports_recurrence_rules(self): + output = generate_ics( + [ + { + "id": "event-1", + "title": "Weekly standup", + "date": "2026-07-06", + "start_time": "09:00", + "duration_minutes": 30, + "recurrence_rule": "weekly:senin", + }, + { + "id": "event-2", + "title": "Weekday focus", + "date": "2026-07-06", + "start_time": "10:00", + "duration_minutes": 60, + "recurrence_rule": "weekdays", + }, + ] + ) + + self.assertIn("RRULE:FREQ=WEEKLY;BYDAY=MO", output) + self.assertIn("RRULE:FREQ=WEEKLY;BYDAY=MO,TU,WE,TH,FR", output) + class TestEventUpdateConflicts(unittest.IsolatedAsyncioTestCase): existing = EventResponse( diff --git a/backend/tests/test_event_details.py b/backend/tests/test_event_details.py new file mode 100644 index 0000000..18c25a2 --- /dev/null +++ b/backend/tests/test_event_details.py @@ -0,0 +1,103 @@ +import unittest +from datetime import date, time + +from pydantic import ValidationError + +from app.models import AssistantRequest, EventCreate, EventResponse, EventUpdate, ParseRequest + + +class TestEventDetails(unittest.TestCase): + def _event(self, **overrides): + values = { + "title": "Product Sync", + "date": date(2026, 7, 6), + "start_time": time(14, 0), + "duration_minutes": 60, + } + values.update(overrides) + return EventCreate(**values) + + def test_normalizes_rich_event_fields(self): + event = self._event( + description="Roadmap review", + location_url="https://zoom.us/j/123", + priority="important", + tags=[" Work ", "planning", "work", ""], + reminder_minutes=15, + ) + + self.assertEqual(event.description, "Roadmap review") + self.assertEqual(event.location_url, "https://zoom.us/j/123") + self.assertEqual(event.priority, "important") + self.assertEqual(event.tags, ["Work", "planning"]) + self.assertEqual(event.reminder_minutes, 15) + + def test_trims_event_title(self): + event = self._event(title=" Product Sync ") + update = EventUpdate(title=" Updated Sync ") + + self.assertEqual(event.title, "Product Sync") + self.assertEqual(update.title, "Updated Sync") + + def test_rejects_blank_event_title(self): + with self.assertRaises(ValidationError): + self._event(title=" ") + with self.assertRaises(ValidationError): + EventUpdate(title=" ") + + def test_rejects_unsafe_location_url(self): + with self.assertRaises(ValidationError): + self._event(location_url="javascript:alert(1)") + + def test_normalizes_scheme_less_location_url(self): + event = self._event(location_url=" zoom.us/j/123 ") + update = EventUpdate(location_url="meet.google.com/abc-defg-hij") + + self.assertEqual(event.location_url, "https://zoom.us/j/123") + self.assertEqual(update.location_url, "https://meet.google.com/abc-defg-hij") + + def test_update_normalizes_tags(self): + update = EventUpdate(tags=["Important", " important ", "Client"]) + self.assertEqual(update.tags, ["Important", "Client"]) + + def test_normalizes_unknown_category_to_other(self): + event = self._event(category=" Work ") + update = EventUpdate(category="Client") + + self.assertEqual(event.category, "other") + self.assertEqual(update.category, "other") + + def test_response_normalizes_unknown_category_to_other(self): + response = EventResponse( + id="evt_1", + user_id="user_1", + title="Imported event", + date=date(2026, 7, 6), + start_time=time(9, 0), + duration_minutes=30, + participants="", + category=" Work ", + ) + + self.assertEqual(response.category, "other") + + def test_rejects_excessive_reminder(self): + with self.assertRaises(ValidationError): + self._event(reminder_minutes=10081) + + def test_trims_parse_and_assistant_text(self): + parse_request = ParseRequest(text=" jadwalkan meeting besok ") + assistant_request = AssistantRequest(text=" apa jadwal besok ") + + self.assertEqual(parse_request.text, "jadwalkan meeting besok") + self.assertEqual(assistant_request.text, "apa jadwal besok") + + def test_rejects_blank_parse_and_assistant_text(self): + with self.assertRaises(ValidationError): + ParseRequest(text=" ") + with self.assertRaises(ValidationError): + AssistantRequest(text=" ") + + +if __name__ == "__main__": + unittest.main() diff --git a/backend/tests/test_event_row_normalization.py b/backend/tests/test_event_row_normalization.py new file mode 100644 index 0000000..6d0e362 --- /dev/null +++ b/backend/tests/test_event_row_normalization.py @@ -0,0 +1,48 @@ +import unittest +from datetime import date, time + +from app import data_access, supabase_store + + +def row(**overrides): + values = { + "id": "evt_1", + "user_id": "user_1", + "title": "Imported event", + "date": date(2026, 7, 6), + "start_time": time(9, 0), + "duration_minutes": 30, + "participants": "", + } + values.update(overrides) + return values + + +class TestEventRowNormalization(unittest.TestCase): + def test_postgres_row_ignores_malformed_external_ids(self): + event = data_access._row_to_event(row(external_ids="not-json")) + + self.assertEqual(event.external_ids, {}) + + def test_postgres_row_parses_json_encoded_tags(self): + event = data_access._row_to_event(row(tags='["client", "roadmap"]')) + + self.assertEqual(event.tags, ["client", "roadmap"]) + + def test_supabase_row_ignores_malformed_external_ids(self): + event = supabase_store._row_to_event( + row(date="2026-07-06", start_time="09:00:00", external_ids="not-json") + ) + + self.assertEqual(event.external_ids, {}) + + def test_supabase_row_parses_json_encoded_tags(self): + event = supabase_store._row_to_event( + row(date="2026-07-06", start_time="09:00:00", tags='["client", "roadmap"]') + ) + + self.assertEqual(event.tags, ["client", "roadmap"]) + + +if __name__ == "__main__": + unittest.main() diff --git a/backend/tests/test_events_router.py b/backend/tests/test_events_router.py new file mode 100644 index 0000000..0e14d95 --- /dev/null +++ b/backend/tests/test_events_router.py @@ -0,0 +1,133 @@ +import unittest +from datetime import date, time +from unittest.mock import AsyncMock, patch + +from fastapi import HTTPException + +from app.models import EventResponse +from app.routers import events + + +class TestEventsRouter(unittest.IsolatedAsyncioTestCase): + def _event( + self, + event_id: str, + event_date: date, + start_time: time, + duration_minutes: int, + recurrence_rule: str | None = None, + ) -> EventResponse: + return EventResponse( + id=event_id, + user_id="user-1", + title=event_id, + date=event_date, + start_time=start_time, + duration_minutes=duration_minutes, + participants="", + recurrence_rule=recurrence_rule, + ) + + async def test_list_events_rejects_invalid_from_date(self): + with patch.object(events.data_access, "list_events", AsyncMock(return_value=[])): + with self.assertRaises(HTTPException) as raised: + await events.list_events( + from_date="not-a-date", + to_date=None, + q=None, + expand=False, + user={"id": "user-1"}, + ) + + self.assertEqual(raised.exception.status_code, 400) + self.assertIn("from", raised.exception.detail) + + async def test_list_events_rejects_invalid_to_date(self): + with patch.object(events.data_access, "list_events", AsyncMock(return_value=[])): + with self.assertRaises(HTTPException) as raised: + await events.list_events( + from_date=None, + to_date="2026-99-99", + q=None, + expand=False, + user={"id": "user-1"}, + ) + + self.assertEqual(raised.exception.status_code, 400) + self.assertIn("to", raised.exception.detail) + + async def test_list_events_rejects_inverted_date_range(self): + list_events = AsyncMock(return_value=[]) + with patch.object(events.data_access, "list_events", list_events): + with self.assertRaises(HTTPException) as raised: + await events.list_events( + from_date="2026-07-08", + to_date="2026-07-07", + q=None, + expand=False, + user={"id": "user-1"}, + ) + + self.assertEqual(raised.exception.status_code, 400) + self.assertIn("from", raised.exception.detail) + list_events.assert_not_awaited() + + async def test_list_events_includes_overnight_event_overlapping_from_date(self): + overnight = self._event( + "overnight", + date(2026, 7, 6), + time(23, 30), + 90, + ) + stale = self._event( + "stale", + date(2026, 7, 6), + time(20, 0), + 60, + ) + + with patch.object( + events.data_access, + "list_events", + AsyncMock(return_value=[overnight, stale]), + ): + result = await events.list_events( + from_date="2026-07-07", + to_date="2026-07-07", + q=None, + expand=False, + user={"id": "user-1"}, + ) + + self.assertEqual([event.id for event in result], ["overnight"]) + + async def test_expanded_recurrence_includes_previous_day_overnight_instance(self): + recurring = self._event( + "series", + date(2026, 7, 1), + time(23, 30), + 90, + recurrence_rule="daily", + ) + + with patch.object( + events.data_access, + "list_events", + AsyncMock(return_value=[recurring]), + ): + result = await events.list_events( + from_date="2026-07-07", + to_date="2026-07-07", + q=None, + expand=True, + user={"id": "user-1"}, + ) + + self.assertEqual( + [event.id for event in result], + ["series_2026-07-06", "series_2026-07-07"], + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/backend/tests/test_integrations.py b/backend/tests/test_integrations.py index 63ff3de..fca0441 100644 --- a/backend/tests/test_integrations.py +++ b/backend/tests/test_integrations.py @@ -1,9 +1,10 @@ import unittest +import os from unittest.mock import patch from fastapi import HTTPException -from app.config import settings +from app.config import Settings, settings from app.core.ics_import import parse_ics from app.integrations.crypto import ( decrypt_token, @@ -15,6 +16,20 @@ from app.integrations.webhooks import validate_webhook_target +class TestProviderConfig(unittest.TestCase): + def test_documented_openrouter_env_var_is_used(self): + with patch.dict(os.environ, {"OPENROUTER_API_KEY": "documented-key"}, clear=True): + config = Settings(_env_file=None) + + self.assertEqual(config.openrouter_api_key, "documented-key") + + def test_legacy_openroute_env_var_still_works(self): + with patch.dict(os.environ, {"OPENROUTE_API_KEY": "legacy-key"}, clear=True): + config = Settings(_env_file=None) + + self.assertEqual(config.openrouter_api_key, "legacy-key") + + class TestIcsImport(unittest.TestCase): def test_parses_event_attendees_recurrence_and_uid(self): content = "\r\n".join( @@ -47,6 +62,26 @@ def test_parses_event_attendees_recurrence_and_uid(self): ) self.assertEqual(event.external_ids, {"ics": "external-123"}) + def test_parses_weekend_weekly_recurrence(self): + content = "\r\n".join( + [ + "BEGIN:VCALENDAR", + "VERSION:2.0", + "BEGIN:VEVENT", + "DTSTART:20260711T090000", + "DTEND:20260711T100000", + "SUMMARY:Saturday training", + "RRULE:FREQ=WEEKLY;BYDAY=SA", + "END:VEVENT", + "END:VCALENDAR", + ] + ) + + events, errors = parse_ics(content, default_timezone="UTC") + + self.assertEqual(errors, []) + self.assertEqual(events[0].recurrence_rule, "weekly:saturday") + def test_unfolds_lines_and_uses_duration(self): content = "\n".join( [ diff --git a/backend/tests/test_supabase_store.py b/backend/tests/test_supabase_store.py new file mode 100644 index 0000000..9bf8cac --- /dev/null +++ b/backend/tests/test_supabase_store.py @@ -0,0 +1,155 @@ +import unittest +from datetime import date, time +from unittest.mock import AsyncMock, patch + +from fastapi import HTTPException + +from app import supabase_store +from app.models import EventResponse, EventUpdate + + +class _FakeResponse: + status_code = 200 + + def __init__(self, body): + self._body = body + + def json(self): + return self._body + + +class _FakeAsyncClient: + def __init__(self, *args, **kwargs): + pass + + async def __aenter__(self): + return self + + async def __aexit__(self, *args): + return None + + async def patch(self, *args, **kwargs): + return _FakeResponse([ + { + "id": "move", + "user_id": "user-1", + "title": "Move me", + "date": "2026-07-07", + "start_time": "00:30:00", + "duration_minutes": 30, + } + ]) + + +def _event( + event_id: str, + title: str, + event_date: date, + start_time: time, + duration_minutes: int, +) -> EventResponse: + return EventResponse( + id=event_id, + user_id="user-1", + title=title, + date=event_date, + start_time=start_time, + duration_minutes=duration_minutes, + participants="", + ) + + +class TestSupabaseStoreConflicts(unittest.IsolatedAsyncioTestCase): + async def test_check_conflict_detects_previous_day_overnight_event(self): + previous_day_event = _event( + "late", + "Late deployment", + date(2026, 7, 6), + time(23, 30), + 90, + ) + + with patch.object( + supabase_store, + "list_events", + AsyncMock(return_value=[previous_day_event]), + ): + conflict, title, alternatives = await supabase_store.check_conflict( + "user-1", + date(2026, 7, 7), + time(0, 30), + 30, + ) + + self.assertTrue(conflict) + self.assertEqual(title, "Late deployment") + self.assertTrue(alternatives) + + async def test_alternatives_avoid_previous_day_overnight_event(self): + overnight_block = _event( + "late", + "Late maintenance", + date(2026, 7, 6), + time(23, 30), + 600, + ) + + with patch.object( + supabase_store, + "list_events", + AsyncMock(return_value=[overnight_block]), + ): + conflict, title, alternatives = await supabase_store.check_conflict( + "user-1", + date(2026, 7, 7), + time(8, 0), + 30, + ) + + self.assertTrue(conflict) + self.assertEqual(title, "Late maintenance") + self.assertTrue(alternatives) + self.assertGreaterEqual(alternatives[0].start_time, time(9, 45)) + + async def test_update_event_rejects_cross_midnight_conflict_before_request(self): + existing_event = _event( + "move", + "Move me", + date(2026, 7, 7), + time(12, 0), + 30, + ) + previous_day_event = _event( + "late", + "Late deployment", + date(2026, 7, 6), + time(23, 30), + 90, + ) + + with ( + patch.object(supabase_store, "get_event", AsyncMock(return_value=existing_event)), + patch.object( + supabase_store, + "list_events", + AsyncMock(return_value=[previous_day_event, existing_event]), + ), + patch.object(supabase_store, "_base", return_value="https://example.test/rest/v1"), + patch.object(supabase_store.httpx, "AsyncClient", _FakeAsyncClient), + ): + with self.assertRaises(HTTPException) as raised: + await supabase_store.update_event( + "move", + "user-1", + EventUpdate( + date=date(2026, 7, 7), + start_time=time(0, 30), + duration_minutes=30, + ), + ) + + self.assertEqual(raised.exception.status_code, 409) + + +if __name__ == "__main__": + unittest.main() diff --git a/frontend/app/dashboard/page.tsx b/frontend/app/dashboard/page.tsx index 4916f77..0720351 100644 --- a/frontend/app/dashboard/page.tsx +++ b/frontend/app/dashboard/page.tsx @@ -1,30 +1,34 @@ "use client"; import { useCallback, useEffect, useState, useRef } from "react"; +import Image from "next/image"; import { useRouter } from "next/navigation"; import { Button } from "@/components/ui/button"; -import { LogOut, Calendar as CalendarIcon, Brain, Download, Settings, User } from "lucide-react"; +import { LogOut, Brain, Download, Settings, User } from "lucide-react"; import { WeeklyCalendar } from "@/components/calendar/WeeklyCalendar"; import { EventDialog, EventData, ConflictData } from "@/components/calendar/EventDialog"; import { fetchApi, ApiError, type ApiEvent, - type AssistantResult, restoreEvent, fetchEventsExpanded, exportIcs, - executeAssistant, applyBlockFocusTime, applySpreadLoad, } from "@/lib/api"; import { format } from "date-fns"; -import { CommandBar } from "@/components/CommandBar"; import { callAssistant } from "@/lib/api"; +import { AssistantPanel } from "@/components/assistant/AssistantPanel"; +import { Toaster } from "@/components/ui/sonner"; +import { toast } from "sonner"; import { InsightsPanel } from "@/components/InsightsPanel"; import { AvailabilityHeatmap } from "@/components/AvailabilityHeatmap"; import { TodayAgenda } from "@/components/TodayAgenda"; +import { NotificationCenter } from "@/components/NotificationCenter"; import { ThemeToggle } from "@/components/theme-toggle"; +import { baseEventId } from "@/lib/eventIds"; +import { readStoredPreferences } from "@/lib/preferences"; import { motion, AnimatePresence } from "framer-motion"; import type { EventClickArg, @@ -43,6 +47,11 @@ type CalendarExtendedProps = { participants?: string; recurrence_rule?: string | null; category?: string | null; + description?: string; + location_url?: string | null; + priority?: "low" | "normal" | "important"; + tags?: string[]; + reminder_minutes?: number | null; }; function initialDateRange(): DateRange { @@ -75,6 +84,11 @@ function toCalendarEvents(events: ApiEvent[]): EventInput[] { participants: event.participants || "", recurrence_rule: event.recurrence_rule || null, category: event.category || null, + description: event.description || "", + location_url: event.location_url || null, + priority: event.priority || "normal", + tags: event.tags || [], + reminder_minutes: event.reminder_minutes ?? null, }, }; }); @@ -102,15 +116,8 @@ export default function DashboardPage() { const [isSaving, setIsSaving] = useState(false); const [conflictData, setConflictData] = useState<ConflictData | null>(null); const [assistantToast, setAssistantToast] = useState<string | null>(null); - const [assistantConfirm, setAssistantConfirm] = useState<{ - eventId: string; - action: "cancel" | "reschedule"; - title: string; - newDate?: string; - newTime?: string; - message: string; - } | null>(null); - const [confirmingAssistant, setConfirmingAssistant] = useState(false); + const [assistantOpen, setAssistantOpen] = useState(false); + const [assistantContext, setAssistantContext] = useState<EventData | null>(null); const [undoDelete, setUndoDelete] = useState<{ id: string; title: string } | null>(null); const [dateRange, setDateRange] = useState<DateRange>(INITIAL_DATE_RANGE); const [exporting, setExporting] = useState(false); @@ -139,18 +146,12 @@ export default function DashboardPage() { // Load user preferences from localStorage useEffect(() => { - const saved = localStorage.getItem("timeora_preferences"); - if (saved) { - try { - const prefs = JSON.parse(saved); - if (prefs.defaultDuration) { - // eslint-disable-next-line react-hooks/set-state-in-effect - setDefaultDuration(prefs.defaultDuration); - } - if (prefs.timezone) { - setTimezone(prefs.timezone); - } - } catch {} + const detectedTimezone = Intl.DateTimeFormat().resolvedOptions().timeZone; + const preferences = readStoredPreferences(detectedTimezone); + // eslint-disable-next-line react-hooks/set-state-in-effect + setDefaultDuration(preferences.defaultDuration); + if (preferences.timezone) { + setTimezone(preferences.timezone); } }, []); @@ -224,7 +225,7 @@ export default function DashboardPage() { URL.revokeObjectURL(url); setAssistantToast("Calendar exported — timeora.ics downloaded."); } catch (err: unknown) { - alert(errorMessage(err, "Failed to export calendar")); + toast.error(errorMessage(err, "Failed to export calendar")); } finally { setExporting(false); } @@ -263,7 +264,7 @@ export default function DashboardPage() { setAssistantToast(result.message); refreshAll(); } catch (err: unknown) { - alert(errorMessage(err, "Failed to block focus time")); + toast.error(errorMessage(err, "Failed to block focus time")); } }; @@ -273,7 +274,7 @@ export default function DashboardPage() { setAssistantToast(result.message); refreshAll(); } catch (err: unknown) { - alert(errorMessage(err, "Failed to spread load")); + toast.error(errorMessage(err, "Failed to spread load")); } }; @@ -292,85 +293,26 @@ export default function DashboardPage() { } }; + const openAssistant = useCallback((context?: EventData | null) => { + setAssistantContext(context ?? null); + setAssistantOpen(true); + }, []); + + const handleAssistantOpenChange = useCallback((open: boolean) => { + setAssistantOpen(open); + if (!open) setAssistantContext(null); + }, []); + const handleEventCategoryChange = async (eventId: string, category: string) => { try { - const baseId = eventId.split("_")[0]; + const baseId = baseEventId(eventId); await fetchApi(`/events/${baseId}`, { method: "PUT", body: JSON.stringify({ category }), }); refreshAll(); } catch (err: unknown) { - alert(errorMessage(err, "Failed to change category")); - } - }; - - const handleParsedNL = ( - parsedData: Partial<EventData>, - meta?: { source?: string; warnings?: string[] } - ) => { - setSelectedEvent(parsedData); - setConflictData(null); - setAssistantToast( - meta?.source === "fallback" - ? "Parsed offline — please verify date and time before saving." - : null - ); - setIsDialogOpen(true); - }; - - const handleAssistant = (result: AssistantResult) => { - if (result.requires_confirmation && result.result && typeof result.result === "object") { - const r = result.result as Record<string, unknown>; - const eventId = r.primary_event_id as string | undefined; - if (eventId) { - setAssistantConfirm({ - eventId, - action: result.intent === "reschedule" ? "reschedule" : "cancel", - title: (r.primary_title as string) || "Event", - newDate: r.new_date as string | undefined, - newTime: r.new_time as string | undefined, - message: result.message, - }); - setAssistantToast(null); - return; - } - } - - setAssistantConfirm(null); - setAssistantToast(result.message); - if (result.intent === "find_slot" && Array.isArray(result.result) && result.result.length > 0) { - const first = result.result[0] as { start_time?: string; reason?: string }; - if (first.start_time) { - setAssistantToast( - `${result.message} First slot: ${first.start_time}${first.reason ? ` — ${first.reason}` : ""}` - ); - } - } - }; - - const handleConfirmAssistant = async () => { - if (!assistantConfirm) return; - setConfirmingAssistant(true); - try { - const result = await executeAssistant({ - event_id: assistantConfirm.eventId, - action: assistantConfirm.action, - new_date: assistantConfirm.newDate, - new_time: assistantConfirm.newTime, - }); - const { eventId, title, action } = assistantConfirm; - setAssistantConfirm(null); - setAssistantToast(result.message); - if (action === "cancel") { - setUndoDelete({ id: eventId, title }); - } - refreshAll(); - } catch (err: unknown) { - const message = err instanceof Error ? err.message : "Failed to execute command"; - alert(message); - } finally { - setConfirmingAssistant(false); + toast.error(errorMessage(err, "Failed to change category")); } }; @@ -379,7 +321,7 @@ export default function DashboardPage() { if (!event.start) return; const extendedProps = event.extendedProps as CalendarExtendedProps; setSelectedEvent({ - id: event.id, + id: baseEventId(event.id), title: event.title, date: format(event.start, "yyyy-MM-dd"), start_time: format(event.start, "HH:mm:ss"), @@ -387,6 +329,11 @@ export default function DashboardPage() { participants: extendedProps.participants || "", recurrence_rule: extendedProps.recurrence_rule || null, category: extendedProps.category || null, + description: extendedProps.description || "", + location_url: extendedProps.location_url || null, + priority: extendedProps.priority || "normal", + tags: extendedProps.tags || [], + reminder_minutes: extendedProps.reminder_minutes ?? null, }); setConflictData(null); setIsDialogOpen(true); @@ -399,7 +346,7 @@ export default function DashboardPage() { const start = rawStart instanceof Date ? rawStart : new Date(rawStart as string | number); const ext = (calEvent.extendedProps || {}) as CalendarExtendedProps; setSelectedEvent({ - id: calEvent.id as string, + id: baseEventId(String(calEvent.id)), title: calEvent.title as string, date: format(start, "yyyy-MM-dd"), start_time: format(start, "HH:mm:ss"), @@ -407,6 +354,11 @@ export default function DashboardPage() { participants: ext.participants || "", recurrence_rule: ext.recurrence_rule || null, category: ext.category || null, + description: ext.description || "", + location_url: ext.location_url || null, + priority: ext.priority || "normal", + tags: ext.tags || [], + reminder_minutes: ext.reminder_minutes ?? null, }); setConflictData(null); setIsDialogOpen(true); @@ -422,7 +374,7 @@ export default function DashboardPage() { } const end = event.end || new Date(event.start.getTime() + 60 * 60_000); try { - await fetchApi(`/events/${event.id}`, { + await fetchApi(`/events/${baseEventId(event.id)}`, { method: "PUT", body: JSON.stringify({ date: format(event.start, "yyyy-MM-dd"), @@ -433,7 +385,7 @@ export default function DashboardPage() { refreshAll(); } catch (err: unknown) { console.error(err); - alert(errorMessage(err, "Failed to move event")); + toast.error(errorMessage(err, "Failed to move event")); arg.revert(); } }; @@ -452,6 +404,11 @@ export default function DashboardPage() { participants: data.participants, recurrence_rule: data.recurrence_rule || null, category: data.category || null, + description: data.description, + location_url: data.location_url || null, + priority: data.priority, + tags: data.tags, + reminder_minutes: data.reminder_minutes ?? null, }), }); } else { @@ -465,6 +422,11 @@ export default function DashboardPage() { participants: data.participants, recurrence_rule: data.recurrence_rule || null, category: data.category || null, + description: data.description, + location_url: data.location_url || null, + priority: data.priority, + tags: data.tags, + reminder_minutes: data.reminder_minutes ?? null, }), }); } @@ -477,10 +439,10 @@ export default function DashboardPage() { if (isConflictData(detail)) { setConflictData(detail); } else { - alert("The selected time conflicts with another event"); + toast.error("The selected time conflicts with another event"); } } else { - alert(errorMessage(err, "Failed to save event")); + toast.error(errorMessage(err, "Failed to save event")); } } finally { setIsSaving(false); @@ -491,14 +453,15 @@ export default function DashboardPage() { if (!confirm("Are you sure you want to delete this event?")) return; setIsSaving(true); try { - await fetchApi(`/events/${id}`, { + const baseId = baseEventId(id); + await fetchApi(`/events/${baseId}`, { method: "DELETE", }); setIsDialogOpen(false); - setUndoDelete({ id, title: title || "Event" }); + setUndoDelete({ id: baseId, title: title || "Event" }); refreshAll(); } catch (err: unknown) { - alert(errorMessage(err, "Failed to delete event")); + toast.error(errorMessage(err, "Failed to delete event")); } finally { setIsSaving(false); } @@ -511,7 +474,7 @@ export default function DashboardPage() { setUndoDelete(null); refreshAll(); } catch (err: unknown) { - alert(errorMessage(err, "Failed to restore event")); + toast.error(errorMessage(err, "Failed to restore event")); } }; @@ -525,14 +488,18 @@ export default function DashboardPage() { <header className="sticky top-0 z-40 border-b border-slate-200/60 dark:border-white/5 bg-white/80 dark:bg-zinc-950/80 backdrop-blur-2xl px-4 sm:px-6 py-3 flex items-center justify-between shadow-[0_4px_30px_rgba(0,0,0,0.03)] transition-all"> {/* Left: Brand */} <div className="flex items-center gap-3"> - <img + <Image src="/logomark_lightmode.png" alt="Timeora Logo" + width={474} + height={403} className="block dark:hidden w-9 h-9 object-contain flex-shrink-0" /> - <img + <Image src="/logomark.png" alt="Timeora Logo" + width={627} + height={502} className="hidden dark:block w-9 h-9 object-contain flex-shrink-0" /> <div className="flex flex-col"> @@ -543,10 +510,10 @@ export default function DashboardPage() { {/* Center: Command Bar Trigger (Desktop) - More prominent */} <div - onClick={() => document.dispatchEvent(new KeyboardEvent('keydown', { key: 'k', metaKey: true }))} + onClick={() => openAssistant()} className="hidden md:flex items-center gap-3 bg-white dark:bg-zinc-900 border border-slate-200/70 dark:border-white/10 hover:border-violet-400/60 focus-within:border-violet-500 shadow-sm hover:shadow-md rounded-2xl px-4 py-2.5 w-full max-w-md cursor-pointer transition-all select-none group" role="button" - aria-label="Open AI Command Bar (⌘K)" + aria-label="Open AI calendar chat (⌘K)" > <div className="flex items-center gap-2.5 flex-1"> <Brain className="w-4 h-4 text-violet-600 dark:text-violet-400 group-hover:scale-110 transition-transform" /> @@ -565,8 +532,9 @@ export default function DashboardPage() { <Button variant="ghost" size="icon" - onClick={() => document.dispatchEvent(new KeyboardEvent('keydown', { key: 'k', metaKey: true }))} - className="md:hidden w-8 h-8 rounded-xl hover:bg-violet-50 hover:text-violet-600 dark:hover:bg-violet-500/10 dark:hover:text-violet-500 transition-colors" + onClick={() => openAssistant()} + className="md:hidden size-11 rounded-xl hover:bg-violet-50 hover:text-violet-600 dark:hover:bg-violet-500/10 dark:hover:text-violet-500 transition-colors" + aria-label="Open AI chat" > <Brain className="w-4 h-4 text-violet-500" /> </Button> @@ -659,38 +627,6 @@ export default function DashboardPage() { </header> <main className="flex-1 max-w-[1400px] w-full mx-auto p-4 sm:p-6 lg:p-8 space-y-10 z-10"> - {assistantConfirm && ( - <motion.div - role="region" - aria-label="Assistant confirmation" - initial={{ opacity: 0, y: -8 }} - animate={{ opacity: 1, y: 0 }} - className="flex items-center justify-between gap-4 rounded-xl border border-amber-200/70 dark:border-amber-900/40 bg-amber-50/90 dark:bg-amber-950/30 px-4 py-3 text-sm shadow-sm" - > - <span className="text-amber-900 dark:text-amber-100">{assistantConfirm.message}</span> - <div className="flex items-center gap-2 shrink-0"> - <Button - variant="ghost" - size="sm" - disabled={confirmingAssistant} - onClick={() => setAssistantConfirm(null)} - aria-label="Dismiss assistant confirmation" - className="font-medium text-slate-600 hover:text-slate-800 dark:text-slate-300" - > - Dismiss - </Button> - <Button - size="sm" - disabled={confirmingAssistant} - onClick={handleConfirmAssistant} - aria-label="Confirm assistant action" - className="font-semibold bg-amber-600 hover:bg-amber-700 text-white" - > - {confirmingAssistant ? "Working…" : "Confirm"} - </Button> - </div> - </motion.div> - )} {assistantToast && ( <motion.div initial={{ opacity: 0, y: -8 }} @@ -772,11 +708,11 @@ export default function DashboardPage() { </div> <h3 className="font-semibold text-violet-700 dark:text-violet-300">No events yet</h3> <p className="text-sm text-violet-600/80 dark:text-violet-400 mt-1 mb-3"> - Start by using the AI Command Bar (⌘K) or the buttons above.<br /> + Start by using the AI calendar chat (⌘K) or the buttons above.<br /> Try: “Jadwalkan meeting besok jam 10 selama 30 menit” </p> - <Button size="sm" variant="outline" onClick={() => document.dispatchEvent(new KeyboardEvent("keydown", { key: "k", metaKey: true }))}> - Open Command Bar + <Button size="sm" variant="outline" onClick={() => openAssistant()}> + Open AI Chat </Button> </div> )} @@ -790,6 +726,11 @@ export default function DashboardPage() { onDatesChange={handleDatesChange} onAddEventClick={handleAddEventClick} onEventCategoryChange={handleEventCategoryChange} + onEventEdit={handleAgendaEventClick} + onEventDelete={(event) => { + if (event.id) void handleDeleteEvent(event.id, event.title); + }} + onEventAskAI={(event) => openAssistant(event)} /> </motion.div> <div className="space-y-6"> @@ -813,6 +754,8 @@ export default function DashboardPage() { </div> </div> + <NotificationCenter events={events} /> + {/* Sidebar Tab Control */} <div className="flex bg-slate-100 dark:bg-zinc-800 p-0.5 rounded-2xl border border-slate-200/50 dark:border-white/5 relative"> {[ @@ -911,14 +854,20 @@ export default function DashboardPage() { {/* Mobile Floating Action Button - High visibility for Command Bar */} <button - onClick={() => document.dispatchEvent(new KeyboardEvent('keydown', { key: 'k', metaKey: true }))} - className="md:hidden fixed bottom-6 right-6 z-50 w-14 h-14 rounded-2xl bg-gradient-to-br from-violet-600 to-indigo-600 text-white shadow-xl shadow-violet-500/30 flex items-center justify-center active:scale-95 transition-all hover:brightness-110" - aria-label="Buka AI Command Bar" + onClick={() => openAssistant()} + className="md:hidden fixed bottom-[calc(1.5rem+env(safe-area-inset-bottom))] right-[calc(1.5rem+env(safe-area-inset-right))] z-50 w-14 h-14 rounded-2xl bg-gradient-to-br from-violet-600 to-indigo-600 text-white shadow-xl shadow-violet-500/30 flex items-center justify-center active:scale-95 transition-all hover:brightness-110" + aria-label="Buka AI chat" > <Brain className="w-6 h-6" /> </button> - <CommandBar onParsed={handleParsedNL} onAssistant={handleAssistant} /> + <AssistantPanel + open={assistantOpen} + onOpenChange={handleAssistantOpenChange} + onEventsChanged={() => refreshAll()} + contextEvent={assistantContext} + /> + <Toaster richColors closeButton /> </div> ); } diff --git a/frontend/app/globals.css b/frontend/app/globals.css index 296e2d9..eeb2a3c 100644 --- a/frontend/app/globals.css +++ b/frontend/app/globals.css @@ -7,9 +7,9 @@ @theme inline { --color-background: var(--background); --color-foreground: var(--foreground); - --font-sans: var(--font-sans); - --font-mono: var(--font-geist-mono); - --font-heading: var(--font-sans); + --font-sans: var(--font-timeora-sans); + --font-mono: var(--font-timeora-mono); + --font-heading: var(--font-timeora-sans); --color-sidebar-ring: var(--sidebar-ring); --color-sidebar-border: var(--sidebar-border); --color-sidebar-accent-foreground: var(--sidebar-accent-foreground); @@ -49,6 +49,8 @@ } :root { + --font-timeora-sans: "Poppins", "Inter", "Segoe UI", "Roboto", "Helvetica Neue", Arial, sans-serif; + --font-timeora-mono: "SFMono-Regular", "Cascadia Code", "Roboto Mono", Consolas, "Liberation Mono", monospace; --background: oklch(1 0 0); --foreground: oklch(0.145 0 0); --card: oklch(1 0 0); @@ -123,9 +125,17 @@ } body { @apply bg-background text-foreground; + position: relative; + min-width: 0; + overflow-x: hidden; + min-height: 100dvh; + padding-left: env(safe-area-inset-left); + padding-right: env(safe-area-inset-right); } html { @apply font-sans; + min-width: 0; + overflow-x: hidden; } } @@ -361,4 +371,4 @@ .dark .fc .fc-timegrid-now-indicator-line { border-color: #a78bfa !important; -} \ No newline at end of file +} diff --git a/frontend/app/layout.tsx b/frontend/app/layout.tsx index c04fac6..c725e7e 100644 --- a/frontend/app/layout.tsx +++ b/frontend/app/layout.tsx @@ -1,13 +1,7 @@ import type { Metadata } from "next"; -import { Poppins } from "next/font/google"; import "./globals.css"; import { ThemeProvider } from "@/components/theme-provider"; - -const poppins = Poppins({ - variable: "--font-sans", - subsets: ["latin"], - weight: ["300", "400", "500", "600", "700", "800"], -}); +import { AuthSessionWatcher } from "@/components/AuthSessionWatcher"; export const metadata: Metadata = { title: "Timeora — Your Intelligent Time Companion", @@ -32,7 +26,7 @@ export default function RootLayout({ return ( <html lang="en" - className={`${poppins.variable} font-sans h-full antialiased`} + className="font-sans h-full antialiased" suppressHydrationWarning > <body className="min-h-full flex flex-col font-sans"> @@ -42,6 +36,7 @@ export default function RootLayout({ enableSystem disableTransitionOnChange > + <AuthSessionWatcher /> {children} </ThemeProvider> </body> diff --git a/frontend/app/login/page.tsx b/frontend/app/login/page.tsx index 8dab511..c0b9a7d 100644 --- a/frontend/app/login/page.tsx +++ b/frontend/app/login/page.tsx @@ -1,12 +1,14 @@ 'use client'; import { useState } from 'react'; +import Image from 'next/image'; import { useRouter } from 'next/navigation'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; import { Mail, Lock, Eye, EyeOff, ArrowRight, ArrowLeft } from 'lucide-react'; import Link from 'next/link'; +import { persistAuthTokens } from '@/lib/session'; export default function LoginPage() { const router = useRouter(); @@ -44,10 +46,7 @@ export default function LoginPage() { throw new Error('Login response did not include an access token'); } - localStorage.setItem('token', data.access_token); - if (data.refresh_token) { - localStorage.setItem('refresh_token', data.refresh_token); - } + persistAuthTokens(data); router.push('/dashboard'); } catch (err: unknown) { setError(err instanceof Error ? err.message : 'Login failed'); @@ -74,17 +73,19 @@ export default function LoginPage() { {/* Brand Logo Header */} <div className="flex justify-center mb-8"> <Link href="/"> - <img + <Image src="/logomark_text_lightmode.png" alt="Timeora Logo" - className="block dark:hidden object-contain" - style={{ height: "40px", width: "auto" }} + width={585} + height={148} + className="block h-10 w-auto object-contain dark:hidden" /> - <img + <Image src="/logomark_text.png" alt="Timeora Logo" - className="hidden dark:block object-contain" - style={{ height: "40px", width: "auto" }} + width={588} + height={166} + className="hidden h-10 w-auto object-contain dark:block" /> </Link> </div> diff --git a/frontend/app/page.tsx b/frontend/app/page.tsx index 0dbec4d..4a98742 100644 --- a/frontend/app/page.tsx +++ b/frontend/app/page.tsx @@ -1,6 +1,7 @@ "use client"; import Link from "next/link"; +import Image from "next/image"; import { useEffect, useState } from "react"; import { useTheme } from "next-themes"; import { useRouter } from "next/navigation"; @@ -77,17 +78,19 @@ export default function LandingPage() { > <div className="max-w-6xl mx-auto px-6 h-16 flex items-center justify-between"> <Link href="/" className="flex items-center group"> - <img + <Image src="/logomark_text_lightmode.png" alt="Timeora Logo" - className="block dark:hidden object-contain transition-transform group-hover:scale-102" - style={{ height: "28px", width: "111px" }} + width={585} + height={148} + className="block h-7 w-[111px] object-contain transition-transform group-hover:scale-102 dark:hidden" /> - <img + <Image src="/logomark_text.png" alt="Timeora Logo" - className="hidden dark:block object-contain transition-transform group-hover:scale-102" - style={{ height: "28px", width: "99px" }} + width={588} + height={166} + className="hidden h-7 w-[99px] object-contain transition-transform group-hover:scale-102 dark:block" /> </Link> <div className="flex items-center gap-3"> @@ -513,14 +516,18 @@ export default function LandingPage() { <footer className="px-6 py-12 border-t border-slate-100 dark:border-zinc-800 bg-slate-50 dark:bg-zinc-900"> <div className="max-w-6xl mx-auto flex flex-col md:flex-row items-center justify-between gap-6"> <div className="flex items-center gap-3"> - <img + <Image src="/logomark_lightmode.png" alt="Timeora Logo" + width={474} + height={403} className="block dark:hidden w-8 h-8 object-contain" /> - <img + <Image src="/logomark.png" alt="Timeora Logo" + width={627} + height={502} className="hidden dark:block w-8 h-8 object-contain" /> <span className="text-lg font-bold text-slate-800 dark:text-zinc-200 tracking-tight">Timeora</span> diff --git a/frontend/app/profile/page.test.tsx b/frontend/app/profile/page.test.tsx new file mode 100644 index 0000000..eb52ae5 --- /dev/null +++ b/frontend/app/profile/page.test.tsx @@ -0,0 +1,92 @@ +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { createElement, type ImgHTMLAttributes } from "react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { exportIcs } from "@/lib/api"; +import ProfilePage from "./page"; + +const router = vi.hoisted(() => ({ + push: vi.fn(), + replace: vi.fn(), +})); + +vi.mock("next/navigation", () => ({ + useRouter: () => router, +})); + +vi.mock("next/image", () => ({ + default: (props: ImgHTMLAttributes<HTMLImageElement>) => createElement("img", { + alt: props.alt ?? "", + ...props, + }), +})); + +vi.mock("@/lib/api", () => ({ + exportIcs: vi.fn(), +})); + +const exportIcsMock = vi.mocked(exportIcs); + +describe("ProfilePage", () => { + beforeEach(() => { + localStorage.clear(); + localStorage.setItem("token", "not-a-real-jwt"); + router.push.mockClear(); + router.replace.mockClear(); + exportIcsMock.mockReset(); + vi.stubGlobal("URL", { + ...URL, + createObjectURL: vi.fn(() => "blob:timeora-export"), + revokeObjectURL: vi.fn(), + }); + vi.spyOn(HTMLAnchorElement.prototype, "click").mockImplementation(() => undefined); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + vi.restoreAllMocks(); + }); + + it("reads email from base64url encoded JWT payloads", async () => { + const payloadWithBase64UrlCharacter = + "eyJlbWFpbCI6ImZyZWRkeS10YW1hQGV4YW1wbGUuY29tIiwibmFtZSI6IsK-In0"; + localStorage.setItem("token", `header.${payloadWithBase64UrlCharacter}.signature`); + + render(<ProfilePage />); + + expect(await screen.findByText("freddy-tama@example.com")).toBeVisible(); + }); + + it("exports profile data through the shared calendar export client", async () => { + const user = userEvent.setup(); + exportIcsMock.mockResolvedValue(new Blob(["BEGIN:VCALENDAR"])); + + render(<ProfilePage />); + + await user.click(screen.getByRole("button", { name: /^Export$/ })); + + expect(exportIcsMock).toHaveBeenCalledOnce(); + expect(await screen.findByText("Data exported successfully!")).toBeVisible(); + }); + + it("attaches export download links before clicking for mobile browser compatibility", async () => { + const user = userEvent.setup(); + const appendSpy = vi.spyOn(document.body, "appendChild"); + exportIcsMock.mockResolvedValue(new Blob(["BEGIN:VCALENDAR"])); + + render(<ProfilePage />); + + await user.click(screen.getByRole("button", { name: /^Export$/ })); + await screen.findByText("Data exported successfully!"); + + const appendedLink = appendSpy.mock.calls + .map(([node]) => node) + .find((node): node is HTMLAnchorElement => node instanceof HTMLAnchorElement); + + expect(appendedLink).toBeDefined(); + expect(appendedLink?.href).toBe("blob:timeora-export"); + expect(appendedLink?.download).toBe("timeora-full-export.ics"); + expect(document.querySelector('a[download="timeora-full-export.ics"]')).toBeNull(); + }); +}); diff --git a/frontend/app/profile/page.tsx b/frontend/app/profile/page.tsx index 258f936..dccda2a 100644 --- a/frontend/app/profile/page.tsx +++ b/frontend/app/profile/page.tsx @@ -1,34 +1,43 @@ "use client"; import { useEffect, useState } from "react"; +import Image from "next/image"; import { useRouter } from "next/navigation"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; -import { ArrowLeft, Save, User, Clock, Shield, Trash2, Download, Calendar as CalendarIcon } from "lucide-react"; +import { ArrowLeft, Save, User, Clock, Shield, Trash2, Download } from "lucide-react"; +import { + DEFAULT_PREFERENCES, + readStoredPreferences, + savePreferences, + type UserPreferences, +} from "@/lib/preferences"; +import { exportIcs } from "@/lib/api"; -interface UserPreferences { - timezone: string; - defaultDuration: number; - workingHoursStart: string; - workingHoursEnd: string; +function decodeBase64Url(value: string): string { + const base64 = value.replace(/-/g, "+").replace(/_/g, "/"); + const padded = base64.padEnd(base64.length + ((4 - (base64.length % 4)) % 4), "="); + const binary = atob(padded); + const bytes = Uint8Array.from(binary, (char) => char.charCodeAt(0)); + return new TextDecoder().decode(bytes); } -const DEFAULT_PREFERENCES: UserPreferences = { - timezone: "", - defaultDuration: 60, - workingHoursStart: "09:00", - workingHoursEnd: "17:00", -}; - function getTokenEmail(): string | null { try { const token = localStorage.getItem("token"); if (!token) return null; + const payloadSegment = token.split(".")[1]; + if (!payloadSegment) return null; // Simple JWT payload decode (no validation) - const payload = JSON.parse(atob(token.split(".")[1])); - return payload.email || payload.user_metadata?.email || null; + const payload = JSON.parse(decodeBase64Url(payloadSegment)) as { + email?: unknown; + user_metadata?: { email?: unknown }; + }; + if (typeof payload.email === "string") return payload.email; + if (typeof payload.user_metadata?.email === "string") return payload.user_metadata.email; + return null; } catch { return null; } @@ -54,20 +63,8 @@ export default function ProfilePage() { // eslint-disable-next-line react-hooks/set-state-in-effect setUserEmail(email); - // Load from localStorage - const saved = localStorage.getItem("timeora_preferences"); - if (saved) { - try { - const parsed = JSON.parse(saved); - setPreferences({ ...DEFAULT_PREFERENCES, ...parsed }); - } catch { - // ignore - } - } else { - // Use detected timezone if none saved - const detected = Intl.DateTimeFormat().resolvedOptions().timeZone; - setPreferences((prev) => ({ ...prev, timezone: detected })); - } + const detected = Intl.DateTimeFormat().resolvedOptions().timeZone; + setPreferences(readStoredPreferences(detected)); }, [router]); const handleChange = (key: keyof UserPreferences, value: string | number) => { @@ -79,7 +76,8 @@ export default function ProfilePage() { setMessage(null); try { - localStorage.setItem("timeora_preferences", JSON.stringify(preferences)); + const normalized = savePreferences(preferences); + setPreferences(normalized); // Optional: sync to backend later setMessage("Preferences saved successfully!"); @@ -93,20 +91,19 @@ export default function ProfilePage() { const handleExportData = async () => { try { - const token = localStorage.getItem("token"); - const baseUrl = (process.env.NEXT_PUBLIC_API_URL || "http://localhost:8000/api").replace(/\/$/, ""); - const res = await fetch(`${baseUrl}/export/ics`, { - headers: token ? { Authorization: `Bearer ${token}` } : {}, - }); - if (!res.ok) throw new Error("Export failed"); - - const blob = await res.blob(); + const blob = await exportIcs(); const url = URL.createObjectURL(blob); const a = document.createElement("a"); - a.href = url; - a.download = "timeora-full-export.ics"; - a.click(); - URL.revokeObjectURL(url); + try { + a.href = url; + a.download = "timeora-full-export.ics"; + a.style.display = "none"; + document.body.appendChild(a); + a.click(); + } finally { + a.remove(); + URL.revokeObjectURL(url); + } setMessage("Data exported successfully!"); } catch { setMessage("Export failed. Please try again."); @@ -139,14 +136,18 @@ export default function ProfilePage() { Back to Dashboard </Button> <div className="flex items-center gap-2"> - <img + <Image src="/logomark_lightmode.png" alt="Timeora Logo" + width={474} + height={403} className="block dark:hidden w-8 h-8 object-contain" /> - <img + <Image src="/logomark.png" alt="Timeora Logo" + width={627} + height={502} className="hidden dark:block w-8 h-8 object-contain" /> <div> diff --git a/frontend/app/register/page.tsx b/frontend/app/register/page.tsx index 2e63ef4..7706e75 100644 --- a/frontend/app/register/page.tsx +++ b/frontend/app/register/page.tsx @@ -1,12 +1,14 @@ 'use client'; import { useState } from 'react'; +import Image from 'next/image'; import { useRouter } from 'next/navigation'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; import { Mail, Lock, Eye, EyeOff, ArrowRight, ArrowLeft } from 'lucide-react'; import Link from 'next/link'; +import { persistAuthTokens } from '@/lib/session'; export default function RegisterPage() { const router = useRouter(); @@ -53,10 +55,7 @@ export default function RegisterPage() { } if (data.access_token) { - localStorage.setItem('token', data.access_token); - if (data.refresh_token) { - localStorage.setItem('refresh_token', data.refresh_token); - } + persistAuthTokens(data); router.push('/dashboard'); } else { setSuccess('Account created! Redirecting to login...'); @@ -89,17 +88,19 @@ export default function RegisterPage() { {/* Brand Logo Header */} <div className="flex justify-center mb-8"> <Link href="/"> - <img + <Image src="/logomark_text_lightmode.png" alt="Timeora Logo" - className="block dark:hidden object-contain" - style={{ height: "40px", width: "auto" }} + width={585} + height={148} + className="block h-10 w-auto object-contain dark:hidden" /> - <img + <Image src="/logomark_text.png" alt="Timeora Logo" - className="hidden dark:block object-contain" - style={{ height: "40px", width: "auto" }} + width={588} + height={166} + className="hidden h-10 w-auto object-contain dark:block" /> </Link> </div> diff --git a/frontend/components/AuthSessionWatcher.test.tsx b/frontend/components/AuthSessionWatcher.test.tsx new file mode 100644 index 0000000..689aa74 --- /dev/null +++ b/frontend/components/AuthSessionWatcher.test.tsx @@ -0,0 +1,22 @@ +import { render } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; + +const router = vi.hoisted(() => ({ + replace: vi.fn(), +})); + +vi.mock("next/navigation", () => ({ + useRouter: () => router, +})); + +import { AuthSessionWatcher } from "./AuthSessionWatcher"; + +describe("AuthSessionWatcher", () => { + it("redirects to login when the API client reports an expired session", () => { + render(<AuthSessionWatcher />); + + window.dispatchEvent(new CustomEvent("timeora:auth-expired")); + + expect(router.replace).toHaveBeenCalledWith("/login"); + }); +}); diff --git a/frontend/components/AuthSessionWatcher.tsx b/frontend/components/AuthSessionWatcher.tsx new file mode 100644 index 0000000..6ea929d --- /dev/null +++ b/frontend/components/AuthSessionWatcher.tsx @@ -0,0 +1,19 @@ +"use client"; + +import { useEffect } from "react"; +import { useRouter } from "next/navigation"; + +export function AuthSessionWatcher() { + const router = useRouter(); + + useEffect(() => { + const handleAuthExpired = () => { + router.replace("/login"); + }; + + window.addEventListener("timeora:auth-expired", handleAuthExpired); + return () => window.removeEventListener("timeora:auth-expired", handleAuthExpired); + }, [router]); + + return null; +} diff --git a/frontend/components/NotificationCenter.test.tsx b/frontend/components/NotificationCenter.test.tsx new file mode 100644 index 0000000..6396578 --- /dev/null +++ b/frontend/components/NotificationCenter.test.tsx @@ -0,0 +1,28 @@ +import { render, screen } from "@testing-library/react"; +import type { EventInput } from "@fullcalendar/core"; +import { describe, expect, it } from "vitest"; + +import { NotificationCenter } from "./NotificationCenter"; + +function dueSoonEvent(): EventInput { + const start = new Date(); + start.setMinutes(start.getMinutes() + 14); + return { + id: "event-1", + title: "Product Sync", + start, + extendedProps: { + participants: "team@example.com", + reminder_minutes: 15, + }, + }; +} + +describe("NotificationCenter", () => { + it("shows an in-app reminder fallback when browser notifications are unavailable", async () => { + render(<NotificationCenter events={[dueSoonEvent()]} />); + + expect(await screen.findByText("Product Sync")).toBeVisible(); + expect(screen.getByText(/Starts at/)).toBeVisible(); + }); +}); diff --git a/frontend/components/NotificationCenter.tsx b/frontend/components/NotificationCenter.tsx new file mode 100644 index 0000000..91bdb30 --- /dev/null +++ b/frontend/components/NotificationCenter.tsx @@ -0,0 +1,157 @@ +"use client"; + +import { useEffect, useMemo, useRef, useState } from "react"; +import type { EventInput } from "@fullcalendar/core"; +import { Bell, BellOff, X } from "lucide-react"; + +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { + createReminderScheduler, + requestReminderPermission, + type ReminderDelivery, + type ReminderEvent, + type ReminderPermissionState, +} from "@/lib/reminders"; + +function readPermission(): ReminderPermissionState { + if (typeof Notification === "undefined") return "unsupported"; + return Notification.permission; +} + +function pad(value: number): string { + return String(value).padStart(2, "0"); +} + +function toLocalDate(value: Date): string { + return `${value.getFullYear()}-${pad(value.getMonth() + 1)}-${pad(value.getDate())}`; +} + +function toLocalTime(value: Date): string { + return `${pad(value.getHours())}:${pad(value.getMinutes())}:${pad(value.getSeconds())}`; +} + +function toReminderEvent(event: EventInput): ReminderEvent | null { + const ext = (event.extendedProps || {}) as Record<string, unknown>; + if (ext.reminder_minutes === null || ext.reminder_minutes === undefined) { + return null; + } + if (!event.id || !event.title || !event.start) return null; + + const start = event.start instanceof Date ? event.start : new Date(event.start as string | number); + if (Number.isNaN(start.getTime())) return null; + + const reminderMinutes = + typeof ext.reminder_minutes === "number" ? ext.reminder_minutes : Number(ext.reminder_minutes); + if (!Number.isFinite(reminderMinutes) || reminderMinutes < 0) { + return null; + } + + return { + id: String(event.id), + title: String(event.title), + date: toLocalDate(start), + start_time: toLocalTime(start), + participants: typeof ext.participants === "string" ? ext.participants : "", + reminder_minutes: reminderMinutes, + }; +} + +export function NotificationCenter({ events }: { events: EventInput[] }) { + const [permission, setPermission] = useState<ReminderPermissionState>(() => readPermission()); + const [fallbacks, setFallbacks] = useState<ReminderDelivery[]>([]); + const schedulerRef = useRef<ReturnType<typeof createReminderScheduler> | null>(null); + + const reminderEvents = useMemo( + () => events.map(toReminderEvent).filter((event): event is ReminderEvent => event !== null), + [events], + ); + + useEffect(() => { + schedulerRef.current = createReminderScheduler({ + fallback: (delivery) => { + setFallbacks((current) => { + if (current.some((item) => item.key === delivery.key)) return current; + return [delivery, ...current].slice(0, 4); + }); + }, + }); + + return () => schedulerRef.current?.cancel(); + }, []); + + useEffect(() => { + schedulerRef.current?.schedule(reminderEvents); + }, [reminderEvents]); + + const handleEnableNotifications = async () => { + const nextPermission = await requestReminderPermission(); + setPermission(nextPermission); + schedulerRef.current?.schedule(reminderEvents); + }; + + const dismiss = (key: string) => { + setFallbacks((current) => current.filter((item) => item.key !== key)); + }; + + const statusLabel = permission === "granted" + ? "Notifications on" + : permission === "default" + ? "Notifications optional" + : "In-app reminders"; + + return ( + <section className="flex flex-col gap-3 rounded-2xl border border-slate-200/60 bg-white/70 p-3 text-sm dark:border-white/10 dark:bg-zinc-900/60"> + <div className="flex items-center justify-between gap-3"> + <div className="flex items-center gap-2"> + <span className="flex size-8 items-center justify-center rounded-xl bg-violet-500/10 text-violet-600 dark:text-violet-300"> + {permission === "granted" ? <Bell /> : <BellOff />} + </span> + <div> + <p className="font-semibold text-slate-800 dark:text-zinc-100">Reminders</p> + <p className="text-xs text-muted-foreground">{statusLabel}</p> + </div> + </div> + {permission === "default" ? ( + <Button type="button" size="sm" variant="outline" onClick={handleEnableNotifications}> + Enable + </Button> + ) : ( + <Badge variant="secondary">{reminderEvents.length}</Badge> + )} + </div> + + {permission !== "granted" ? ( + <p className="text-xs leading-5 text-muted-foreground"> + Timeora will show reminders here when browser notifications are unavailable or blocked. + </p> + ) : null} + + {fallbacks.length ? ( + <div className="flex flex-col gap-2" aria-live="polite"> + {fallbacks.map((reminder) => ( + <div + key={reminder.key} + className="flex items-start justify-between gap-3 rounded-xl border border-violet-200/70 bg-violet-50 px-3 py-2 dark:border-violet-900/50 dark:bg-violet-950/30" + > + <div className="min-w-0"> + <p className="truncate font-medium text-violet-900 dark:text-violet-100">{reminder.title}</p> + <p className="text-xs text-violet-700/80 dark:text-violet-300/80">{reminder.body}</p> + </div> + <Button + type="button" + variant="ghost" + size="icon-sm" + aria-label={`Dismiss ${reminder.title} reminder`} + onClick={() => dismiss(reminder.key)} + className="shrink-0" + > + <X /> + </Button> + </div> + ))} + </div> + ) : null} + </section> + ); +} diff --git a/frontend/components/TodayAgenda.test.tsx b/frontend/components/TodayAgenda.test.tsx new file mode 100644 index 0000000..41659c2 --- /dev/null +++ b/frontend/components/TodayAgenda.test.tsx @@ -0,0 +1,31 @@ +import { render, screen } from "@testing-library/react"; +import type { EventInput } from "@fullcalendar/core"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { TodayAgenda } from "./TodayAgenda"; + +describe("TodayAgenda", () => { + afterEach(() => { + vi.useRealTimers(); + }); + + it("shows an overnight event that started yesterday but is still happening now", () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-07-07T00:30:00")); + const overnightEvent: EventInput = { + id: "event-1", + title: "Late deployment", + start: "2026-07-06T23:30:00", + end: "2026-07-07T01:00:00", + extendedProps: { + duration_minutes: 90, + category: "meeting", + }, + }; + + render(<TodayAgenda events={[overnightEvent]} />); + + expect(screen.getAllByText("Late deployment").length).toBeGreaterThan(0); + expect(screen.getByText("Happening now")).toBeInTheDocument(); + }); +}); diff --git a/frontend/components/TodayAgenda.tsx b/frontend/components/TodayAgenda.tsx index 9335a69..b4feaee 100644 --- a/frontend/components/TodayAgenda.tsx +++ b/frontend/components/TodayAgenda.tsx @@ -1,7 +1,7 @@ "use client"; import { useEffect, useMemo, useState } from "react"; -import { format, isToday, differenceInMinutes } from "date-fns"; +import { format, differenceInMinutes } from "date-fns"; import { Sun, Sunrise, @@ -51,6 +51,19 @@ function formatCountdown(minutes: number): string { return `${hours}j ${mins}m lagi`; } +function dayBounds(day: Date): { start: Date; end: Date } { + const start = new Date(day); + start.setHours(0, 0, 0, 0); + const end = new Date(start); + end.setDate(end.getDate() + 1); + return { start, end }; +} + +function overlapsDay(start: Date, end: Date, day: Date): boolean { + const bounds = dayBounds(day); + return start < bounds.end && end > bounds.start; +} + export function TodayAgenda({ events, onEventClick }: TodayAgendaProps) { const [now, setNow] = useState(new Date()); @@ -66,25 +79,42 @@ export function TodayAgenda({ events, onEventClick }: TodayAgendaProps) { if (!e.start) return false; const raw = e.start; const start = raw instanceof Date ? raw : new Date(raw as string | number); - return isToday(start); + if (Number.isNaN(start.getTime())) return false; + const rawEnd = e.end; + const ext = (e.extendedProps || {}) as Record<string, unknown>; + const durationMinutes = + typeof ext.duration_minutes === "number" && Number.isFinite(ext.duration_minutes) + ? ext.duration_minutes + : 60; + const end = rawEnd + ? rawEnd instanceof Date ? rawEnd : new Date(rawEnd as string | number) + : new Date(start.getTime() + durationMinutes * 60_000); + if (Number.isNaN(end.getTime())) return false; + return overlapsDay(start, end, now); }) .map((e) => { const rawS = e.start!; - const rawE = e.end!; const start = rawS instanceof Date ? rawS : new Date(rawS as string | number); - const end = rawE instanceof Date ? rawE : new Date(rawE as string | number); const ext = (e.extendedProps || {}) as Record<string, unknown>; + const configuredDuration = + typeof ext.duration_minutes === "number" && Number.isFinite(ext.duration_minutes) + ? ext.duration_minutes + : null; + const rawE = e.end; + const end = rawE + ? rawE instanceof Date ? rawE : new Date(rawE as string | number) + : new Date(start.getTime() + (configuredDuration ?? 60) * 60_000); return { id: e.id as string, title: e.title as string, start, end, category: ext.category as string | null | undefined, - duration_minutes: (ext.duration_minutes as number) || differenceInMinutes(end, start), + duration_minutes: configuredDuration ?? differenceInMinutes(end, start), }; }) .sort((a, b) => a.start.getTime() - b.start.getTime()); - }, [events]); + }, [events, now]); const nextEvent = useMemo(() => { return todayEvents.find((e) => e.end > now); diff --git a/frontend/components/assistant/AssistantPanel.test.tsx b/frontend/components/assistant/AssistantPanel.test.tsx new file mode 100644 index 0000000..ea1d40a --- /dev/null +++ b/frontend/components/assistant/AssistantPanel.test.tsx @@ -0,0 +1,170 @@ +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { callAssistant, executeAssistant } from "@/lib/api"; +import { AssistantPanel } from "./AssistantPanel"; + +vi.mock("@/lib/api", async () => { + const actual = await vi.importActual<typeof import("@/lib/api")>("@/lib/api"); + return { + ...actual, + callAssistant: vi.fn(), + executeAssistant: vi.fn(), + }; +}); + +const callAssistantMock = vi.mocked(callAssistant); +const executeAssistantMock = vi.mocked(executeAssistant); + +describe("AssistantPanel", () => { + beforeEach(() => { + callAssistantMock.mockReset(); + executeAssistantMock.mockReset(); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it("submits from a visible Send button and renders feedback", async () => { + const user = userEvent.setup(); + callAssistantMock.mockResolvedValue({ + intent: "query", + result: [], + events: [], + suggested_actions: [], + message: "I found 3 events today.", + }); + render(<AssistantPanel open onOpenChange={vi.fn()} onEventsChanged={vi.fn()} />); + + await user.type(screen.getByPlaceholderText("Ask or type a message…"), "Apa jadwal saya hari ini?"); + await user.click(screen.getByRole("button", { name: "Send" })); + + expect(callAssistantMock).toHaveBeenCalledWith("Apa jadwal saya hari ini?", undefined); + expect(await screen.findByText("I found 3 events today.")).toBeVisible(); + }); + + it("still submits on browsers without crypto.randomUUID", async () => { + const user = userEvent.setup(); + vi.stubGlobal("crypto", {}); + callAssistantMock.mockResolvedValue({ + intent: "query", + result: [], + events: [], + suggested_actions: [], + message: "Fallback IDs still work.", + }); + render(<AssistantPanel open onOpenChange={vi.fn()} onEventsChanged={vi.fn()} />); + + await user.type(screen.getByPlaceholderText("Ask or type a message…"), "Apa jadwal saya?"); + await user.click(screen.getByRole("button", { name: "Send" })); + + expect(callAssistantMock).toHaveBeenCalledWith("Apa jadwal saya?", undefined); + expect(await screen.findByText("Fallback IDs still work.")).toBeVisible(); + }); + + it("renders clarification choices and resubmits with the selected event", async () => { + const user = userEvent.setup(); + callAssistantMock + .mockResolvedValueOnce({ + intent: "cancel", + result: { events: [] }, + message: "Which event did you mean?", + clarification: { + type: "event_selection", + prompt: "Which team meeting did you mean?", + choices: [ + { id: "one", title: "Marketing Sync", start_time: "10:00:00" }, + { id: "two", title: "Product Sync", start_time: "14:00:00" }, + ], + }, + }) + .mockResolvedValueOnce({ + intent: "cancel", + result: { primary_event_id: "two", primary_title: "Product Sync" }, + message: "Cancel Product Sync?", + requires_confirmation: true, + }); + render(<AssistantPanel open onOpenChange={vi.fn()} onEventsChanged={vi.fn()} />); + + await user.type(screen.getByPlaceholderText("Ask or type a message…"), "Batalkan team sync"); + await user.click(screen.getByRole("button", { name: "Send" })); + await user.click(await screen.findByRole("button", { name: /Product Sync/ })); + + expect(callAssistantMock).toHaveBeenLastCalledWith("Batalkan team sync", { + selected_event_id: "two", + }); + expect(await screen.findByRole("button", { name: "Confirm action" })).toBeVisible(); + }); + + it("confirms native update tool results with event data", async () => { + const user = userEvent.setup(); + const onEventsChanged = vi.fn(); + callAssistantMock.mockResolvedValueOnce({ + intent: "update", + result: { + primary_event_id: "event-1", + event_data: { description: "Updated prep notes", priority: "important" }, + }, + message: "Update Product Sync?", + requires_confirmation: true, + }); + executeAssistantMock.mockResolvedValueOnce({ + intent: "update", + result: { id: "event-1", title: "Product Sync" }, + message: "Event updated.", + executed: true, + }); + render(<AssistantPanel open onOpenChange={vi.fn()} onEventsChanged={onEventsChanged} />); + + await user.type(screen.getByPlaceholderText("Ask or type a message…"), "Make Product Sync important"); + await user.click(screen.getByRole("button", { name: "Send" })); + await user.click(await screen.findByRole("button", { name: "Confirm action" })); + + expect(executeAssistantMock).toHaveBeenCalledWith({ + action: "update", + event_id: "event-1", + event_data: { description: "Updated prep notes", priority: "important" }, + }); + expect(onEventsChanged).toHaveBeenCalled(); + }); + + it("does not offer confirmation when the assistant action payload is incomplete", async () => { + const user = userEvent.setup(); + callAssistantMock.mockResolvedValueOnce({ + intent: "create", + result: {}, + message: "Create this event?", + requires_confirmation: true, + }); + render(<AssistantPanel open onOpenChange={vi.fn()} onEventsChanged={vi.fn()} />); + + await user.type(screen.getByPlaceholderText("Ask or type a message…"), "Jadwalkan sesuatu"); + await user.click(screen.getByRole("button", { name: "Send" })); + + expect(await screen.findByText("Create this event?")).toBeVisible(); + expect(screen.queryByRole("button", { name: "Confirm action" })).not.toBeInTheDocument(); + }); + + it("supports older Android WebView media query listeners", () => { + const addListener = vi.fn(); + const removeListener = vi.fn(); + vi.stubGlobal("matchMedia", vi.fn().mockReturnValue({ + matches: true, + media: "(max-width: 767px)", + onchange: null, + addListener, + removeListener, + dispatchEvent: () => false, + })); + + const { unmount } = render(<AssistantPanel open onOpenChange={vi.fn()} onEventsChanged={vi.fn()} />); + + expect(addListener).toHaveBeenCalledWith(expect.any(Function)); + + unmount(); + + expect(removeListener).toHaveBeenCalledWith(expect.any(Function)); + }); +}); diff --git a/frontend/components/assistant/AssistantPanel.tsx b/frontend/components/assistant/AssistantPanel.tsx new file mode 100644 index 0000000..1932ca7 --- /dev/null +++ b/frontend/components/assistant/AssistantPanel.tsx @@ -0,0 +1,360 @@ +"use client"; + +import { FormEvent, useEffect, useRef, useState } from "react"; +import { Bot, Check, RotateCcw, Send, Sparkles, X } from "lucide-react"; +import { AnimatePresence, motion, useReducedMotion } from "framer-motion"; + +import { Button } from "@/components/ui/button"; +import { + Drawer, + DrawerContent, + DrawerDescription, + DrawerHeader, + DrawerTitle, +} from "@/components/ui/drawer"; +import { Textarea } from "@/components/ui/textarea"; +import { + callAssistant, + executeAssistant, + type AssistantExecuteParams, + type AssistantResult, +} from "@/lib/api"; +import type { EventData } from "@/components/calendar/EventDialog"; +import { ClarificationCard } from "./ClarificationCard"; + +type Message = { + id: string; + role: "user" | "assistant"; + text: string; + requestText?: string; + result?: AssistantResult; +}; + +let fallbackMessageCounter = 0; + +function createMessageId(): string { + if (typeof globalThis.crypto?.randomUUID === "function") { + return globalThis.crypto.randomUUID(); + } + + fallbackMessageCounter += 1; + return `msg-${Date.now().toString(36)}-${fallbackMessageCounter.toString(36)}`; +} + +function subscribeMediaQuery(query: MediaQueryList, listener: () => void): () => void { + if (typeof query.addEventListener === "function") { + query.addEventListener("change", listener); + return () => query.removeEventListener("change", listener); + } + + query.addListener(listener); + return () => query.removeListener(listener); +} + +function useMobile(): boolean { + const [mobile, setMobile] = useState(false); + useEffect(() => { + const query = window.matchMedia("(max-width: 767px)"); + const update = () => setMobile(query.matches); + update(); + return subscribeMediaQuery(query, update); + }, []); + return mobile; +} + +function isRecord(value: unknown): value is Record<string, unknown> { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function isNonEmptyString(value: unknown): value is string { + return typeof value === "string" && value.trim().length > 0; +} + +function executionParams(result: AssistantResult): AssistantExecuteParams | null { + if (!result.requires_confirmation || !isRecord(result.result)) return null; + const data = result.result; + if (result.intent === "create") { + if (!isRecord(data.event_data)) return null; + return { + action: "create", + event_data: data.event_data, + }; + } + if (result.intent === "cancel") { + if (!isNonEmptyString(data.primary_event_id)) return null; + return { + action: "cancel", + event_id: data.primary_event_id, + }; + } + if (result.intent === "reschedule") { + if ( + !isNonEmptyString(data.primary_event_id) || + !isNonEmptyString(data.new_date) || + !isNonEmptyString(data.new_time) + ) { + return null; + } + return { + action: "reschedule", + event_id: data.primary_event_id, + new_date: data.new_date, + new_time: data.new_time, + }; + } + if (result.intent === "update" || result.intent === "edit") { + const eventId = typeof data.primary_event_id === "string" + ? data.primary_event_id + : typeof data.event_id === "string" + ? data.event_id + : null; + const eventData = data.event_data; + if (!eventId || !eventData || typeof eventData !== "object" || Array.isArray(eventData)) { + return null; + } + return { + action: "update", + event_id: eventId, + event_data: eventData as Record<string, unknown>, + }; + } + return null; +} + +export function AssistantPanel({ + open, + onOpenChange, + onEventsChanged, + contextEvent, +}: { + open: boolean; + onOpenChange: (open: boolean) => void; + onEventsChanged: () => void; + contextEvent?: EventData | null; +}) { + const [query, setQuery] = useState(""); + const [messages, setMessages] = useState<Message[]>([]); + const [pending, setPending] = useState(false); + const [retryText, setRetryText] = useState<string | null>(null); + const scrollRef = useRef<HTMLDivElement>(null); + const isMobile = useMobile(); + const reduceMotion = useReducedMotion(); + + useEffect(() => { + const listener = (event: KeyboardEvent) => { + if (event.key.toLowerCase() === "k" && (event.metaKey || event.ctrlKey)) { + event.preventDefault(); + onOpenChange(!open); + } + if (event.key === "Escape" && open) onOpenChange(false); + }; + document.addEventListener("keydown", listener); + return () => document.removeEventListener("keydown", listener); + }, [onOpenChange, open]); + + useEffect(() => { + scrollRef.current?.scrollTo?.({ top: scrollRef.current.scrollHeight, behavior: reduceMotion ? "auto" : "smooth" }); + }, [messages, pending, reduceMotion]); + + const submit = async ( + text: string, + options?: { selected_event_id?: string; context_event_id?: string }, + addUserMessage = true, + ) => { + const normalized = text.trim(); + if (!normalized || pending) return; + if (addUserMessage) { + setMessages((current) => [...current, { id: createMessageId(), role: "user", text: normalized }]); + } + setPending(true); + setRetryText(null); + try { + const result = await callAssistant(normalized, options); + setMessages((current) => [ + ...current, + { + id: createMessageId(), + role: "assistant", + text: result.message, + requestText: normalized, + result, + }, + ]); + setQuery(""); + } catch (error: unknown) { + const message = error instanceof Error ? error.message : "Timeora could not process that request."; + setMessages((current) => [...current, { id: createMessageId(), role: "assistant", text: message }]); + setRetryText(normalized); + } finally { + setPending(false); + } + }; + + const handleSubmit = (event: FormEvent) => { + event.preventDefault(); + void submit( + query, + contextEvent?.id ? { context_event_id: contextEvent.id } : undefined, + ); + }; + + const confirmResult = async (result: AssistantResult) => { + const params = executionParams(result); + if (!params || pending) return; + setPending(true); + try { + const executed = await executeAssistant(params); + setMessages((current) => [ + ...current, + { id: createMessageId(), role: "assistant", text: executed.message, result: executed }, + ]); + onEventsChanged(); + } catch (error: unknown) { + setMessages((current) => [ + ...current, + { + id: createMessageId(), + role: "assistant", + text: error instanceof Error ? error.message : "The calendar action failed.", + }, + ]); + } finally { + setPending(false); + } + }; + + const content = ( + <div className="flex min-h-0 flex-1 flex-col bg-popover text-popover-foreground"> + <header className="flex min-h-16 items-center justify-between border-b border-border px-4"> + <div className="flex items-center gap-2"> + <span className="flex size-9 items-center justify-center rounded-xl bg-primary text-primary-foreground"> + <Sparkles className="size-4" /> + </span> + <div> + <h2 className="font-semibold">Ask your calendar</h2> + <p className="text-xs text-muted-foreground">Query, create, and reschedule safely</p> + </div> + </div> + <Button type="button" variant="ghost" size="icon" aria-label="Close AI chat" onClick={() => onOpenChange(false)}> + <X /> + </Button> + </header> + + <div ref={scrollRef} className="flex min-h-0 flex-1 flex-col gap-3 overflow-y-auto px-4 py-4"> + {messages.length === 0 ? ( + <div className="my-auto flex flex-col items-center gap-3 text-center text-muted-foreground"> + <Bot className="size-8 text-primary" /> + <div> + <p className="font-medium text-foreground">Ask directly about your calendar</p> + <p className="mt-1 max-w-xs text-sm">Try “Apa jadwal saya hari ini?” or “Pindahkan Product Sync ke jam 3.”</p> + </div> + </div> + ) : null} + + <AnimatePresence initial={false}> + {messages.map((message) => ( + <motion.div + key={message.id} + initial={reduceMotion ? false : { opacity: 0, y: 8 }} + animate={{ opacity: 1, y: 0 }} + className={message.role === "user" ? "ml-10 rounded-2xl rounded-br-md bg-primary px-4 py-3 text-sm text-primary-foreground" : "mr-6 flex flex-col gap-3 rounded-2xl rounded-bl-md border border-border bg-card px-4 py-3 text-sm"} + > + <p>{message.text}</p> + {message.result?.events?.length ? ( + <div className="flex flex-col gap-2"> + {message.result.events.map((event) => ( + <div key={event.id} className="rounded-lg border border-border bg-background/60 p-2"> + <p className="font-medium">{event.title}</p> + <p className="text-xs text-muted-foreground">{event.date} · {event.start_time.slice(0, 5)}</p> + </div> + ))} + </div> + ) : null} + {message.result?.clarification && message.requestText ? ( + <ClarificationCard + clarification={message.result.clarification} + disabled={pending} + onSelect={(eventId) => void submit(message.requestText as string, { selected_event_id: eventId }, false)} + /> + ) : null} + {message.result && executionParams(message.result) ? ( + <div className="flex gap-2"> + <Button type="button" size="sm" disabled={pending} aria-label="Confirm action" onClick={() => void confirmResult(message.result as AssistantResult)}> + <Check data-icon="inline-start" /> Confirm + </Button> + <Button type="button" size="sm" variant="outline" onClick={() => setQuery(message.requestText || "")}>Edit request</Button> + </div> + ) : null} + </motion.div> + ))} + </AnimatePresence> + + {pending ? ( + <div className="mr-auto flex items-center gap-1 rounded-2xl rounded-bl-md border border-border bg-card px-4 py-3" aria-label="AI is thinking"> + {[0, 1, 2].map((dot) => ( + <motion.span + key={dot} + className="size-1.5 rounded-full bg-primary" + animate={reduceMotion ? undefined : { opacity: [0.35, 1, 0.35], y: [0, -2, 0] }} + transition={{ repeat: Infinity, duration: 0.9, delay: dot * 0.12 }} + /> + ))} + </div> + ) : null} + </div> + + <form onSubmit={handleSubmit} className="border-t border-border p-3 pb-[max(0.75rem,env(safe-area-inset-bottom))]"> + {retryText ? ( + <Button type="button" variant="ghost" size="sm" onClick={() => void submit(retryText)}> + <RotateCcw data-icon="inline-start" /> Retry last request + </Button> + ) : null} + <div className="flex items-end gap-2 rounded-2xl border border-input bg-background p-2 focus-within:ring-2 focus-within:ring-ring"> + <Textarea + value={query} + onChange={(event) => setQuery(event.target.value)} + onKeyDown={(event) => { + if (event.key === "Enter" && !event.shiftKey) { + event.preventDefault(); + event.currentTarget.form?.requestSubmit(); + } + }} + placeholder="Ask or type a message…" + rows={1} + disabled={pending} + className="max-h-32 min-h-11 resize-none border-0 bg-transparent shadow-none focus-visible:ring-0" + /> + <Button type="submit" size="icon" aria-label="Send" disabled={pending || !query.trim()} className="size-11 shrink-0 rounded-xl"> + <Send /> + </Button> + </div> + </form> + </div> + ); + + if (isMobile) { + return ( + <Drawer open={open} onOpenChange={onOpenChange} showSwipeHandle snapPoints={[0.58, 0.94]}> + <DrawerContent className="h-[94dvh]"> + <DrawerHeader className="sr-only"> + <DrawerTitle>Ask your calendar</DrawerTitle> + <DrawerDescription>Use AI tools to query and update Timeora events.</DrawerDescription> + </DrawerHeader> + {content} + </DrawerContent> + </Drawer> + ); + } + + return open ? ( + <motion.aside + initial={reduceMotion ? false : { x: 32, opacity: 0 }} + animate={{ x: 0, opacity: 1 }} + exit={{ x: 32, opacity: 0 }} + className="fixed inset-y-0 right-0 z-50 flex w-[min(430px,38vw)] min-w-[360px] border-l border-border bg-popover shadow-2xl" + aria-label="Ask your calendar" + > + {content} + </motion.aside> + ) : null; +} diff --git a/frontend/components/assistant/ClarificationCard.tsx b/frontend/components/assistant/ClarificationCard.tsx new file mode 100644 index 0000000..7e93fc5 --- /dev/null +++ b/frontend/components/assistant/ClarificationCard.tsx @@ -0,0 +1,35 @@ +"use client"; + +import type { AssistantResult } from "@/lib/api"; + +type Clarification = NonNullable<AssistantResult["clarification"]>; + +export function ClarificationCard({ + clarification, + disabled, + onSelect, +}: { + clarification: Clarification; + disabled: boolean; + onSelect: (eventId: string) => void; +}) { + return ( + <div className="flex flex-col gap-2 rounded-xl border border-border bg-background/60 p-3"> + <p className="text-sm font-medium text-foreground">{clarification.prompt}</p> + {clarification.choices.map((choice) => ( + <button + key={choice.id} + type="button" + disabled={disabled} + onClick={() => onSelect(choice.id)} + className="min-h-11 rounded-lg border border-border bg-card px-3 py-2 text-left transition-colors hover:border-primary hover:bg-accent disabled:opacity-50" + > + <span className="block text-sm font-medium">{choice.title}</span> + {choice.start_time ? ( + <span className="text-xs text-muted-foreground">{choice.start_time.slice(0, 5)}</span> + ) : null} + </button> + ))} + </div> + ); +} diff --git a/frontend/components/calendar/EventActions.test.tsx b/frontend/components/calendar/EventActions.test.tsx new file mode 100644 index 0000000..b086187 --- /dev/null +++ b/frontend/components/calendar/EventActions.test.tsx @@ -0,0 +1,85 @@ +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { describe, expect, it, vi } from "vitest"; + +import { EventActions } from "./EventActions"; +import { safeMeetingUrl } from "./EventPreview"; +import type { EventData } from "./EventDialog"; + +const EVENT: EventData = { + id: "event-1", + title: "Product Sync", + date: "2026-07-06", + start_time: "14:00:00", + duration_minutes: 60, + participants: "team@example.com", + recurrence_rule: null, + category: "meeting", + description: "Roadmap review", + location_url: "https://zoom.us/j/123", + priority: "important", + tags: ["planning"], + reminder_minutes: 15, +}; + +describe("EventActions", () => { + it("provides the touch overflow actions", async () => { + const user = userEvent.setup(); + const onEdit = vi.fn(); + const onAskAI = vi.fn(); + const onDelete = vi.fn(); + render( + <EventActions + event={EVENT} + onEdit={onEdit} + onAskAI={onAskAI} + onDelete={onDelete} + > + <div>Event block</div> + </EventActions>, + ); + + await user.click(screen.getByRole("button", { name: "Event actions" })); + await user.click(await screen.findByRole("menuitem", { name: "Ask AI" })); + + expect(onAskAI).toHaveBeenCalledWith(EVENT); + }); + + it("does not bubble overflow action taps to the parent calendar event", async () => { + const user = userEvent.setup(); + const onEdit = vi.fn(); + const onAskAI = vi.fn(); + const onDelete = vi.fn(); + const onParentClick = vi.fn(); + + render( + <div onClick={onParentClick}> + <EventActions + event={EVENT} + onEdit={onEdit} + onAskAI={onAskAI} + onDelete={onDelete} + > + <div>Event block</div> + </EventActions> + </div>, + ); + + await user.click(screen.getByRole("button", { name: "Event actions" })); + await user.click(await screen.findByRole("menuitem", { name: "Ask AI" })); + + expect(onAskAI).toHaveBeenCalledWith(EVENT); + expect(onParentClick).not.toHaveBeenCalled(); + }); + + it("rejects executable and malformed meeting links", () => { + expect(safeMeetingUrl("javascript:alert(1)")).toBeNull(); + expect(safeMeetingUrl("not a link")).toBeNull(); + expect(safeMeetingUrl("https://zoom.us/j/123")).toBe("https://zoom.us/j/123"); + }); + + it("normalizes common meeting links that omit https", () => { + expect(safeMeetingUrl("zoom.us/j/123")).toBe("https://zoom.us/j/123"); + expect(safeMeetingUrl(" meet.google.com/abc-defg-hij ")).toBe("https://meet.google.com/abc-defg-hij"); + }); +}); diff --git a/frontend/components/calendar/EventActions.tsx b/frontend/components/calendar/EventActions.tsx new file mode 100644 index 0000000..71af714 --- /dev/null +++ b/frontend/components/calendar/EventActions.tsx @@ -0,0 +1,108 @@ +"use client"; + +import type { ReactNode, SyntheticEvent } from "react"; +import { MoreVertical, Pencil, Sparkles, Trash2 } from "lucide-react"; + +import { Button } from "@/components/ui/button"; +import { + ContextMenu, + ContextMenuContent, + ContextMenuGroup, + ContextMenuItem, + ContextMenuTrigger, +} from "@/components/ui/context-menu"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuGroup, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import type { EventData } from "./EventDialog"; + +type EventActionsProps = { + event: EventData; + children: ReactNode; + onEdit: (event: EventData) => void; + onAskAI: (event: EventData) => void; + onDelete: (event: EventData) => void; +}; + +function stopCalendarClick(event: SyntheticEvent) { + event.stopPropagation(); +} + +export function EventActions({ event, children, onEdit, onAskAI, onDelete }: EventActionsProps) { + return ( + <div className="group/event relative h-full"> + <ContextMenu> + <ContextMenuTrigger className="block h-full" onContextMenu={stopCalendarClick}> + {children} + </ContextMenuTrigger> + <ContextMenuContent onClick={stopCalendarClick} onPointerDown={stopCalendarClick}> + <ContextMenuGroup> + <ContextMenuItem onClick={(clickEvent) => { + stopCalendarClick(clickEvent); + onEdit(event); + }}> + <Pencil /> Edit + </ContextMenuItem> + <ContextMenuItem onClick={(clickEvent) => { + stopCalendarClick(clickEvent); + onAskAI(event); + }}> + <Sparkles /> Ask AI + </ContextMenuItem> + <ContextMenuItem variant="destructive" onClick={(clickEvent) => { + stopCalendarClick(clickEvent); + onDelete(event); + }}> + <Trash2 /> Delete + </ContextMenuItem> + </ContextMenuGroup> + </ContextMenuContent> + </ContextMenu> + + <DropdownMenu> + <DropdownMenuTrigger + render={ + <Button + type="button" + variant="ghost" + size="icon" + aria-label="Event actions" + onClick={stopCalendarClick} + onMouseDown={stopCalendarClick} + onPointerDown={stopCalendarClick} + className="absolute right-1 top-1 size-11 bg-background/80 opacity-100 shadow-sm md:size-8 md:opacity-0 md:group-hover/event:opacity-100 md:group-focus-within/event:opacity-100" + /> + } + > + <MoreVertical /> + </DropdownMenuTrigger> + <DropdownMenuContent align="end" onClick={stopCalendarClick} onPointerDown={stopCalendarClick}> + <DropdownMenuGroup> + <DropdownMenuItem onClick={(clickEvent) => { + stopCalendarClick(clickEvent); + onEdit(event); + }}> + <Pencil /> Edit + </DropdownMenuItem> + <DropdownMenuItem onClick={(clickEvent) => { + stopCalendarClick(clickEvent); + onAskAI(event); + }}> + <Sparkles /> Ask AI + </DropdownMenuItem> + <DropdownMenuItem variant="destructive" onClick={(clickEvent) => { + stopCalendarClick(clickEvent); + onDelete(event); + }}> + <Trash2 /> Delete + </DropdownMenuItem> + </DropdownMenuGroup> + </DropdownMenuContent> + </DropdownMenu> + </div> + ); +} diff --git a/frontend/components/calendar/EventDialog.test.tsx b/frontend/components/calendar/EventDialog.test.tsx new file mode 100644 index 0000000..505a5bf --- /dev/null +++ b/frontend/components/calendar/EventDialog.test.tsx @@ -0,0 +1,120 @@ +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { describe, expect, it, vi } from "vitest"; + +import { EventDialog } from "./EventDialog"; + +describe("EventDialog", () => { + it("keeps desktop dialog centering while exposing rich event fields", () => { + render( + <EventDialog + open + onOpenChange={vi.fn()} + initialData={null} + onSave={vi.fn()} + />, + ); + + const dialog = screen.getByRole("dialog"); + + expect(dialog.className).not.toContain("sm:static"); + expect(dialog.className).toContain("sm:-translate-x-1/2"); + expect(screen.getByLabelText("Deskripsi")).toBeVisible(); + expect(screen.getByLabelText("Meeting link")).toBeVisible(); + expect(screen.getByLabelText("Priority")).toBeVisible(); + expect(screen.getByLabelText("Reminder")).toBeVisible(); + expect(screen.getByLabelText("Tags")).toBeVisible(); + }); + + it("does not save blank-looking titles", async () => { + const user = userEvent.setup(); + const onSave = vi.fn(); + render( + <EventDialog + open + onOpenChange={vi.fn()} + initialData={null} + onSave={onSave} + />, + ); + + await user.type(screen.getByLabelText("Judul Event"), " "); + await user.click(screen.getByRole("button", { name: "Simpan Event" })); + + expect(onSave).not.toHaveBeenCalled(); + }); + + it("disables save until the event title is present", async () => { + const user = userEvent.setup(); + render( + <EventDialog + open + onOpenChange={vi.fn()} + initialData={null} + onSave={vi.fn()} + />, + ); + + const titleInput = screen.getByLabelText("Judul Event"); + const saveButton = screen.getByRole("button", { name: "Simpan Event" }); + + expect(saveButton).toBeDisabled(); + + await user.type(titleInput, "Planning"); + + expect(saveButton).toBeEnabled(); + + await user.clear(titleInput); + + expect(saveButton).toBeDisabled(); + }); + + it("calculates duration correctly for events that end after midnight", async () => { + const user = userEvent.setup(); + const onSave = vi.fn(); + render( + <EventDialog + open + onOpenChange={vi.fn()} + initialData={null} + onSave={onSave} + />, + ); + + await user.type(screen.getByLabelText("Judul Event"), "Late deployment"); + await user.clear(screen.getByLabelText("Mulai")); + await user.type(screen.getByLabelText("Mulai"), "23:00"); + await user.clear(screen.getByLabelText("Selesai")); + await user.type(screen.getByLabelText("Selesai"), "00:30"); + await user.click(screen.getByRole("button", { name: "Simpan Event" })); + + expect(onSave).toHaveBeenCalledWith(expect.objectContaining({ + start_time: "23:00:00", + duration_minutes: 90, + })); + }); + + it("keeps the existing duration when only the start time changes", async () => { + const user = userEvent.setup(); + const onSave = vi.fn(); + render( + <EventDialog + open + onOpenChange={vi.fn()} + initialData={null} + onSave={onSave} + />, + ); + + await user.type(screen.getByLabelText("Judul Event"), "Moved planning"); + await user.clear(screen.getByLabelText("Mulai")); + await user.type(screen.getByLabelText("Mulai"), "11:00"); + await user.click(screen.getByRole("button", { name: "Simpan Event" })); + + expect(onSave).toHaveBeenCalledWith(expect.objectContaining({ + start_time: "11:00:00", + duration_minutes: 60, + })); + expect(screen.getByLabelText("Selesai")).toHaveValue("12:00"); + }); +}); diff --git a/frontend/components/calendar/EventDialog.tsx b/frontend/components/calendar/EventDialog.tsx index 2c26060..c294772 100644 --- a/frontend/components/calendar/EventDialog.tsx +++ b/frontend/components/calendar/EventDialog.tsx @@ -12,7 +12,8 @@ import { import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; -import { AlertTriangle, Clock, CalendarIcon, Users, Sparkles, Bookmark, Tag } from "lucide-react"; +import { Textarea } from "@/components/ui/textarea"; +import { AlertTriangle, Bell, Clock, CalendarIcon, Flag, Link, Users, Sparkles, Bookmark, Tag } from "lucide-react"; import { motion, AnimatePresence } from "framer-motion"; import { CATEGORY_OPTIONS, getCategoryConfig } from "@/lib/categories"; import { getTemplates, saveTemplate, applyTemplate, type EventTemplate } from "@/lib/templates"; @@ -32,6 +33,11 @@ export type EventData = { participants: string; recurrence_rule?: string | null; category?: string | null; + description: string; + location_url?: string | null; + priority: "low" | "normal" | "important"; + tags: string[]; + reminder_minutes?: number | null; }; interface EventDialogProps { @@ -53,6 +59,11 @@ function defaultEventData(initialData: Partial<EventData> | null): Partial<Event duration_minutes: 60, participants: "", category: null, + description: "", + location_url: null, + priority: "normal", + tags: [], + reminder_minutes: null, }; } @@ -82,6 +93,7 @@ export function EventDialog({ const [formData, setFormData] = useState<Partial<EventData>>(initialFormData); const [endTime, setEndTime] = useState(() => calculateEndTime(initialFormData)); + const canSave = Boolean(formData.title?.trim() && formData.date && formData.start_time); const calculateDuration = (start: string, end: string) => { const [h1, m1] = start.split(":").map(Number); @@ -91,11 +103,14 @@ export function EventDialog({ const startMinutes = h1 * 60 + m1; const endMinutes = h2 * 60 + m2; const difference = endMinutes - startMinutes; - return difference > 0 ? difference : 60; + if (difference > 0) return difference; + if (difference < 0) return difference + 24 * 60; + return 60; }; const handleSave = () => { - if (!formData.title || !formData.date || !formData.start_time) return; + const title = formData.title?.trim(); + if (!title || !formData.date || !formData.start_time) return; // Convert HH:MM to HH:MM:SS for start_time if needed let finalStart = formData.start_time; @@ -105,13 +120,18 @@ export function EventDialog({ onSave({ id: formData.id, - title: formData.title, + title, date: formData.date, start_time: finalStart, duration_minutes: formData.duration_minutes || 60, participants: formData.participants || "", recurrence_rule: formData.recurrence_rule || null, category: formData.category || null, + description: formData.description || "", + location_url: formData.location_url || null, + priority: formData.priority || "normal", + tags: formData.tags || [], + reminder_minutes: formData.reminder_minutes ?? null, }); }; @@ -122,10 +142,11 @@ export function EventDialog({ }; const handleSaveAsTemplate = () => { - if (!formData.title) return; + const title = formData.title?.trim(); + if (!title) return; saveTemplate({ - name: formData.title, - title: formData.title, + name: title, + title, duration_minutes: formData.duration_minutes || 60, start_time: formData.start_time || "09:00:00", category: formData.category || null, @@ -156,7 +177,7 @@ export function EventDialog({ return ( <Dialog open={open} onOpenChange={onOpenChange}> - <DialogContent className="sm:max-w-[460px] glass border border-zinc-200/50 dark:border-white/10 bg-white/90 dark:bg-zinc-950/90 shadow-2xl backdrop-blur-2xl rounded-t-3xl sm:rounded-2xl overflow-hidden p-0 gap-0 fixed sm:static bottom-0 left-0 right-0 w-full sm:w-auto max-h-[90vh] sm:max-h-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-bottom sm:data-[state=closed]:slide-out-to-bottom-0 data-[state=open]:slide-in-from-bottom sm:data-[state=open]:slide-in-from-bottom-0"> + <DialogContent className="glass fixed top-auto bottom-0 left-0 right-0 z-50 w-full translate-x-0 translate-y-0 border border-zinc-200/50 bg-white/90 p-0 gap-0 shadow-2xl backdrop-blur-2xl rounded-t-3xl overflow-hidden max-h-[calc(100dvh-1rem)] dark:border-white/10 dark:bg-zinc-950/90 sm:top-1/2 sm:bottom-auto sm:left-1/2 sm:right-auto sm:w-full sm:max-w-[560px] sm:-translate-x-1/2 sm:-translate-y-1/2 sm:rounded-2xl sm:max-h-[90dvh] data-[state=closed]:slide-out-to-bottom sm:data-[state=closed]:slide-out-to-bottom-0 data-[state=open]:slide-in-from-bottom sm:data-[state=open]:slide-in-from-bottom-0"> <DialogHeader className="px-6 py-5 border-b border-zinc-100 dark:border-white/5 bg-zinc-50/50 dark:bg-black/20"> <DialogTitle className="text-xl font-semibold flex items-center gap-2"> <div className="w-8 h-8 rounded-full bg-primary/10 flex items-center justify-center"> @@ -171,7 +192,7 @@ export function EventDialog({ <div className="w-10 h-1.5 bg-zinc-300 dark:bg-zinc-700 rounded-full" /> </div> - <div className="px-6 py-5 overflow-y-auto max-h-[70vh]"> + <div className="px-6 py-5 overflow-y-auto max-h-[70dvh] overscroll-contain"> {/* Template Selector */} {!formData.id && ( <div className="mb-4"> @@ -261,6 +282,30 @@ export function EventDialog({ className="bg-zinc-50 dark:bg-black/20 border-zinc-200 dark:border-white/10 focus-visible:ring-primary shadow-sm" /> </div> + + <div className="grid gap-2"> + <Label htmlFor="description">Deskripsi</Label> + <Textarea + id="description" + value={formData.description || ""} + onChange={(e) => setFormData({ ...formData, description: e.target.value })} + placeholder="Agenda, context, or preparation notes" + rows={3} + /> + </div> + + <div className="grid gap-2"> + <Label htmlFor="locationUrl" className="flex items-center gap-1.5"> + <Link className="size-4" /> Meeting link + </Label> + <Input + id="locationUrl" + type="url" + value={formData.location_url || ""} + onChange={(e) => setFormData({ ...formData, location_url: e.target.value || null })} + placeholder="https://zoom.us/j/..." + /> + </div> <div className="grid gap-2"> <Label htmlFor="date" className="text-zinc-600 dark:text-zinc-400">Tanggal</Label> @@ -283,11 +328,16 @@ export function EventDialog({ value={(formData.start_time || "").substring(0, 5)} onChange={(e) => { const newStart = e.target.value; + const duration = formData.duration_minutes || 60; setFormData({ ...formData, start_time: newStart, - duration_minutes: calculateDuration(newStart, endTime) + duration_minutes: duration, }); + setEndTime(calculateEndTime({ + start_time: newStart, + duration_minutes: duration, + })); }} className="bg-zinc-50 dark:bg-black/20 border-zinc-200 dark:border-white/10 focus-visible:ring-primary shadow-sm" /> @@ -321,7 +371,7 @@ export function EventDialog({ id="category" value={formData.category || ""} onChange={(e) => setFormData({ ...formData, category: e.target.value || null })} - className="h-10 w-full rounded-md border border-zinc-200 dark:border-white/10 bg-zinc-50 dark:bg-black/20 px-3 py-2 text-sm shadow-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary" + className="min-h-11 w-full rounded-md border border-zinc-200 dark:border-white/10 bg-zinc-50 dark:bg-black/20 px-3 py-2 text-sm shadow-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary sm:min-h-10" > <option value="">— No category —</option> {CATEGORY_OPTIONS.map((cat) => ( @@ -332,6 +382,64 @@ export function EventDialog({ </select> </div> + <div className="grid grid-cols-1 gap-4 sm:grid-cols-2"> + <div className="grid gap-2"> + <Label htmlFor="priority" className="flex items-center gap-1.5"> + <Flag className="size-4" /> Priority + </Label> + <select + id="priority" + value={formData.priority || "normal"} + onChange={(e) => setFormData({ + ...formData, + priority: e.target.value as EventData["priority"], + })} + className="min-h-11 rounded-md border border-input bg-background px-3 text-sm sm:min-h-10" + > + <option value="low">Low</option> + <option value="normal">Normal</option> + <option value="important">Important</option> + </select> + </div> + <div className="grid gap-2"> + <Label htmlFor="reminder" className="flex items-center gap-1.5"> + <Bell className="size-4" /> Reminder + </Label> + <select + id="reminder" + value={formData.reminder_minutes ?? ""} + onChange={(e) => setFormData({ + ...formData, + reminder_minutes: e.target.value ? Number(e.target.value) : null, + })} + className="min-h-11 rounded-md border border-input bg-background px-3 text-sm sm:min-h-10" + > + <option value="">None</option> + <option value="0">At start time</option> + <option value="5">5 minutes before</option> + <option value="15">15 minutes before</option> + <option value="30">30 minutes before</option> + <option value="60">1 hour before</option> + <option value="1440">1 day before</option> + </select> + </div> + </div> + + <div className="grid gap-2"> + <Label htmlFor="tags" className="flex items-center gap-1.5"> + <Tag className="size-4" /> Tags + </Label> + <Input + id="tags" + value={(formData.tags || []).join(", ")} + onChange={(e) => setFormData({ + ...formData, + tags: e.target.value.split(",").map((tag) => tag.trim()).filter(Boolean), + })} + placeholder="planning, client, deep-work" + /> + </div> + <div className="grid gap-2"> <Label htmlFor="participants" className="text-zinc-600 dark:text-zinc-400 flex items-center gap-1.5"> <Users className="w-3.5 h-3.5" /> Partisipan (Opsional) @@ -347,7 +455,7 @@ export function EventDialog({ </div> </div> - <DialogFooter className="m-0 rounded-b-2xl px-6 py-4 border-t border-zinc-100 dark:border-white/5 bg-zinc-50/50 dark:bg-black/20 flex sm:justify-between w-full items-center"> + <DialogFooter className="sticky bottom-0 m-0 w-full items-center rounded-b-2xl border-t border-border bg-background/95 px-4 py-4 pb-[max(1rem,env(safe-area-inset-bottom))] backdrop-blur sm:justify-between sm:px-6"> <div className="flex items-center gap-2"> {formData.id && ( <Button @@ -355,12 +463,12 @@ export function EventDialog({ variant="destructive" onClick={() => onDelete?.(formData.id as string, formData.title)} disabled={isSaving} - className="bg-red-500/10 text-red-600 hover:bg-red-500/20 hover:text-red-700 border-none shadow-none" + className="min-h-11 bg-red-500/10 text-red-600 hover:bg-red-500/20 hover:text-red-700 border-none shadow-none" > Hapus </Button> )} - {!formData.id && formData.title && ( + {!formData.id && formData.title?.trim() && ( <Button type="button" variant="ghost" @@ -379,14 +487,14 @@ export function EventDialog({ variant="outline" onClick={() => onOpenChange(false)} disabled={isSaving} - className="bg-slate-100 hover:bg-slate-200 text-slate-700 border-transparent dark:bg-zinc-800 dark:hover:bg-zinc-700 dark:text-zinc-300 shadow-sm transition-all font-medium rounded-xl px-5" + className="min-h-11 bg-slate-100 hover:bg-slate-200 text-slate-700 border-transparent dark:bg-zinc-800 dark:hover:bg-zinc-700 dark:text-zinc-300 shadow-sm transition-all font-medium rounded-xl px-5" > Batal </Button> <Button onClick={handleSave} - disabled={isSaving} - className="bg-gradient-to-r from-violet-600 to-indigo-600 hover:from-violet-700 hover:to-indigo-700 text-white shadow-md hover:shadow-lg hover:-translate-y-0.5 transition-all font-semibold rounded-xl px-6" + disabled={isSaving || !canSave} + className="min-h-11 bg-gradient-to-r from-violet-600 to-indigo-600 hover:from-violet-700 hover:to-indigo-700 text-white shadow-md hover:shadow-lg hover:-translate-y-0.5 transition-all font-semibold rounded-xl px-6" > {isSaving ? ( <motion.div animate={{ rotate: 360 }} transition={{ repeat: Infinity, duration: 1, ease: "linear" }}> diff --git a/frontend/components/calendar/EventPreview.tsx b/frontend/components/calendar/EventPreview.tsx new file mode 100644 index 0000000..8e437e9 --- /dev/null +++ b/frontend/components/calendar/EventPreview.tsx @@ -0,0 +1,76 @@ +"use client"; + +import { Bell, ExternalLink, Mail, MapPin, Tag } from "lucide-react"; + +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { gmailSearchUrl } from "@/lib/reminders"; +import type { EventData } from "./EventDialog"; + +export function safeMeetingUrl(value?: string | null): string | null { + if (!value) return null; + const normalized = value.trim(); + if (!normalized || /\s/.test(normalized)) return null; + let candidate = normalized; + if (!candidate.includes("://")) { + if (candidate.startsWith("//")) { + candidate = `https:${candidate}`; + } else { + const host = candidate.split("/", 1)[0]; + if (!host.includes(".")) return null; + candidate = `https://${candidate}`; + } + } + try { + const parsed = new URL(candidate); + if (parsed.protocol !== "http:" && parsed.protocol !== "https:") return null; + if (parsed.username || parsed.password) return null; + return parsed.toString(); + } catch { + return null; + } +} + +export function EventPreview({ event }: { event: EventData }) { + const meetingUrl = safeMeetingUrl(event.location_url); + return ( + <div className="flex min-w-0 flex-col gap-3"> + <div className="flex items-start justify-between gap-3"> + <div className="min-w-0"> + <p className="truncate font-semibold text-foreground">{event.title}</p> + <p className="text-xs text-muted-foreground"> + {event.date} · {event.start_time.slice(0, 5)} · {event.duration_minutes} min + </p> + </div> + {event.priority === "important" ? <Badge variant="destructive">Important</Badge> : null} + </div> + + {event.description ? <p className="text-sm leading-6 text-muted-foreground">{event.description}</p> : null} + + {event.tags.length ? ( + <div className="flex flex-wrap gap-1.5"> + <Tag aria-hidden="true" className="size-4 text-muted-foreground" /> + {event.tags.map((tag) => <Badge key={tag} variant="secondary">{tag}</Badge>)} + </div> + ) : null} + + <div className="flex flex-wrap gap-2"> + {meetingUrl ? ( + <Button variant="outline" size="sm" render={<a href={meetingUrl} target="_blank" rel="noreferrer" />}> + <MapPin data-icon="inline-start" /> Join meeting <ExternalLink data-icon="inline-end" /> + </Button> + ) : null} + <Button variant="outline" size="sm" render={<a href={gmailSearchUrl(event)} target="_blank" rel="noreferrer" />}> + <Mail data-icon="inline-start" /> Find in Gmail + </Button> + </div> + + {event.reminder_minutes !== null && event.reminder_minutes !== undefined ? ( + <p className="flex items-center gap-2 text-xs text-muted-foreground"> + <Bell aria-hidden="true" className="size-4" /> + Remind me {event.reminder_minutes} minutes before + </p> + ) : null} + </div> + ); +} diff --git a/frontend/components/calendar/WeeklyCalendar.test.tsx b/frontend/components/calendar/WeeklyCalendar.test.tsx new file mode 100644 index 0000000..5f330b3 --- /dev/null +++ b/frontend/components/calendar/WeeklyCalendar.test.tsx @@ -0,0 +1,159 @@ +import React from "react"; +import { render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +vi.mock("@fullcalendar/react", async () => { + const React = await import("react"); + + type MockFullCalendarProps = { + initialView?: string; + events?: unknown[]; + datesSet?: (arg: { start: Date; end: Date; view: { title: string; type: string } }) => void; + }; + + const MockFullCalendar = React.forwardRef( + function MockFullCalendar(props: MockFullCalendarProps, ref) { + const { datesSet, events, initialView } = props; + React.useImperativeHandle(ref, () => ({ + getApi: () => ({ + view: { type: initialView ?? "timeGridWeek" }, + changeView: () => undefined, + prev: () => undefined, + next: () => undefined, + today: () => undefined, + }), + })); + + React.useEffect(() => { + datesSet?.({ + start: new Date(2026, 6, 6), + end: new Date(2026, 6, 13), + view: { title: "July 2026", type: initialView ?? "timeGridWeek" }, + }); + }, [datesSet, initialView]); + + return <div data-testid="calendar">{events?.length ?? 0}</div>; + }, + ); + + return { default: MockFullCalendar }; +}); + +import { WeeklyCalendar } from "./WeeklyCalendar"; + +const noop = vi.fn(); + +describe("WeeklyCalendar", () => { + afterEach(() => { + localStorage.clear(); + vi.restoreAllMocks(); + }); + + it("falls back to default presets when stored presets are corrupted", () => { + localStorage.setItem("timeora_category_presets", "{not valid json"); + + expect(() => { + render( + <WeeklyCalendar + events={[]} + onDateClick={noop} + onEventClick={noop} + onEventDrop={noop} + onEventResize={noop} + />, + ); + }).not.toThrow(); + + expect(screen.getByRole("button", { name: "All" })).toBeVisible(); + }); + + it("does not crash when saved view storage fails", async () => { + const user = userEvent.setup(); + vi.spyOn(window, "prompt").mockReturnValue("Focus View"); + vi.spyOn(Storage.prototype, "setItem").mockImplementation(() => { + throw new DOMException("Quota exceeded", "QuotaExceededError"); + }); + + render( + <WeeklyCalendar + events={[]} + onDateClick={noop} + onEventClick={noop} + onEventDrop={noop} + onEventResize={noop} + />, + ); + + await expect(user.click(screen.getByRole("button", { name: "+ Save" }))).resolves.toBeUndefined(); + expect(screen.getByRole("button", { name: "Focus View" })).toBeVisible(); + }); + + it("reports visible range using local calendar dates", async () => { + const onDatesChange = vi.fn(); + + render( + <WeeklyCalendar + events={[]} + onDateClick={noop} + onEventClick={noop} + onEventDrop={noop} + onEventResize={noop} + onDatesChange={onDatesChange} + />, + ); + + await waitFor(() => { + expect(onDatesChange).toHaveBeenCalledWith("2026-07-06", "2026-07-13"); + }); + }); + + it("keeps events with unknown categories visible by default", () => { + render( + <WeeklyCalendar + events={[ + { + id: "evt_unknown", + title: "Imported client meeting", + start: "2026-07-06T09:00:00", + extendedProps: { category: "work" }, + }, + ]} + onDateClick={noop} + onEventClick={noop} + onEventDrop={noop} + onEventResize={noop} + />, + ); + + expect(screen.getByTestId("calendar")).toHaveTextContent("1"); + expect(screen.getByRole("button", { name: /Other1/ })).toBeVisible(); + }); + + it("normalizes legacy categories from saved presets", async () => { + const user = userEvent.setup(); + localStorage.setItem("timeora_category_presets", JSON.stringify({ Legacy: ["work"] })); + + render( + <WeeklyCalendar + events={[ + { + id: "evt_legacy", + title: "Legacy work event", + start: "2026-07-06T10:00:00", + extendedProps: { category: "work" }, + }, + ]} + onDateClick={noop} + onEventClick={noop} + onEventDrop={noop} + onEventResize={noop} + />, + ); + + await user.click(screen.getByRole("button", { name: "Legacy" })); + + expect(screen.getByTestId("calendar")).toHaveTextContent("1"); + expect(screen.getByRole("button", { name: /Other1/ })).toBeVisible(); + }); +}); diff --git a/frontend/components/calendar/WeeklyCalendar.tsx b/frontend/components/calendar/WeeklyCalendar.tsx index 926b7f3..0633f1a 100644 --- a/frontend/components/calendar/WeeklyCalendar.tsx +++ b/frontend/components/calendar/WeeklyCalendar.tsx @@ -8,6 +8,7 @@ import interactionPlugin, { type DateClickArg, type EventResizeDoneArg, } from "@fullcalendar/interaction"; +import { format } from "date-fns"; import type { EventClickArg, EventDropArg, @@ -17,6 +18,10 @@ import type { import { CATEGORY_OPTIONS, getCategoryConfig } from "@/lib/categories"; import { Plus, Filter, ChevronLeft, ChevronRight } from "lucide-react"; import { motion } from "framer-motion"; +import { HoverCard, HoverCardContent, HoverCardTrigger } from "@/components/ui/hover-card"; +import { EventActions } from "./EventActions"; +import { EventPreview } from "./EventPreview"; +import type { EventData } from "./EventDialog"; interface WeeklyCalendarProps { events: EventInput[]; @@ -27,8 +32,76 @@ interface WeeklyCalendarProps { onDatesChange?: (from: string, to: string) => void; onAddEventClick?: () => void; onEventCategoryChange?: (eventId: string, category: string) => void; + onEventEdit?: (eventId: string) => void; + onEventDelete?: (event: EventData) => void; + onEventAskAI?: (event: EventData) => void; } +const UNCATEGORIZED_CATEGORY_KEY = "uncategorized"; +const CATEGORY_KEYS = new Set(CATEGORY_OPTIONS.map((category) => category.key)); +const ALL_CATEGORY_KEYS = [...CATEGORY_OPTIONS.map((category) => category.key), UNCATEGORIZED_CATEGORY_KEY]; + +const DEFAULT_CATEGORY_PRESETS: Record<string, string[]> = { + "All": ALL_CATEGORY_KEYS, + "Work": ["meeting", "focus"], + "Focus": ["focus"], + "Life": ["personal", "health", "social"], +}; + +function normalizeCalendarCategory(category: unknown): string { + if (typeof category !== "string") return UNCATEGORIZED_CATEGORY_KEY; + const normalized = category.trim().toLowerCase(); + if (!normalized) return UNCATEGORIZED_CATEGORY_KEY; + if (normalized === UNCATEGORIZED_CATEGORY_KEY) return UNCATEGORIZED_CATEGORY_KEY; + return CATEGORY_KEYS.has(normalized) ? normalized : "other"; +} + +function normalizeSelectedCategories(categories: string[]): string[] { + return Array.from(new Set(categories.map(normalizeCalendarCategory))); +} + +function formatCalendarDate(date: Date): string { + return format(date, "yyyy-MM-dd"); +} + +function readCategoryPresets(): Record<string, string[]> { + if (typeof window === "undefined") return DEFAULT_CATEGORY_PRESETS; + + try { + const saved = localStorage.getItem("timeora_category_presets"); + if (!saved) return DEFAULT_CATEGORY_PRESETS; + + const parsed = JSON.parse(saved) as unknown; + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { + return DEFAULT_CATEGORY_PRESETS; + } + + const validEntries = Object.entries(parsed).flatMap(([name, categories]) => { + if ( + typeof name !== "string" || + !Array.isArray(categories) || + !categories.every((category) => typeof category === "string") + ) { + return []; + } + return [[name, normalizeSelectedCategories(categories)]] as Array<[string, string[]]>; + }); + + return validEntries.length ? Object.fromEntries(validEntries) : DEFAULT_CATEGORY_PRESETS; + } catch { + return DEFAULT_CATEGORY_PRESETS; + } +} + +function writeCategoryPresets(presets: Record<string, string[]>): void { + if (typeof window === "undefined") return; + try { + localStorage.setItem("timeora_category_presets", JSON.stringify(presets)); + } catch { + // Saving a filter view should not break the calendar when storage is full, + // blocked, or unavailable in private browsing contexts. + } +} export function WeeklyCalendar({ @@ -40,6 +113,9 @@ export function WeeklyCalendar({ onDatesChange, onAddEventClick, onEventCategoryChange, + onEventEdit, + onEventDelete, + onEventAskAI, }: WeeklyCalendarProps) { const calendarRef = useRef<FullCalendar>(null); const categoryDragEventIdRef = useRef<string | null>(null); @@ -49,10 +125,25 @@ export function WeeklyCalendar({ // interactionPlugin swallows the native HTML5 dataTransfer. const renderEventContent = React.useCallback((arg: EventContentArg) => { const ext = arg.event.extendedProps as Record<string, unknown>; - const cat = getCategoryConfig(ext.category as string | null | undefined); + const categoryKey = normalizeCalendarCategory(ext.category); + const cat = getCategoryConfig(categoryKey); const isDayGrid = arg.view.type === "dayGridMonth"; const eventId = arg.event.id as string; - const categoryKey = (ext.category as string) || "uncategorized"; + const eventData: EventData = { + id: eventId, + title: arg.event.title, + date: arg.event.startStr.slice(0, 10), + start_time: arg.event.start?.toTimeString().slice(0, 8) || "00:00:00", + duration_minutes: Number(ext.duration_minutes || 60), + participants: String(ext.participants || ""), + recurrence_rule: (ext.recurrence_rule as string | null) || null, + category: categoryKey === UNCATEGORIZED_CATEGORY_KEY ? null : categoryKey, + description: String(ext.description || ""), + location_url: (ext.location_url as string | null) || null, + priority: (ext.priority as EventData["priority"]) || "normal", + tags: Array.isArray(ext.tags) ? ext.tags as string[] : [], + reminder_minutes: typeof ext.reminder_minutes === "number" ? ext.reminder_minutes : null, + }; const handleDragStart = (e: React.DragEvent) => { e.dataTransfer.setData("text/plain", eventId); @@ -61,8 +152,7 @@ export function WeeklyCalendar({ categoryDragEventIdRef.current = eventId; }; - if (isDayGrid) { - return ( + const content = isDayGrid ? ( <div draggable onDragStart={handleDragStart} @@ -81,10 +171,7 @@ export function WeeklyCalendar({ {arg.event.title} </span> </div> - ); - } - - return ( + ) : ( <div draggable onDragStart={handleDragStart} @@ -106,36 +193,43 @@ export function WeeklyCalendar({ </div> </div> ); - }, []); + + return ( + <HoverCard> + <HoverCardTrigger render={<div className="h-full" />}> + <EventActions + event={eventData} + onEdit={(event) => onEventEdit?.(event.id || "")} + onAskAI={(event) => onEventAskAI?.(event)} + onDelete={(event) => onEventDelete?.(event)} + > + {content} + </EventActions> + </HoverCardTrigger> + <HoverCardContent side="right" className="w-80"> + <EventPreview event={eventData} /> + </HoverCardContent> + </HoverCard> + ); + }, [onEventAskAI, onEventDelete, onEventEdit]); const [isMobile, setIsMobile] = React.useState(false); const [currentView, setCurrentView] = React.useState("timeGridWeek"); const [calendarTitle, setCalendarTitle] = React.useState(""); - const [selectedCategories, setSelectedCategories] = React.useState<string[]>(() => [ - "meeting", "personal", "focus", "health", "social", "other", "uncategorized" - ]); + const [selectedCategories, setSelectedCategories] = React.useState<string[]>(() => ALL_CATEGORY_KEYS); // Saved views / filter presets (localStorage) - const [presets, setPresets] = React.useState<Record<string, string[]>>(() => { - if (typeof window !== 'undefined') { - const saved = localStorage.getItem('timeora_category_presets'); - if (saved) return JSON.parse(saved); - } - return { - "All": ["meeting", "personal", "focus", "health", "social", "other", "uncategorized"], - "Work": ["meeting", "focus"], - "Focus": ["focus"], - "Life": ["personal", "health", "social"], - }; - }); + const [presets, setPresets] = React.useState<Record<string, string[]>>(readCategoryPresets); const saveCurrentAsPreset = (name: string) => { - const newPresets = { ...presets, [name]: [...selectedCategories] }; + const presetName = name.trim(); + if (!presetName) return; + const newPresets = { ...presets, [presetName]: [...selectedCategories] }; setPresets(newPresets); - localStorage.setItem('timeora_category_presets', JSON.stringify(newPresets)); + writeCategoryPresets(newPresets); }; const applyPreset = (cats: string[]) => { - setSelectedCategories(cats); + setSelectedCategories(normalizeSelectedCategories(cats)); }; const handleCategoryDrop = (e: React.DragEvent, newCategory: string) => { @@ -191,7 +285,7 @@ export function WeeklyCalendar({ const filteredEvents = React.useMemo(() => { return events.filter(event => { const ext = (event.extendedProps || {}) as Record<string, unknown>; - const category = (ext.category as string) || "uncategorized"; + const category = normalizeCalendarCategory(ext.category); return selectedCategories.includes(category); }); }, [events, selectedCategories]); @@ -201,7 +295,7 @@ export function WeeklyCalendar({ const counts: Record<string, number> = {}; events.forEach(event => { const ext = (event.extendedProps || {}) as Record<string, unknown>; - const category = (ext.category as string) || "uncategorized"; + const category = normalizeCalendarCategory(ext.category); counts[category] = (counts[category] || 0) + 1; }); return counts; @@ -232,21 +326,23 @@ export function WeeklyCalendar({ <button type="button" onClick={handlePrev} - className="p-1.5 rounded-lg hover:bg-white dark:hover:bg-zinc-700 text-slate-600 dark:text-slate-300 transition-colors cursor-pointer" + className="flex min-h-11 min-w-11 items-center justify-center rounded-lg hover:bg-white dark:hover:bg-zinc-700 text-slate-600 dark:text-slate-300 transition-colors cursor-pointer sm:min-h-8 sm:min-w-8" + aria-label="Previous calendar range" > <ChevronLeft className="w-4 h-4" /> </button> <button type="button" onClick={handleToday} - className="px-2.5 py-1 text-xs font-semibold rounded-lg hover:bg-white dark:hover:bg-zinc-700 text-slate-600 dark:text-slate-300 transition-colors cursor-pointer" + className="min-h-11 px-3 text-xs font-semibold rounded-lg hover:bg-white dark:hover:bg-zinc-700 text-slate-600 dark:text-slate-300 transition-colors cursor-pointer sm:min-h-8" > Today </button> <button type="button" onClick={handleNext} - className="p-1.5 rounded-lg hover:bg-white dark:hover:bg-zinc-700 text-slate-600 dark:text-slate-300 transition-colors cursor-pointer" + className="flex min-h-11 min-w-11 items-center justify-center rounded-lg hover:bg-white dark:hover:bg-zinc-700 text-slate-600 dark:text-slate-300 transition-colors cursor-pointer sm:min-h-8 sm:min-w-8" + aria-label="Next calendar range" > <ChevronRight className="w-4 h-4" /> </button> @@ -267,7 +363,7 @@ export function WeeklyCalendar({ key={v.id} type="button" onClick={() => handleViewChange(v.id)} - className="relative px-3.5 py-1.5 text-xs font-semibold rounded-lg transition-colors cursor-pointer select-none" + className="relative min-h-11 px-3.5 text-xs font-semibold rounded-lg transition-colors cursor-pointer select-none sm:min-h-8" style={{ color: isActive ? "var(--foreground)" : "var(--muted-foreground)" }} > {isActive && ( @@ -325,7 +421,7 @@ export function WeeklyCalendar({ onClick={() => toggleCategory(cat.key)} onDragOver={handleDragOver} onDrop={(e) => handleCategoryDrop(e, cat.key)} - className={`flex items-center gap-1.5 px-3 py-1.5 rounded-full border transition-all cursor-pointer font-medium hover:scale-[1.03] ${ + className={`flex min-h-11 items-center gap-1.5 px-3 py-1.5 rounded-full border transition-all cursor-pointer font-medium hover:scale-[1.03] sm:min-h-8 ${ isActive ? `${cat.bg} ${cat.text} ${cat.border}` : "bg-slate-50 dark:bg-zinc-900 border-slate-200 dark:border-zinc-800 text-slate-400 dark:text-zinc-500 opacity-60 hover:opacity-100" @@ -342,34 +438,34 @@ export function WeeklyCalendar({ <button type="button" - onClick={() => toggleCategory("uncategorized")} + onClick={() => toggleCategory(UNCATEGORIZED_CATEGORY_KEY)} onDragOver={handleDragOver} - onDrop={(e) => handleCategoryDrop(e, "uncategorized")} - className={`flex items-center gap-1.5 px-3 py-1.5 rounded-full border transition-all cursor-pointer font-medium hover:scale-[1.03] ${ - selectedCategories.includes("uncategorized") + onDrop={(e) => handleCategoryDrop(e, UNCATEGORIZED_CATEGORY_KEY)} + className={`flex min-h-11 items-center gap-1.5 px-3 py-1.5 rounded-full border transition-all cursor-pointer font-medium hover:scale-[1.03] sm:min-h-8 ${ + selectedCategories.includes(UNCATEGORIZED_CATEGORY_KEY) ? "bg-indigo-100 dark:bg-indigo-900/30 text-indigo-700 dark:text-indigo-300 border-indigo-200 dark:border-indigo-800" : "bg-slate-50 dark:bg-zinc-900 border-slate-200 dark:border-zinc-800 text-slate-400 dark:text-zinc-500 opacity-60 hover:opacity-100" }`} > - <span className={`w-1.5 h-1.5 rounded-full ${selectedCategories.includes("uncategorized") ? "bg-indigo-500" : "bg-slate-300 dark:bg-zinc-700"}`} /> + <span className={`w-1.5 h-1.5 rounded-full ${selectedCategories.includes(UNCATEGORIZED_CATEGORY_KEY) ? "bg-indigo-500" : "bg-slate-300 dark:bg-zinc-700"}`} /> <span>📅 Uncategorized</span> <span className="ml-0.5 px-1.5 py-0.2 bg-black/5 dark:bg-white/10 rounded-full text-[9px] font-bold"> - {categoryCounts["uncategorized"] || 0} + {categoryCounts[UNCATEGORIZED_CATEGORY_KEY] || 0} </span> </button> <button type="button" onClick={() => { - if (selectedCategories.length === 7) { + if (selectedCategories.length === ALL_CATEGORY_KEYS.length) { setSelectedCategories([]); } else { - setSelectedCategories(["meeting", "personal", "focus", "health", "social", "other", "uncategorized"]); + setSelectedCategories(ALL_CATEGORY_KEYS); } }} - className="text-[11px] text-violet-600 dark:text-violet-400 hover:text-violet-800 dark:hover:text-violet-300 transition-colors ml-2 font-semibold cursor-pointer" + className="min-h-11 text-[11px] text-violet-600 dark:text-violet-400 hover:text-violet-800 dark:hover:text-violet-300 transition-colors ml-2 font-semibold cursor-pointer sm:min-h-8" > - {selectedCategories.length === 7 ? "Clear All" : "Select All"} + {selectedCategories.length === ALL_CATEGORY_KEYS.length ? "Clear All" : "Select All"} </button> </div> @@ -377,7 +473,7 @@ export function WeeklyCalendar({ <button type="button" onClick={onAddEventClick} - className="flex items-center gap-2 px-4 py-2 bg-gradient-to-r from-violet-600 to-indigo-600 hover:from-violet-700 hover:to-indigo-700 text-white rounded-xl text-xs font-semibold shadow-md hover:shadow-lg hover:-translate-y-0.5 transition-all cursor-pointer shrink-0 xl:ml-auto" + className="flex min-h-11 items-center gap-2 px-4 py-2 bg-gradient-to-r from-violet-600 to-indigo-600 hover:from-violet-700 hover:to-indigo-700 text-white rounded-xl text-xs font-semibold shadow-md hover:shadow-lg hover:-translate-y-0.5 transition-all cursor-pointer shrink-0 xl:ml-auto" > <Plus className="w-4 h-4" /> Tambah Event @@ -386,7 +482,7 @@ export function WeeklyCalendar({ </div> {/* Calendar Area - Robust height (no brittle 100vh calc) */} - <div className="bg-transparent rounded-lg w-full flex flex-col min-h-[520px] h-[620px] lg:h-[min(680px,calc(100vh-240px))]"> + <div className="bg-transparent rounded-lg w-full flex flex-col min-h-[520px] h-[620px] max-w-full overflow-x-auto lg:h-[min(680px,calc(100dvh-240px))]"> <FullCalendar ref={calendarRef} plugins={[timeGridPlugin, dayGridPlugin, interactionPlugin]} @@ -408,8 +504,8 @@ export function WeeklyCalendar({ datesSet={(arg) => { setCalendarTitle(arg.view.title); if (onDatesChange) { - const from = arg.start.toISOString().slice(0, 10); - const to = arg.end.toISOString().slice(0, 10); + const from = formatCalendarDate(arg.start); + const to = formatCalendarDate(arg.end); onDatesChange(from, to); } }} @@ -423,7 +519,8 @@ export function WeeklyCalendar({ eventBorderColor="#4f46e5" eventDidMount={(info) => { const ext = info.event.extendedProps as Record<string, unknown>; - const cat = getCategoryConfig(ext.category as string | null | undefined); + const categoryKey = normalizeCalendarCategory(ext.category); + const cat = getCategoryConfig(categoryKey); info.el.style.backgroundColor = cat.calendarBg; info.el.style.borderColor = cat.calendarBorder; info.el.style.borderLeftColor = cat.calendarBorder; @@ -431,7 +528,7 @@ export function WeeklyCalendar({ info.el.setAttribute("data-timeora-event-id", info.event.id); info.el.setAttribute( "data-timeora-category", - (ext.category as string) || "uncategorized" + categoryKey ); }} titleFormat={ @@ -444,5 +541,3 @@ export function WeeklyCalendar({ </div> ); } - - diff --git a/frontend/components/ui/badge.tsx b/frontend/components/ui/badge.tsx new file mode 100644 index 0000000..b20959d --- /dev/null +++ b/frontend/components/ui/badge.tsx @@ -0,0 +1,52 @@ +import { mergeProps } from "@base-ui/react/merge-props" +import { useRender } from "@base-ui/react/use-render" +import { cva, type VariantProps } from "class-variance-authority" + +import { cn } from "@/lib/utils" + +const badgeVariants = cva( + "group/badge inline-flex h-5 w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded-4xl border border-transparent px-2 py-0.5 text-xs font-medium whitespace-nowrap transition-all focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&>svg]:pointer-events-none [&>svg]:size-3!", + { + variants: { + variant: { + default: "bg-primary text-primary-foreground [a]:hover:bg-primary/80", + secondary: + "bg-secondary text-secondary-foreground [a]:hover:bg-secondary/80", + destructive: + "bg-destructive/10 text-destructive focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:focus-visible:ring-destructive/40 [a]:hover:bg-destructive/20", + outline: + "border-border text-foreground [a]:hover:bg-muted [a]:hover:text-muted-foreground", + ghost: + "hover:bg-muted hover:text-muted-foreground dark:hover:bg-muted/50", + link: "text-primary underline-offset-4 hover:underline", + }, + }, + defaultVariants: { + variant: "default", + }, + } +) + +function Badge({ + className, + variant = "default", + render, + ...props +}: useRender.ComponentProps<"span"> & VariantProps<typeof badgeVariants>) { + return useRender({ + defaultTagName: "span", + props: mergeProps<"span">( + { + className: cn(badgeVariants({ variant }), className), + }, + props + ), + render, + state: { + slot: "badge", + variant, + }, + }) +} + +export { Badge, badgeVariants } diff --git a/frontend/components/ui/context-menu.tsx b/frontend/components/ui/context-menu.tsx new file mode 100644 index 0000000..9c0eb98 --- /dev/null +++ b/frontend/components/ui/context-menu.tsx @@ -0,0 +1,271 @@ +"use client" + +import * as React from "react" +import { ContextMenu as ContextMenuPrimitive } from "@base-ui/react/context-menu" + +import { cn } from "@/lib/utils" +import { ChevronRightIcon, CheckIcon } from "lucide-react" + +function ContextMenu({ ...props }: ContextMenuPrimitive.Root.Props) { + return <ContextMenuPrimitive.Root data-slot="context-menu" {...props} /> +} + +function ContextMenuPortal({ ...props }: ContextMenuPrimitive.Portal.Props) { + return ( + <ContextMenuPrimitive.Portal data-slot="context-menu-portal" {...props} /> + ) +} + +function ContextMenuTrigger({ + className, + ...props +}: ContextMenuPrimitive.Trigger.Props) { + return ( + <ContextMenuPrimitive.Trigger + data-slot="context-menu-trigger" + className={cn("select-none", className)} + {...props} + /> + ) +} + +function ContextMenuContent({ + className, + align = "start", + alignOffset = 4, + side = "right", + sideOffset = 0, + ...props +}: ContextMenuPrimitive.Popup.Props & + Pick< + ContextMenuPrimitive.Positioner.Props, + "align" | "alignOffset" | "side" | "sideOffset" + >) { + return ( + <ContextMenuPrimitive.Portal> + <ContextMenuPrimitive.Positioner + className="isolate z-50 outline-none" + align={align} + alignOffset={alignOffset} + side={side} + sideOffset={sideOffset} + > + <ContextMenuPrimitive.Popup + data-slot="context-menu-content" + className={cn("z-50 max-h-(--available-height) min-w-36 origin-(--transform-origin) overflow-x-hidden overflow-y-auto rounded-lg bg-popover p-1 text-popover-foreground shadow-md ring-1 ring-foreground/10 duration-100 outline-none data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95", className )} + {...props} + /> + </ContextMenuPrimitive.Positioner> + </ContextMenuPrimitive.Portal> + ) +} + +function ContextMenuGroup({ ...props }: ContextMenuPrimitive.Group.Props) { + return ( + <ContextMenuPrimitive.Group data-slot="context-menu-group" {...props} /> + ) +} + +function ContextMenuLabel({ + className, + inset, + ...props +}: ContextMenuPrimitive.GroupLabel.Props & { + inset?: boolean +}) { + return ( + <ContextMenuPrimitive.GroupLabel + data-slot="context-menu-label" + data-inset={inset} + className={cn( + "px-1.5 py-1 text-xs font-medium text-muted-foreground data-inset:pl-7", + className + )} + {...props} + /> + ) +} + +function ContextMenuItem({ + className, + inset, + variant = "default", + ...props +}: ContextMenuPrimitive.Item.Props & { + inset?: boolean + variant?: "default" | "destructive" +}) { + return ( + <ContextMenuPrimitive.Item + data-slot="context-menu-item" + data-inset={inset} + data-variant={variant} + className={cn( + "group/context-menu-item relative flex cursor-default items-center gap-1.5 rounded-md px-1.5 py-1 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-inset:pl-7 data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 data-[variant=destructive]:focus:text-destructive dark:data-[variant=destructive]:focus:bg-destructive/20 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 focus:*:[svg]:text-accent-foreground data-[variant=destructive]:*:[svg]:text-destructive", + className + )} + {...props} + /> + ) +} + +function ContextMenuSub({ ...props }: ContextMenuPrimitive.SubmenuRoot.Props) { + return ( + <ContextMenuPrimitive.SubmenuRoot data-slot="context-menu-sub" {...props} /> + ) +} + +function ContextMenuSubTrigger({ + className, + inset, + children, + ...props +}: ContextMenuPrimitive.SubmenuTrigger.Props & { + inset?: boolean +}) { + return ( + <ContextMenuPrimitive.SubmenuTrigger + data-slot="context-menu-sub-trigger" + data-inset={inset} + className={cn( + "flex cursor-default items-center gap-1.5 rounded-md px-1.5 py-1 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-inset:pl-7 data-open:bg-accent data-open:text-accent-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", + className + )} + {...props} + > + {children} + <ChevronRightIcon className="ml-auto" /> + </ContextMenuPrimitive.SubmenuTrigger> + ) +} + +function ContextMenuSubContent({ + ...props +}: React.ComponentProps<typeof ContextMenuContent>) { + return ( + <ContextMenuContent + data-slot="context-menu-sub-content" + className="shadow-lg" + side="right" + {...props} + /> + ) +} + +function ContextMenuCheckboxItem({ + className, + children, + checked, + inset, + ...props +}: ContextMenuPrimitive.CheckboxItem.Props & { + inset?: boolean +}) { + return ( + <ContextMenuPrimitive.CheckboxItem + data-slot="context-menu-checkbox-item" + data-inset={inset} + className={cn( + "relative flex cursor-default items-center gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-inset:pl-7 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", + className + )} + checked={checked} + {...props} + > + <span className="pointer-events-none absolute right-2"> + <ContextMenuPrimitive.CheckboxItemIndicator> + <CheckIcon + /> + </ContextMenuPrimitive.CheckboxItemIndicator> + </span> + {children} + </ContextMenuPrimitive.CheckboxItem> + ) +} + +function ContextMenuRadioGroup({ + ...props +}: ContextMenuPrimitive.RadioGroup.Props) { + return ( + <ContextMenuPrimitive.RadioGroup + data-slot="context-menu-radio-group" + {...props} + /> + ) +} + +function ContextMenuRadioItem({ + className, + children, + inset, + ...props +}: ContextMenuPrimitive.RadioItem.Props & { + inset?: boolean +}) { + return ( + <ContextMenuPrimitive.RadioItem + data-slot="context-menu-radio-item" + data-inset={inset} + className={cn( + "relative flex cursor-default items-center gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-inset:pl-7 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", + className + )} + {...props} + > + <span className="pointer-events-none absolute right-2"> + <ContextMenuPrimitive.RadioItemIndicator> + <CheckIcon + /> + </ContextMenuPrimitive.RadioItemIndicator> + </span> + {children} + </ContextMenuPrimitive.RadioItem> + ) +} + +function ContextMenuSeparator({ + className, + ...props +}: ContextMenuPrimitive.Separator.Props) { + return ( + <ContextMenuPrimitive.Separator + data-slot="context-menu-separator" + className={cn("-mx-1 my-1 h-px bg-border", className)} + {...props} + /> + ) +} + +function ContextMenuShortcut({ + className, + ...props +}: React.ComponentProps<"span">) { + return ( + <span + data-slot="context-menu-shortcut" + className={cn( + "ml-auto text-xs tracking-widest text-muted-foreground group-focus/context-menu-item:text-accent-foreground", + className + )} + {...props} + /> + ) +} + +export { + ContextMenu, + ContextMenuTrigger, + ContextMenuContent, + ContextMenuItem, + ContextMenuCheckboxItem, + ContextMenuRadioItem, + ContextMenuLabel, + ContextMenuSeparator, + ContextMenuShortcut, + ContextMenuGroup, + ContextMenuPortal, + ContextMenuSub, + ContextMenuSubContent, + ContextMenuSubTrigger, + ContextMenuRadioGroup, +} diff --git a/frontend/components/ui/drawer.tsx b/frontend/components/ui/drawer.tsx new file mode 100644 index 0000000..bde74ac --- /dev/null +++ b/frontend/components/ui/drawer.tsx @@ -0,0 +1,228 @@ +"use client" + +import * as React from "react" +import { Drawer as DrawerPrimitive } from "@base-ui/react/drawer" + +import { cn } from "@/lib/utils" + +type DrawerContextProps = { + hasSnapPoints: boolean + modal: DrawerPrimitive.Root.Props["modal"] + showSwipeHandle: boolean + swipeDirection: NonNullable<DrawerPrimitive.Root.Props["swipeDirection"]> +} + +const DrawerContext = React.createContext<DrawerContextProps | null>(null) + +function useDrawer() { + const context = React.useContext(DrawerContext) + + if (!context) { + throw new Error("useDrawer must be used within a Drawer.") + } + + return context +} + +function Drawer({ + modal = true, + showSwipeHandle = false, + snapPoints, + swipeDirection = "down", + ...props +}: DrawerPrimitive.Root.Props & { + showSwipeHandle?: boolean +}) { + const hasSnapPoints = snapPoints != null && snapPoints.length > 0 + const contextValue = React.useMemo( + () => ({ hasSnapPoints, modal, showSwipeHandle, swipeDirection }), + [hasSnapPoints, modal, showSwipeHandle, swipeDirection] + ) + + return ( + <DrawerContext.Provider value={contextValue}> + <DrawerPrimitive.Root + data-slot="drawer" + modal={modal} + snapPoints={snapPoints} + swipeDirection={swipeDirection} + {...props} + /> + </DrawerContext.Provider> + ) +} + +function DrawerTrigger({ ...props }: DrawerPrimitive.Trigger.Props) { + return <DrawerPrimitive.Trigger data-slot="drawer-trigger" {...props} /> +} + +function DrawerPortal({ ...props }: DrawerPrimitive.Portal.Props) { + return <DrawerPrimitive.Portal data-slot="drawer-portal" {...props} /> +} + +function DrawerClose({ ...props }: DrawerPrimitive.Close.Props) { + return <DrawerPrimitive.Close data-slot="drawer-close" {...props} /> +} + +function DrawerOverlay({ + className, + ...props +}: DrawerPrimitive.Backdrop.Props) { + return ( + <DrawerPrimitive.Backdrop + data-slot="drawer-overlay" + className={cn( + "fixed inset-0 z-50 min-h-dvh bg-black/10 opacity-[max(var(--drawer-overlay-min-opacity,0),calc(1-var(--drawer-swipe-progress)))] transition-opacity duration-450 ease-[cubic-bezier(0.32,0.72,0,1)] select-none data-ending-style:pointer-events-none data-ending-style:opacity-0 data-ending-style:duration-[calc(var(--drawer-swipe-strength)*400ms)] data-snap-points:[--drawer-overlay-min-opacity:0.5] data-starting-style:opacity-0 data-swiping:duration-0 supports-backdrop-filter:backdrop-blur-xs supports-[-webkit-touch-callout:none]:absolute", + className + )} + {...props} + /> + ) +} + +function DrawerSwipeHandle({ + className, + ...props +}: React.ComponentProps<"div">) { + return ( + <div + data-slot="drawer-swipe-handle" + aria-hidden="true" + className={cn( + "relative z-10 flex shrink-0 cursor-grab transition-opacity duration-200 group-data-nested-drawer-open/drawer-popup:opacity-0 group-data-nested-drawer-swiping/drawer-popup:opacity-100 group-data-[swipe-axis=x]/drawer-popup:h-full group-data-[swipe-axis=x]/drawer-popup:w-3 group-data-[swipe-axis=x]/drawer-popup:items-center group-data-[swipe-axis=y]/drawer-popup:h-3 group-data-[swipe-axis=y]/drawer-popup:w-full group-data-[swipe-axis=y]/drawer-popup:justify-center group-data-[swipe-direction=down]/drawer-popup:items-end group-data-[swipe-direction=left]/drawer-popup:order-last group-data-[swipe-direction=left]/drawer-popup:justify-start group-data-[swipe-direction=right]/drawer-popup:justify-end group-data-[swipe-direction=up]/drawer-popup:order-last group-data-[swipe-direction=up]/drawer-popup:items-start after:block after:shrink-0 after:rounded-full after:bg-muted group-data-[swipe-axis=x]/drawer-popup:after:h-24 group-data-[swipe-axis=x]/drawer-popup:after:w-1 group-data-[swipe-axis=y]/drawer-popup:after:h-1 group-data-[swipe-axis=y]/drawer-popup:after:w-24 active:cursor-grabbing", + className + )} + {...props} + /> + ) +} + +function DrawerContent({ + className, + children, + ...props +}: DrawerPrimitive.Popup.Props) { + const { hasSnapPoints, modal, showSwipeHandle, swipeDirection } = useDrawer() + const swipeAxis = + swipeDirection === "down" || swipeDirection === "up" ? "y" : "x" + + return ( + <DrawerPortal data-slot="drawer-portal"> + {modal === true && ( + <DrawerOverlay data-snap-points={hasSnapPoints ? "" : undefined} /> + )} + <DrawerPrimitive.Viewport + data-slot="drawer-viewport" + data-modal={modal} + className="pointer-events-none fixed inset-0 z-50 select-none data-[modal=true]:pointer-events-auto" + > + <DrawerPrimitive.Popup + data-slot="drawer-popup" + data-swipe-axis={swipeAxis} + data-snap-points={hasSnapPoints ? "" : undefined} + className={cn( + // Base. + "group/drawer-popup pointer-events-auto fixed z-50 m-(--drawer-inset,0px) flex h-(--drawer-content-height) max-h-(--drawer-content-max-height,none) min-h-0 w-(--drawer-content-width,auto) transform-[translate3d(var(--translate-x,0px),var(--translate-y,0px),0)_scale(var(--stack-scale))] flex-col bg-popover text-sm text-popover-foreground transition-[transform,height,opacity,filter] duration-450 ease-[cubic-bezier(0.22,1,0.36,1)] will-change-transform outline-none select-none [interpolate-size:allow-keywords] data-[swipe-direction=down]:rounded-t-xl data-[swipe-direction=down]:border-t data-[swipe-direction=left]:rounded-r-xl data-[swipe-direction=left]:border-r data-[swipe-direction=right]:rounded-l-xl data-[swipe-direction=right]:border-l data-[swipe-direction=up]:rounded-b-xl data-[swipe-direction=up]:border-b", + // Nested. + "data-nested-drawer-open:overflow-hidden data-nested-drawer-open:brightness-95", + // Bleed. + "after:pointer-events-none after:absolute after:bg-(--drawer-bleed-background,var(--color-popover)) data-[swipe-axis=x]:after:inset-y-0 data-[swipe-axis=x]:after:w-(--bleed) data-[swipe-axis=y]:after:inset-x-0 data-[swipe-axis=y]:after:h-(--bleed) data-[swipe-direction=down]:after:top-full data-[swipe-direction=left]:after:right-full data-[swipe-direction=right]:after:left-full data-[swipe-direction=up]:after:bottom-full", + // Sizing. + "[--drawer-content-height:var(--drawer-height,auto)] data-[swipe-axis=x]:[--drawer-content-width:75%] data-[swipe-axis=y]:[--drawer-content-max-height:calc(100dvh-6rem)] data-[swipe-axis=y]:data-snap-points:[--drawer-content-height:100dvh] data-[swipe-axis=x]:sm:[--drawer-content-width:24rem]", + // Stack. + "[--bleed:3rem] [--peek:1rem] [--stack-height:var(--drawer-frontmost-height,var(--drawer-height,0px))] [--stack-peek-offset:max(0px,calc((var(--nested-drawers)-var(--stack-progress))*var(--peek)))] [--stack-progress:clamp(0,var(--drawer-swipe-progress),1)] [--stack-scale-base:max(0,calc(1-(var(--nested-drawers)*var(--stack-step))))] [--stack-scale:clamp(0,calc(var(--stack-scale-base)+(var(--stack-step)*var(--stack-progress))),1)] [--stack-shrink:calc(1-var(--stack-scale))] [--stack-step:0.05]", + // Transitions. + "data-ending-style:transform-(--closed-transform) data-ending-style:opacity-[0.9999] data-ending-style:duration-[calc(var(--drawer-swipe-strength)*400ms)] data-nested-drawer-swiping:duration-0 data-ending-style:data-nested-drawer-swiping:duration-[calc(var(--drawer-swipe-strength)*400ms)] data-starting-style:transform-(--closed-transform) data-swiping:duration-0 data-ending-style:data-swiping:duration-[calc(var(--drawer-swipe-strength)*400ms)]", + // Axis: y. + "data-[swipe-axis=y]:inset-x-0 data-[swipe-axis=y]:data-nested-drawer-open:h-(--stack-height)", + // Axis: x. + "data-[swipe-axis=x]:inset-y-0 data-[swipe-axis=x]:flex-row", + // Direction: down. + "data-[swipe-direction=down]:bottom-0 data-[swipe-direction=down]:origin-bottom data-[swipe-direction=down]:[--closed-transform:translate3d(0,calc(100%+var(--drawer-inset,0px)+2px),0)] data-[swipe-direction=down]:[--translate-y:calc(var(--drawer-snap-point-offset,0px)+var(--drawer-swipe-movement-y)-var(--stack-peek-offset)-(var(--stack-shrink)*var(--stack-height)))]", + // Direction: up. + "data-[swipe-direction=up]:top-0 data-[swipe-direction=up]:origin-top data-[swipe-direction=up]:[--closed-transform:translate3d(0,calc(-100%-var(--drawer-inset,0px)-2px),0)] data-[swipe-direction=up]:[--translate-y:calc(var(--drawer-snap-point-offset,0px)+var(--drawer-swipe-movement-y)+var(--stack-peek-offset)+(var(--stack-shrink)*var(--stack-height)))]", + // Direction: left. + "data-[swipe-direction=left]:left-0 data-[swipe-direction=left]:origin-left data-[swipe-direction=left]:[--closed-transform:translate3d(calc(-100%-var(--drawer-inset,0px)-2px),0,0)] data-[swipe-direction=left]:[--translate-x:calc(var(--drawer-swipe-movement-x)+var(--stack-peek-offset)+(var(--stack-shrink)*100%))]", + // Direction: right. + "data-[swipe-direction=right]:right-0 data-[swipe-direction=right]:origin-right data-[swipe-direction=right]:[--closed-transform:translate3d(calc(100%+var(--drawer-inset,0px)+2px),0,0)] data-[swipe-direction=right]:[--translate-x:calc(var(--drawer-swipe-movement-x)-var(--stack-peek-offset)-(var(--stack-shrink)*100%))]", + className + )} + {...props} + > + {showSwipeHandle && <DrawerSwipeHandle />} + <DrawerPrimitive.Content + data-slot="drawer-content" + className={cn( + "flex min-h-0 flex-1 flex-col overflow-hidden overscroll-contain rounded-[inherit] transition-opacity duration-300 ease-[cubic-bezier(0.45,1.005,0,1.005)] select-text group-data-nested-drawer-open/drawer-popup:opacity-0 group-data-nested-drawer-swiping/drawer-popup:opacity-100 group-data-swiping/drawer-popup:select-none" + )} + > + {children} + </DrawerPrimitive.Content> + </DrawerPrimitive.Popup> + </DrawerPrimitive.Viewport> + </DrawerPortal> + ) +} + +function DrawerHeader({ className, ...props }: React.ComponentProps<"div">) { + return ( + <div + data-slot="drawer-header" + className={cn( + "flex shrink-0 flex-col gap-0.5 p-4 pb-0 group-data-[swipe-axis=y]/drawer-popup:text-center md:gap-0.5 md:text-left", + className + )} + {...props} + /> + ) +} + +function DrawerFooter({ className, ...props }: React.ComponentProps<"div">) { + return ( + <div + data-slot="drawer-footer" + className={cn("mt-auto flex shrink-0 flex-col gap-2 p-4 pt-0", className)} + {...props} + /> + ) +} + +function DrawerTitle({ className, ...props }: DrawerPrimitive.Title.Props) { + return ( + <DrawerPrimitive.Title + data-slot="drawer-title" + className={cn( + "font-heading text-base font-medium text-foreground", + className + )} + {...props} + /> + ) +} + +function DrawerDescription({ + className, + ...props +}: DrawerPrimitive.Description.Props) { + return ( + <DrawerPrimitive.Description + data-slot="drawer-description" + className={cn("text-sm text-balance text-muted-foreground", className)} + {...props} + /> + ) +} + +export { + Drawer, + DrawerPortal, + DrawerOverlay, + DrawerSwipeHandle, + DrawerTrigger, + DrawerClose, + DrawerContent, + DrawerHeader, + DrawerFooter, + DrawerTitle, + DrawerDescription, +} diff --git a/frontend/components/ui/dropdown-menu.tsx b/frontend/components/ui/dropdown-menu.tsx new file mode 100644 index 0000000..9d5ebbd --- /dev/null +++ b/frontend/components/ui/dropdown-menu.tsx @@ -0,0 +1,268 @@ +"use client" + +import * as React from "react" +import { Menu as MenuPrimitive } from "@base-ui/react/menu" + +import { cn } from "@/lib/utils" +import { ChevronRightIcon, CheckIcon } from "lucide-react" + +function DropdownMenu({ ...props }: MenuPrimitive.Root.Props) { + return <MenuPrimitive.Root data-slot="dropdown-menu" {...props} /> +} + +function DropdownMenuPortal({ ...props }: MenuPrimitive.Portal.Props) { + return <MenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} /> +} + +function DropdownMenuTrigger({ ...props }: MenuPrimitive.Trigger.Props) { + return <MenuPrimitive.Trigger data-slot="dropdown-menu-trigger" {...props} /> +} + +function DropdownMenuContent({ + align = "start", + alignOffset = 0, + side = "bottom", + sideOffset = 4, + className, + ...props +}: MenuPrimitive.Popup.Props & + Pick< + MenuPrimitive.Positioner.Props, + "align" | "alignOffset" | "side" | "sideOffset" + >) { + return ( + <MenuPrimitive.Portal> + <MenuPrimitive.Positioner + className="isolate z-50 outline-none" + align={align} + alignOffset={alignOffset} + side={side} + sideOffset={sideOffset} + > + <MenuPrimitive.Popup + data-slot="dropdown-menu-content" + className={cn("z-50 max-h-(--available-height) w-(--anchor-width) min-w-32 origin-(--transform-origin) overflow-x-hidden overflow-y-auto rounded-lg bg-popover p-1 text-popover-foreground shadow-md ring-1 ring-foreground/10 duration-100 outline-none data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:overflow-hidden data-closed:fade-out-0 data-closed:zoom-out-95", className )} + {...props} + /> + </MenuPrimitive.Positioner> + </MenuPrimitive.Portal> + ) +} + +function DropdownMenuGroup({ ...props }: MenuPrimitive.Group.Props) { + return <MenuPrimitive.Group data-slot="dropdown-menu-group" {...props} /> +} + +function DropdownMenuLabel({ + className, + inset, + ...props +}: MenuPrimitive.GroupLabel.Props & { + inset?: boolean +}) { + return ( + <MenuPrimitive.GroupLabel + data-slot="dropdown-menu-label" + data-inset={inset} + className={cn( + "px-1.5 py-1 text-xs font-medium text-muted-foreground data-inset:pl-7", + className + )} + {...props} + /> + ) +} + +function DropdownMenuItem({ + className, + inset, + variant = "default", + ...props +}: MenuPrimitive.Item.Props & { + inset?: boolean + variant?: "default" | "destructive" +}) { + return ( + <MenuPrimitive.Item + data-slot="dropdown-menu-item" + data-inset={inset} + data-variant={variant} + className={cn( + "group/dropdown-menu-item relative flex cursor-default items-center gap-1.5 rounded-md px-1.5 py-1 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-inset:pl-7 data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 data-[variant=destructive]:focus:text-destructive dark:data-[variant=destructive]:focus:bg-destructive/20 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 data-[variant=destructive]:*:[svg]:text-destructive", + className + )} + {...props} + /> + ) +} + +function DropdownMenuSub({ ...props }: MenuPrimitive.SubmenuRoot.Props) { + return <MenuPrimitive.SubmenuRoot data-slot="dropdown-menu-sub" {...props} /> +} + +function DropdownMenuSubTrigger({ + className, + inset, + children, + ...props +}: MenuPrimitive.SubmenuTrigger.Props & { + inset?: boolean +}) { + return ( + <MenuPrimitive.SubmenuTrigger + data-slot="dropdown-menu-sub-trigger" + data-inset={inset} + className={cn( + "flex cursor-default items-center gap-1.5 rounded-md px-1.5 py-1 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-inset:pl-7 data-popup-open:bg-accent data-popup-open:text-accent-foreground data-open:bg-accent data-open:text-accent-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", + className + )} + {...props} + > + {children} + <ChevronRightIcon className="ml-auto" /> + </MenuPrimitive.SubmenuTrigger> + ) +} + +function DropdownMenuSubContent({ + align = "start", + alignOffset = -3, + side = "right", + sideOffset = 0, + className, + ...props +}: React.ComponentProps<typeof DropdownMenuContent>) { + return ( + <DropdownMenuContent + data-slot="dropdown-menu-sub-content" + className={cn("w-auto min-w-[96px] rounded-lg bg-popover p-1 text-popover-foreground shadow-lg ring-1 ring-foreground/10 duration-100 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95", className )} + align={align} + alignOffset={alignOffset} + side={side} + sideOffset={sideOffset} + {...props} + /> + ) +} + +function DropdownMenuCheckboxItem({ + className, + children, + checked, + inset, + ...props +}: MenuPrimitive.CheckboxItem.Props & { + inset?: boolean +}) { + return ( + <MenuPrimitive.CheckboxItem + data-slot="dropdown-menu-checkbox-item" + data-inset={inset} + className={cn( + "relative flex cursor-default items-center gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground focus:**:text-accent-foreground data-inset:pl-7 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", + className + )} + checked={checked} + {...props} + > + <span + className="pointer-events-none absolute right-2 flex items-center justify-center" + data-slot="dropdown-menu-checkbox-item-indicator" + > + <MenuPrimitive.CheckboxItemIndicator> + <CheckIcon + /> + </MenuPrimitive.CheckboxItemIndicator> + </span> + {children} + </MenuPrimitive.CheckboxItem> + ) +} + +function DropdownMenuRadioGroup({ ...props }: MenuPrimitive.RadioGroup.Props) { + return ( + <MenuPrimitive.RadioGroup + data-slot="dropdown-menu-radio-group" + {...props} + /> + ) +} + +function DropdownMenuRadioItem({ + className, + children, + inset, + ...props +}: MenuPrimitive.RadioItem.Props & { + inset?: boolean +}) { + return ( + <MenuPrimitive.RadioItem + data-slot="dropdown-menu-radio-item" + data-inset={inset} + className={cn( + "relative flex cursor-default items-center gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground focus:**:text-accent-foreground data-inset:pl-7 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", + className + )} + {...props} + > + <span + className="pointer-events-none absolute right-2 flex items-center justify-center" + data-slot="dropdown-menu-radio-item-indicator" + > + <MenuPrimitive.RadioItemIndicator> + <CheckIcon + /> + </MenuPrimitive.RadioItemIndicator> + </span> + {children} + </MenuPrimitive.RadioItem> + ) +} + +function DropdownMenuSeparator({ + className, + ...props +}: MenuPrimitive.Separator.Props) { + return ( + <MenuPrimitive.Separator + data-slot="dropdown-menu-separator" + className={cn("-mx-1 my-1 h-px bg-border", className)} + {...props} + /> + ) +} + +function DropdownMenuShortcut({ + className, + ...props +}: React.ComponentProps<"span">) { + return ( + <span + data-slot="dropdown-menu-shortcut" + className={cn( + "ml-auto text-xs tracking-widest text-muted-foreground group-focus/dropdown-menu-item:text-accent-foreground", + className + )} + {...props} + /> + ) +} + +export { + DropdownMenu, + DropdownMenuPortal, + DropdownMenuTrigger, + DropdownMenuContent, + DropdownMenuGroup, + DropdownMenuLabel, + DropdownMenuItem, + DropdownMenuCheckboxItem, + DropdownMenuRadioGroup, + DropdownMenuRadioItem, + DropdownMenuSeparator, + DropdownMenuShortcut, + DropdownMenuSub, + DropdownMenuSubTrigger, + DropdownMenuSubContent, +} diff --git a/frontend/components/ui/hover-card.tsx b/frontend/components/ui/hover-card.tsx new file mode 100644 index 0000000..58a477c --- /dev/null +++ b/frontend/components/ui/hover-card.tsx @@ -0,0 +1,51 @@ +"use client" + +import { PreviewCard as PreviewCardPrimitive } from "@base-ui/react/preview-card" + +import { cn } from "@/lib/utils" + +function HoverCard({ ...props }: PreviewCardPrimitive.Root.Props) { + return <PreviewCardPrimitive.Root data-slot="hover-card" {...props} /> +} + +function HoverCardTrigger({ ...props }: PreviewCardPrimitive.Trigger.Props) { + return ( + <PreviewCardPrimitive.Trigger data-slot="hover-card-trigger" {...props} /> + ) +} + +function HoverCardContent({ + className, + side = "bottom", + sideOffset = 4, + align = "center", + alignOffset = 4, + ...props +}: PreviewCardPrimitive.Popup.Props & + Pick< + PreviewCardPrimitive.Positioner.Props, + "align" | "alignOffset" | "side" | "sideOffset" + >) { + return ( + <PreviewCardPrimitive.Portal data-slot="hover-card-portal"> + <PreviewCardPrimitive.Positioner + align={align} + alignOffset={alignOffset} + side={side} + sideOffset={sideOffset} + className="isolate z-50" + > + <PreviewCardPrimitive.Popup + data-slot="hover-card-content" + className={cn( + "z-50 w-64 origin-(--transform-origin) rounded-lg bg-popover p-2.5 text-sm text-popover-foreground shadow-md ring-1 ring-foreground/10 outline-hidden duration-100 data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95", + className + )} + {...props} + /> + </PreviewCardPrimitive.Positioner> + </PreviewCardPrimitive.Portal> + ) +} + +export { HoverCard, HoverCardTrigger, HoverCardContent } diff --git a/frontend/components/ui/sonner.tsx b/frontend/components/ui/sonner.tsx new file mode 100644 index 0000000..9280ee5 --- /dev/null +++ b/frontend/components/ui/sonner.tsx @@ -0,0 +1,49 @@ +"use client" + +import { useTheme } from "next-themes" +import { Toaster as Sonner, type ToasterProps } from "sonner" +import { CircleCheckIcon, InfoIcon, TriangleAlertIcon, OctagonXIcon, Loader2Icon } from "lucide-react" + +const Toaster = ({ ...props }: ToasterProps) => { + const { theme = "system" } = useTheme() + + return ( + <Sonner + theme={theme as ToasterProps["theme"]} + className="toaster group" + icons={{ + success: ( + <CircleCheckIcon className="size-4" /> + ), + info: ( + <InfoIcon className="size-4" /> + ), + warning: ( + <TriangleAlertIcon className="size-4" /> + ), + error: ( + <OctagonXIcon className="size-4" /> + ), + loading: ( + <Loader2Icon className="size-4 animate-spin" /> + ), + }} + style={ + { + "--normal-bg": "var(--popover)", + "--normal-text": "var(--popover-foreground)", + "--normal-border": "var(--border)", + "--border-radius": "var(--radius)", + } as React.CSSProperties + } + toastOptions={{ + classNames: { + toast: "cn-toast", + }, + }} + {...props} + /> + ) +} + +export { Toaster } diff --git a/frontend/components/ui/textarea.tsx b/frontend/components/ui/textarea.tsx new file mode 100644 index 0000000..04d27f7 --- /dev/null +++ b/frontend/components/ui/textarea.tsx @@ -0,0 +1,18 @@ +import * as React from "react" + +import { cn } from "@/lib/utils" + +function Textarea({ className, ...props }: React.ComponentProps<"textarea">) { + return ( + <textarea + data-slot="textarea" + className={cn( + "flex field-sizing-content min-h-16 w-full rounded-lg border border-input bg-transparent px-2.5 py-2 text-base transition-colors outline-none placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:bg-input/50 disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:disabled:bg-input/80 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40", + className + )} + {...props} + /> + ) +} + +export { Textarea } diff --git a/frontend/lib/api.test.ts b/frontend/lib/api.test.ts new file mode 100644 index 0000000..23cae8a --- /dev/null +++ b/frontend/lib/api.test.ts @@ -0,0 +1,154 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { ApiError, exportIcs, fetchApi } from "./api"; + +function jsonResponse(status: number, body: unknown): Response { + return new Response(JSON.stringify(body), { + status, + headers: { "Content-Type": "application/json" }, + }); +} + +describe("fetchApi", () => { + beforeEach(() => { + localStorage.clear(); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it("attaches an abort signal so stalled production requests can time out", async () => { + const fetchMock = vi.fn().mockResolvedValue(jsonResponse(200, { ok: true })); + vi.stubGlobal("fetch", fetchMock); + + await fetchApi("/health"); + + expect(fetchMock).toHaveBeenCalledOnce(); + expect(fetchMock.mock.calls[0][1]?.signal).toBeInstanceOf(AbortSignal); + }); + + it("honors an already-aborted caller signal before sending requests", async () => { + const controller = new AbortController(); + controller.abort("closed"); + const fetchMock = vi.fn((_url: string, init?: RequestInit) => { + if (init?.signal?.aborted) { + return Promise.reject(new DOMException("Aborted", "AbortError")); + } + return Promise.resolve(jsonResponse(200, { ok: true })); + }); + vi.stubGlobal("fetch", fetchMock); + + await expect(fetchApi("/health", { signal: controller.signal })).rejects.toMatchObject({ + status: 408, + }); + + expect(fetchMock).toHaveBeenCalledOnce(); + expect(fetchMock.mock.calls[0][1]?.signal?.aborted).toBe(true); + }); + + it("clears stale credentials when refresh is rejected", async () => { + localStorage.setItem("token", "expired-access"); + localStorage.setItem("refresh_token", "expired-refresh"); + const fetchMock = vi + .fn() + .mockResolvedValueOnce(jsonResponse(401, { detail: "expired" })) + .mockResolvedValueOnce(jsonResponse(401, { detail: "invalid refresh" })); + vi.stubGlobal("fetch", fetchMock); + + await expect(fetchApi("/events")).rejects.toBeInstanceOf(ApiError); + + expect(localStorage.getItem("token")).toBeNull(); + expect(localStorage.getItem("refresh_token")).toBeNull(); + }); + + it("attaches an abort signal to token refresh requests", async () => { + localStorage.setItem("token", "expired-access"); + localStorage.setItem("refresh_token", "expired-refresh"); + const fetchMock = vi + .fn() + .mockResolvedValueOnce(jsonResponse(401, { detail: "expired" })) + .mockResolvedValueOnce(jsonResponse(401, { detail: "invalid refresh" })); + vi.stubGlobal("fetch", fetchMock); + + await expect(fetchApi("/events")).rejects.toBeInstanceOf(ApiError); + + expect(fetchMock.mock.calls[1][1]?.signal).toBeInstanceOf(AbortSignal); + }); + + it("clears a stale access token when no refresh token is available", async () => { + localStorage.setItem("token", "expired-access"); + const fetchMock = vi.fn().mockResolvedValue(jsonResponse(401, { detail: "expired" })); + vi.stubGlobal("fetch", fetchMock); + + await expect(fetchApi("/events")).rejects.toBeInstanceOf(ApiError); + + expect(localStorage.getItem("token")).toBeNull(); + }); + + it("uses a plain-text server error instead of a generic message", async () => { + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue( + new Response("Service is warming up", { + status: 503, + headers: { "Content-Type": "text/plain" }, + }), + ), + ); + + await expect(fetchApi("/events")).rejects.toMatchObject({ + status: 503, + message: "Service is warming up", + }); + }); + + it("returns null for successful empty responses", async () => { + vi.stubGlobal("fetch", vi.fn().mockResolvedValue(new Response(null, { status: 204 }))); + + await expect(fetchApi("/events/one", { method: "DELETE" })).resolves.toBeNull(); + }); + + it("refreshes stale credentials before retrying calendar export", async () => { + localStorage.setItem("token", "expired-access"); + localStorage.setItem("refresh_token", "valid-refresh"); + const fetchMock = vi + .fn() + .mockResolvedValueOnce(jsonResponse(401, { detail: "expired" })) + .mockResolvedValueOnce(jsonResponse(200, { access_token: "fresh-access" })) + .mockResolvedValueOnce( + new Response("BEGIN:VCALENDAR", { + status: 200, + headers: { "Content-Type": "text/calendar" }, + }), + ); + vi.stubGlobal("fetch", fetchMock); + + const blob = await exportIcs(); + + expect(await blob.text()).toBe("BEGIN:VCALENDAR"); + expect(localStorage.getItem("token")).toBe("fresh-access"); + expect(fetchMock).toHaveBeenCalledTimes(3); + expect(fetchMock.mock.calls[0][1]?.headers).toEqual({ + Authorization: "Bearer expired-access", + }); + expect(fetchMock.mock.calls[2][1]?.headers).toEqual({ + Authorization: "Bearer fresh-access", + }); + }); + + it("attaches an abort signal to calendar export requests", async () => { + localStorage.setItem("token", "valid-access"); + const fetchMock = vi.fn().mockResolvedValue( + new Response("BEGIN:VCALENDAR", { + status: 200, + headers: { "Content-Type": "text/calendar" }, + }), + ); + vi.stubGlobal("fetch", fetchMock); + + await exportIcs(); + + expect(fetchMock.mock.calls[0][1]?.signal).toBeInstanceOf(AbortSignal); + }); +}); diff --git a/frontend/lib/api.ts b/frontend/lib/api.ts index 8974641..313a738 100644 --- a/frontend/lib/api.ts +++ b/frontend/lib/api.ts @@ -1,4 +1,7 @@ +import { persistAuthTokens } from './session'; + const API_BASE_URL = (process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8000/api').replace(/\/+$/, ''); +const DEFAULT_TIMEOUT_MS = 15_000; type JsonRecord = Record<string, unknown>; @@ -35,24 +38,72 @@ type AuthTokenResponse = { refresh_token?: string; }; +function clearSession(): void { + localStorage.removeItem('token'); + localStorage.removeItem('refresh_token'); + window.dispatchEvent(new CustomEvent('timeora:auth-expired')); +} + +async function responseData(response: Response): Promise<unknown> { + if (response.status === 204) return null; + const contentType = response.headers.get('content-type') || ''; + if (contentType.includes('application/json')) { + return response.json().catch(() => ({})); + } + const text = await response.text().catch(() => ''); + return text ? { detail: text } : {}; +} + +function requestSignal(external?: AbortSignal | null): { + signal: AbortSignal; + dispose: () => void; +} { + const controller = new AbortController(); + const timeout = window.setTimeout(() => controller.abort(), DEFAULT_TIMEOUT_MS); + const abortFromExternal = () => controller.abort(external?.reason); + if (external?.aborted) { + controller.abort(external.reason); + } else { + external?.addEventListener('abort', abortFromExternal, { once: true }); + } + return { + signal: controller.signal, + dispose: () => { + window.clearTimeout(timeout); + external?.removeEventListener('abort', abortFromExternal); + }, + }; +} + async function refreshAccessToken(): Promise<boolean> { const refreshToken = localStorage.getItem('refresh_token'); - if (!refreshToken) return false; + if (!refreshToken) { + if (localStorage.getItem('token')) clearSession(); + return false; + } + const timeout = requestSignal(); try { const resp = await fetch(`${API_BASE_URL}/auth/refresh`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ refresh_token: refreshToken }), + signal: timeout.signal, }); - if (!resp.ok) return false; + if (!resp.ok) { + clearSession(); + return false; + } const data = await resp.json() as AuthTokenResponse; if (data.access_token) { - localStorage.setItem('token', data.access_token); - if (data.refresh_token) localStorage.setItem('refresh_token', data.refresh_token); + persistAuthTokens(data, { preserveRefreshToken: true }); return true; } + clearSession(); } catch { + clearSession(); return false; + } finally { + timeout.dispose(); } return false; } @@ -71,10 +122,26 @@ export async function fetchApi<T = unknown>( } if (token) headers.set('Authorization', `Bearer ${token}`); - const response = await fetch(`${API_BASE_URL}${endpoint}`, { - ...options, - headers, - }); + const timeout = requestSignal(options.signal); + let response: Response; + try { + response = await fetch(`${API_BASE_URL}${endpoint}`, { + ...options, + headers, + signal: timeout.signal, + }); + } catch (error: unknown) { + if (timeout.signal.aborted) { + throw new ApiError(408, { detail: 'The request took too long. Please try again.' }, 'Request timed out'); + } + throw new ApiError( + 0, + { detail: error instanceof Error ? error.message : 'Network request failed' }, + 'Network request failed', + ); + } finally { + timeout.dispose(); + } if (response.status === 401 && retry && !endpoint.includes('/auth/')) { const refreshed = await refreshAccessToken(); @@ -82,7 +149,7 @@ export async function fetchApi<T = unknown>( } if (!response.ok) { - const errorData = await response.json().catch(() => ({})); + const errorData = await responseData(response); throw new ApiError(response.status, errorData, 'An error occurred while fetching data'); } @@ -90,7 +157,7 @@ export async function fetchApi<T = unknown>( return null as T; } - return response.json() as Promise<T>; + return responseData(response) as Promise<T>; } export type ParseResult = { @@ -112,13 +179,26 @@ export type AssistantResult = { message: string; requires_confirmation?: boolean; executed?: boolean; + clarification?: { + type: string; + prompt: string; + choices: Array<{ + id: string; + title: string; + date?: string | null; + start_time?: string | null; + }>; + } | null; + events?: ApiEvent[]; + suggested_actions?: string[]; }; export type AssistantExecuteParams = { - event_id: string; - action: 'cancel' | 'reschedule'; + event_id?: string; + action: 'cancel' | 'reschedule' | 'create' | 'update'; new_date?: string; new_time?: string; + event_data?: Record<string, unknown>; }; export async function parseEventNL(text: string): Promise<ParseResult> { @@ -128,10 +208,13 @@ export async function parseEventNL(text: string): Promise<ParseResult> { }); } -export async function callAssistant(text: string): Promise<AssistantResult> { +export async function callAssistant( + text: string, + options?: { selected_event_id?: string; context_event_id?: string }, +): Promise<AssistantResult> { return fetchApi<AssistantResult>('/assistant', { method: 'POST', - body: JSON.stringify({ text }), + body: JSON.stringify({ text, ...options }), }); } @@ -144,6 +227,7 @@ export async function executeAssistant(params: AssistantExecuteParams): Promise< action: params.action, new_date: params.new_date, new_time: params.new_time, + event_data: params.event_data, }), }); } @@ -232,6 +316,11 @@ export type ApiEvent = { participants?: string; recurrence_rule?: string | null; category?: string | null; + description: string; + location_url?: string | null; + priority: 'low' | 'normal' | 'important'; + tags: string[]; + reminder_minutes?: number | null; external_ids?: Record<string, string>; sync_status?: string; last_synced_at?: string | null; @@ -253,13 +342,33 @@ export async function fetchEventsExpanded( return fetchApi<ApiEvent[]>(`/events?${qs.toString()}`); } -export async function exportIcs(): Promise<Blob> { +export async function exportIcs(retry = true): Promise<Blob> { const token = localStorage.getItem('token'); - const response = await fetch(`${API_BASE_URL}/export/ics`, { - headers: token ? { Authorization: `Bearer ${token}` } : {}, - }); + const timeout = requestSignal(); + let response: Response; + try { + response = await fetch(`${API_BASE_URL}/export/ics`, { + headers: token ? { Authorization: `Bearer ${token}` } : {}, + signal: timeout.signal, + }); + } catch (error: unknown) { + if (timeout.signal.aborted) { + throw new ApiError(408, { detail: 'The export took too long. Please try again.' }, 'Export timed out'); + } + throw new ApiError( + 0, + { detail: error instanceof Error ? error.message : 'Calendar export failed' }, + 'Calendar export failed', + ); + } finally { + timeout.dispose(); + } + if (response.status === 401 && retry) { + const refreshed = await refreshAccessToken(); + if (refreshed) return exportIcs(false); + } if (!response.ok) { - const errorData = await response.json().catch(() => ({})); + const errorData = await responseData(response); throw new ApiError(response.status, errorData, 'Failed to export calendar'); } return response.blob(); diff --git a/frontend/lib/eventIds.test.ts b/frontend/lib/eventIds.test.ts new file mode 100644 index 0000000..81c5188 --- /dev/null +++ b/frontend/lib/eventIds.test.ts @@ -0,0 +1,14 @@ +import { describe, expect, it } from "vitest"; + +import { baseEventId } from "./eventIds"; + +describe("baseEventId", () => { + it("removes only the recurring instance date suffix", () => { + expect(baseEventId("team_sync_2026-07-06")).toBe("team_sync"); + }); + + it("leaves non-recurring ids with underscores intact", () => { + expect(baseEventId("team_sync")).toBe("team_sync"); + expect(baseEventId("team_sync_draft")).toBe("team_sync_draft"); + }); +}); diff --git a/frontend/lib/eventIds.ts b/frontend/lib/eventIds.ts new file mode 100644 index 0000000..682a7ae --- /dev/null +++ b/frontend/lib/eventIds.ts @@ -0,0 +1,3 @@ +export function baseEventId(eventId: string): string { + return eventId.replace(/_\d{4}-\d{2}-\d{2}$/, ""); +} diff --git a/frontend/lib/preferences.test.ts b/frontend/lib/preferences.test.ts new file mode 100644 index 0000000..ae7ebb0 --- /dev/null +++ b/frontend/lib/preferences.test.ts @@ -0,0 +1,60 @@ +import { beforeEach, describe, expect, it } from "vitest"; + +import { + DEFAULT_PREFERENCES, + normalizePreferences, + readStoredPreferences, + savePreferences, +} from "@/lib/preferences"; + +describe("preferences", () => { + beforeEach(() => { + localStorage.clear(); + }); + + it("falls back when stored preference fields have invalid types", () => { + const preferences = normalizePreferences( + { + timezone: 123, + defaultDuration: "abc", + workingHoursStart: "25:99", + workingHoursEnd: null, + }, + "Asia/Jakarta", + ); + + expect(preferences).toEqual({ + timezone: "Asia/Jakarta", + defaultDuration: DEFAULT_PREFERENCES.defaultDuration, + workingHoursStart: DEFAULT_PREFERENCES.workingHoursStart, + workingHoursEnd: DEFAULT_PREFERENCES.workingHoursEnd, + }); + }); + + it("clamps default event duration to the supported input range", () => { + expect(normalizePreferences({ defaultDuration: 1 }).defaultDuration).toBe(5); + expect(normalizePreferences({ defaultDuration: 999 }).defaultDuration).toBe(480); + }); + + it("recovers from malformed stored preference JSON", () => { + localStorage.setItem("timeora_preferences", "{bad-json"); + + expect(readStoredPreferences("Asia/Jakarta")).toEqual({ + ...DEFAULT_PREFERENCES, + timezone: "Asia/Jakarta", + }); + }); + + it("saves normalized preferences back to localStorage", () => { + const saved = savePreferences({ + ...DEFAULT_PREFERENCES, + timezone: "Asia/Jakarta", + defaultDuration: 999, + workingHoursStart: "7am", + }); + + expect(saved.defaultDuration).toBe(480); + expect(saved.workingHoursStart).toBe(DEFAULT_PREFERENCES.workingHoursStart); + expect(JSON.parse(localStorage.getItem("timeora_preferences") ?? "{}")).toEqual(saved); + }); +}); diff --git a/frontend/lib/preferences.ts b/frontend/lib/preferences.ts new file mode 100644 index 0000000..3e601a8 --- /dev/null +++ b/frontend/lib/preferences.ts @@ -0,0 +1,96 @@ +export type UserPreferences = { + timezone: string; + defaultDuration: number; + workingHoursStart: string; + workingHoursEnd: string; +}; + +export const DEFAULT_PREFERENCES: UserPreferences = { + timezone: "", + defaultDuration: 60, + workingHoursStart: "09:00", + workingHoursEnd: "17:00", +}; + +const STORAGE_KEY = "timeora_preferences"; +const MIN_DEFAULT_DURATION = 5; +const MAX_DEFAULT_DURATION = 480; +const TIME_VALUE_PATTERN = /^([01]\d|2[0-3]):[0-5]\d$/; + +function toPreferenceRecord(value: unknown): Record<string, unknown> { + if (typeof value !== "object" || value === null || Array.isArray(value)) { + return {}; + } + + return value as Record<string, unknown>; +} + +function normalizeDefaultDuration(value: unknown): number { + const duration = typeof value === "number" + ? value + : typeof value === "string" && value.trim() !== "" + ? Number(value) + : DEFAULT_PREFERENCES.defaultDuration; + + if (!Number.isFinite(duration)) { + return DEFAULT_PREFERENCES.defaultDuration; + } + + return Math.min( + MAX_DEFAULT_DURATION, + Math.max(MIN_DEFAULT_DURATION, Math.round(duration)), + ); +} + +function normalizeTimeValue(value: unknown, fallback: string): string { + return typeof value === "string" && TIME_VALUE_PATTERN.test(value) + ? value + : fallback; +} + +export function normalizePreferences( + value: unknown, + fallbackTimezone = DEFAULT_PREFERENCES.timezone, +): UserPreferences { + const preferences = toPreferenceRecord(value); + + return { + timezone: typeof preferences.timezone === "string" + ? preferences.timezone + : fallbackTimezone, + defaultDuration: normalizeDefaultDuration(preferences.defaultDuration), + workingHoursStart: normalizeTimeValue( + preferences.workingHoursStart, + DEFAULT_PREFERENCES.workingHoursStart, + ), + workingHoursEnd: normalizeTimeValue( + preferences.workingHoursEnd, + DEFAULT_PREFERENCES.workingHoursEnd, + ), + }; +} + +export function readStoredPreferences( + fallbackTimezone = DEFAULT_PREFERENCES.timezone, +): UserPreferences { + if (typeof window === "undefined") { + return normalizePreferences({}, fallbackTimezone); + } + + try { + const stored = window.localStorage.getItem(STORAGE_KEY); + if (!stored) { + return normalizePreferences({}, fallbackTimezone); + } + + return normalizePreferences(JSON.parse(stored), fallbackTimezone); + } catch { + return normalizePreferences({}, fallbackTimezone); + } +} + +export function savePreferences(preferences: UserPreferences): UserPreferences { + const normalized = normalizePreferences(preferences); + window.localStorage.setItem(STORAGE_KEY, JSON.stringify(normalized)); + return normalized; +} diff --git a/frontend/lib/reminders.test.ts b/frontend/lib/reminders.test.ts new file mode 100644 index 0000000..8358473 --- /dev/null +++ b/frontend/lib/reminders.test.ts @@ -0,0 +1,131 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { + createReminderScheduler, + getReminderDelayMs, + getReminderFireTime, + gmailSearchUrl, + requestReminderPermission, + type ReminderEvent, +} from "./reminders"; + +const EVENT: ReminderEvent = { + id: "event-1", + title: "Product Sync", + date: "2026-07-06", + start_time: "14:00:00", + participants: "team@example.com", + reminder_minutes: 15, +}; + +describe("reminders", () => { + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it("calculates the notification fire time and delay", () => { + const fireTime = getReminderFireTime(EVENT); + + expect(fireTime?.getHours()).toBe(13); + expect(fireTime?.getMinutes()).toBe(45); + expect(getReminderDelayMs(EVENT, new Date(fireTime!.getTime() - 15 * 60_000))).toBe(15 * 60_000); + }); + + it("falls back in-app when browser notification permission is denied", () => { + const notify = vi.fn(); + const fallback = vi.fn(); + const afterFireTime = new Date(getReminderFireTime(EVENT)!.getTime() + 60_000); + const scheduler = createReminderScheduler({ + now: () => afterFireTime, + permission: () => "denied", + notify, + fallback, + }); + + scheduler.schedule([EVENT]); + + expect(notify).not.toHaveBeenCalled(); + expect(fallback).toHaveBeenCalledWith(expect.objectContaining({ title: "Product Sync" })); + }); + + it("suppresses duplicate reminders for the same event occurrence", () => { + const fallback = vi.fn(); + const afterFireTime = new Date(getReminderFireTime(EVENT)!.getTime() + 60_000); + const scheduler = createReminderScheduler({ + now: () => afterFireTime, + permission: () => "unsupported", + fallback, + }); + + scheduler.schedule([EVENT]); + scheduler.schedule([EVENT]); + + expect(fallback).toHaveBeenCalledTimes(1); + }); + + it("ignores non-finite reminder values instead of scheduling invalid timers", () => { + const setTimer = vi.fn(); + const fallback = vi.fn(); + const invalidEvent: ReminderEvent = { + ...EVENT, + reminder_minutes: Number.NaN, + }; + const scheduler = createReminderScheduler({ + setTimer, + fallback, + }); + + scheduler.schedule([invalidEvent]); + + expect(getReminderFireTime(invalidEvent)).toBeNull(); + expect(setTimer).not.toHaveBeenCalled(); + expect(fallback).not.toHaveBeenCalled(); + }); + + it("chunks far future reminders instead of scheduling an overflowing browser timer", () => { + const maxBrowserDelayMs = 2_147_483_647; + let now = new Date("2026-07-06T00:00:00"); + const callbacks: Array<() => void> = []; + const setTimer = vi.fn((callback: () => void) => { + callbacks.push(callback); + return callbacks.length; + }); + const farFutureEvent: ReminderEvent = { + ...EVENT, + date: "2026-09-04", + start_time: "14:00:00", + reminder_minutes: 0, + }; + const scheduler = createReminderScheduler({ + now: () => now, + setTimer, + }); + + scheduler.schedule([farFutureEvent]); + + expect(setTimer).toHaveBeenCalledWith(expect.any(Function), maxBrowserDelayMs); + + now = new Date("2026-09-04T13:55:00"); + callbacks[0](); + + expect(setTimer).toHaveBeenLastCalledWith(expect.any(Function), 5 * 60_000); + }); + + it("creates a safe encoded Gmail search URL", () => { + expect(gmailSearchUrl({ + title: "Product Sync / Q3", + participants: "team@example.com boss@example.com", + })).toBe( + "https://mail.google.com/mail/u/0/#search/Product%20Sync%20%2F%20Q3%20team%40example.com%20boss%40example.com", + ); + }); + + it("falls back when browser notification permission requests fail", async () => { + vi.stubGlobal("Notification", { + permission: "default", + requestPermission: vi.fn().mockRejectedValue(new Error("blocked")), + }); + + await expect(requestReminderPermission()).resolves.toBe("unsupported"); + }); +}); diff --git a/frontend/lib/reminders.ts b/frontend/lib/reminders.ts new file mode 100644 index 0000000..1fcbc12 --- /dev/null +++ b/frontend/lib/reminders.ts @@ -0,0 +1,174 @@ +export type ReminderEvent = { + id: string; + title: string; + date: string; + start_time: string; + participants?: string; + reminder_minutes?: number | null; +}; + +export type ReminderDelivery = { + key: string; + title: string; + body: string; + event: ReminderEvent; +}; + +export type ReminderPermissionState = NotificationPermission | "unsupported"; + +type TimerHandle = number | ReturnType<typeof setTimeout>; + +type ReminderSchedulerOptions = { + now?: () => Date; + setTimer?: (callback: () => void, delayMs: number) => TimerHandle; + clearTimer?: (handle: TimerHandle) => void; + permission?: () => ReminderPermissionState; + notify?: (title: string, options: NotificationOptions) => void; + fallback?: (delivery: ReminderDelivery) => void; +}; + +const MAX_REMINDER_TIMER_DELAY_MS = 2_147_483_647; + +function parseLocalEventStart(event: ReminderEvent): Date { + return new Date(`${event.date}T${event.start_time}`); +} + +export function getReminderFireTime(event: ReminderEvent): Date | null { + if (event.reminder_minutes === null || event.reminder_minutes === undefined) { + return null; + } + if (!Number.isFinite(event.reminder_minutes) || event.reminder_minutes < 0) { + return null; + } + const start = parseLocalEventStart(event); + if (Number.isNaN(start.getTime())) return null; + return new Date(start.getTime() - event.reminder_minutes * 60_000); +} + +export function getReminderDelayMs(event: ReminderEvent, now = new Date()): number | null { + const fireTime = getReminderFireTime(event); + if (!fireTime) return null; + return fireTime.getTime() - now.getTime(); +} + +export function gmailSearchUrl(event: Pick<ReminderEvent, "title" | "participants">): string { + const terms = [event.title, event.participants].filter(Boolean).join(" "); + return `https://mail.google.com/mail/u/0/#search/${encodeURIComponent(terms)}`; +} + +export async function requestReminderPermission(): Promise<ReminderPermissionState> { + if (typeof Notification === "undefined") return "unsupported"; + if (Notification.permission !== "default") return Notification.permission; + try { + return await Notification.requestPermission(); + } catch { + return "unsupported"; + } +} + +function currentReminderPermission(): ReminderPermissionState { + if (typeof Notification === "undefined") return "unsupported"; + return Notification.permission; +} + +function reminderKey(event: ReminderEvent): string { + return `${event.id}:${event.date}:${event.start_time}:${event.reminder_minutes ?? "none"}`; +} + +function reminderBody(event: ReminderEvent): string { + const time = event.start_time.slice(0, 5); + const participants = event.participants ? ` · ${event.participants}` : ""; + return `Starts at ${time}${participants}`; +} + +export function createReminderScheduler(options: ReminderSchedulerOptions = {}) { + const now = options.now ?? (() => new Date()); + const setTimer = options.setTimer ?? ((callback, delayMs) => window.setTimeout(callback, delayMs)); + const clearTimer = options.clearTimer ?? ((handle) => window.clearTimeout(handle)); + const permission = options.permission ?? currentReminderPermission; + const notify = options.notify ?? ((title, notificationOptions) => { + if (typeof Notification !== "undefined") { + new Notification(title, notificationOptions); + } + }); + const fallback = options.fallback ?? (() => undefined); + const timers = new Map<string, TimerHandle>(); + const delivered = new Set<string>(); + + const deliver = (event: ReminderEvent) => { + const key = reminderKey(event); + if (delivered.has(key)) return; + delivered.add(key); + timers.delete(key); + + const delivery: ReminderDelivery = { + key, + title: event.title, + body: reminderBody(event), + event, + }; + + if (permission() === "granted") { + notify(`Upcoming: ${event.title}`, { + body: delivery.body, + tag: key, + }); + return; + } + + fallback(delivery); + }; + + const schedule = (events: ReminderEvent[]) => { + const nextKeys = new Set<string>(); + + for (const event of events) { + const fireTime = getReminderFireTime(event); + if (!fireTime) continue; + + const start = parseLocalEventStart(event); + if (Number.isNaN(start.getTime()) || start.getTime() <= now().getTime()) { + continue; + } + + const key = reminderKey(event); + nextKeys.add(key); + if (delivered.has(key)) continue; + + const delayMs = fireTime.getTime() - now().getTime(); + if (delayMs <= 0) { + deliver(event); + continue; + } + + if (timers.has(key)) continue; + + if (delayMs > MAX_REMINDER_TIMER_DELAY_MS) { + timers.set(key, setTimer(() => { + timers.delete(key); + schedule([event]); + }, MAX_REMINDER_TIMER_DELAY_MS)); + continue; + } + + timers.set(key, setTimer(() => deliver(event), delayMs)); + } + + for (const [key, handle] of timers) { + if (!nextKeys.has(key)) { + clearTimer(handle); + timers.delete(key); + } + } + }; + + return { + schedule, + cancel() { + for (const handle of timers.values()) { + clearTimer(handle); + } + timers.clear(); + }, + }; +} diff --git a/frontend/lib/session.test.ts b/frontend/lib/session.test.ts new file mode 100644 index 0000000..3765ae6 --- /dev/null +++ b/frontend/lib/session.test.ts @@ -0,0 +1,30 @@ +import { beforeEach, describe, expect, it } from "vitest"; + +import { persistAuthTokens } from "./session"; + +describe("persistAuthTokens", () => { + beforeEach(() => { + localStorage.clear(); + }); + + it("clears stale refresh tokens when a new login response omits refresh_token", () => { + localStorage.setItem("refresh_token", "old-refresh"); + + persistAuthTokens({ access_token: "new-access" }); + + expect(localStorage.getItem("token")).toBe("new-access"); + expect(localStorage.getItem("refresh_token")).toBeNull(); + }); + + it("can preserve the existing refresh token for refresh responses that only rotate access_token", () => { + localStorage.setItem("refresh_token", "existing-refresh"); + + persistAuthTokens( + { access_token: "fresh-access" }, + { preserveRefreshToken: true }, + ); + + expect(localStorage.getItem("token")).toBe("fresh-access"); + expect(localStorage.getItem("refresh_token")).toBe("existing-refresh"); + }); +}); diff --git a/frontend/lib/session.ts b/frontend/lib/session.ts new file mode 100644 index 0000000..efe6a5b --- /dev/null +++ b/frontend/lib/session.ts @@ -0,0 +1,26 @@ +type AuthTokens = { + access_token?: string; + refresh_token?: string | null; +}; + +type PersistAuthTokensOptions = { + preserveRefreshToken?: boolean; +}; + +export function persistAuthTokens( + tokens: AuthTokens, + options: PersistAuthTokensOptions = {}, +): void { + if (tokens.access_token) { + localStorage.setItem("token", tokens.access_token); + } + + if (tokens.refresh_token) { + localStorage.setItem("refresh_token", tokens.refresh_token); + return; + } + + if (!options.preserveRefreshToken) { + localStorage.removeItem("refresh_token"); + } +} diff --git a/frontend/lib/templates.test.ts b/frontend/lib/templates.test.ts new file mode 100644 index 0000000..43ef579 --- /dev/null +++ b/frontend/lib/templates.test.ts @@ -0,0 +1,113 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { applyTemplate, getCustomTemplates, saveTemplate } from "./templates"; + +describe("templates", () => { + beforeEach(() => { + localStorage.clear(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + vi.useRealTimers(); + }); + + it("recovers when stored custom templates are not an array", () => { + localStorage.setItem("timeora_templates", "{}"); + + expect(getCustomTemplates()).toEqual([]); + expect(() => saveTemplate({ + name: "Planning", + title: "Planning", + duration_minutes: 30, + start_time: "10:00:00", + category: "meeting", + participants: "", + })).not.toThrow(); + }); + + it("drops stored custom templates with invalid scheduling fields", () => { + localStorage.setItem("timeora_templates", JSON.stringify([ + { + id: "custom-valid", + name: "Planning", + title: "Planning", + duration_minutes: 30, + start_time: "10:00:00", + category: "meeting", + participants: "", + }, + { + id: "custom-invalid-duration", + name: "Bad Duration", + title: "Bad Duration", + duration_minutes: 0, + start_time: "11:00:00", + category: "meeting", + participants: "", + }, + { + id: "custom-invalid-time", + name: "Bad Time", + title: "Bad Time", + duration_minutes: 30, + start_time: "99:99:99", + category: "meeting", + participants: "", + }, + ])); + + expect(getCustomTemplates()).toHaveLength(1); + expect(getCustomTemplates()[0].id).toBe("custom-valid"); + }); + + it("normalizes saved custom templates before storing them", () => { + const saved = saveTemplate({ + name: "Huge Planning", + title: "Huge Planning", + duration_minutes: 9999, + start_time: "bad-time", + category: "", + participants: "", + }); + + expect(saved).toEqual(expect.objectContaining({ + duration_minutes: 480, + start_time: "09:00:00", + category: null, + })); + expect(getCustomTemplates()[0]).toEqual(saved); + }); + + it("does not crash when browser storage rejects custom templates", () => { + vi.spyOn(Storage.prototype, "setItem").mockImplementation(() => { + throw new DOMException("Quota exceeded", "QuotaExceededError"); + }); + + expect(() => saveTemplate({ + name: "Planning", + title: "Planning", + duration_minutes: 30, + start_time: "10:00:00", + category: "meeting", + participants: "", + })).not.toThrow(); + }); + + it("uses the local calendar date when applying a template without an explicit date", () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date(2026, 6, 7, 0, 30)); + + const applied = applyTemplate({ + id: "custom-planning", + name: "Planning", + title: "Planning", + duration_minutes: 30, + start_time: "10:00:00", + category: "meeting", + participants: "", + }); + + expect(applied.date).toBe("2026-07-07"); + }); +}); diff --git a/frontend/lib/templates.ts b/frontend/lib/templates.ts index cba0e9b..072509e 100644 --- a/frontend/lib/templates.ts +++ b/frontend/lib/templates.ts @@ -1,4 +1,6 @@ -import { EventData } from "@/components/calendar/EventDialog"; +import { CATEGORIES } from "@/lib/categories"; +import type { EventData } from "@/components/calendar/EventDialog"; +import { format } from "date-fns"; export type EventTemplate = { id: string; @@ -11,6 +13,10 @@ export type EventTemplate = { }; const STORAGE_KEY = "timeora_templates"; +const MIN_TEMPLATE_DURATION = 5; +const MAX_TEMPLATE_DURATION = 480; +const DEFAULT_TEMPLATE_START_TIME = "09:00:00"; +const TIME_VALUE_PATTERN = /^([01]\d|2[0-3]):[0-5]\d(?::[0-5]\d)?$/; const DEFAULT_TEMPLATES: EventTemplate[] = [ { @@ -51,37 +57,94 @@ const DEFAULT_TEMPLATES: EventTemplate[] = [ }, ]; -export function getTemplates(): EventTemplate[] { - if (typeof window === "undefined") return DEFAULT_TEMPLATES; - try { - const stored = localStorage.getItem(STORAGE_KEY); - if (!stored) return DEFAULT_TEMPLATES; - const parsed = JSON.parse(stored) as EventTemplate[]; - return [...DEFAULT_TEMPLATES, ...parsed]; - } catch { - return DEFAULT_TEMPLATES; - } +function isEventTemplate(value: unknown): value is EventTemplate { + if (!value || typeof value !== "object") return false; + const template = value as Partial<EventTemplate>; + return ( + typeof template.id === "string" && + typeof template.name === "string" && + typeof template.title === "string" && + typeof template.duration_minutes === "number" && + Number.isFinite(template.duration_minutes) && + template.duration_minutes >= MIN_TEMPLATE_DURATION && + template.duration_minutes <= MAX_TEMPLATE_DURATION && + typeof template.start_time === "string" && + TIME_VALUE_PATTERN.test(template.start_time) && + (template.category === null || ( + typeof template.category === "string" && + template.category in CATEGORIES + )) && + typeof template.participants === "string" + ); } -export function getCustomTemplates(): EventTemplate[] { +function normalizeDuration(value: number): number { + if (!Number.isFinite(value)) return 60; + return Math.min( + MAX_TEMPLATE_DURATION, + Math.max(MIN_TEMPLATE_DURATION, Math.round(value)), + ); +} + +function normalizeStartTime(value: string): string { + if (!TIME_VALUE_PATTERN.test(value)) return DEFAULT_TEMPLATE_START_TIME; + return value.length === 5 ? `${value}:00` : value; +} + +function normalizeCategory(value: string | null): string | null { + if (!value) return null; + return value in CATEGORIES ? value : null; +} + +function normalizeTemplate(template: Omit<EventTemplate, "id">): Omit<EventTemplate, "id"> { + return { + ...template, + duration_minutes: normalizeDuration(template.duration_minutes), + start_time: normalizeStartTime(template.start_time), + category: normalizeCategory(template.category), + }; +} + +function readCustomTemplates(): EventTemplate[] { if (typeof window === "undefined") return []; try { const stored = localStorage.getItem(STORAGE_KEY); if (!stored) return []; - return JSON.parse(stored) as EventTemplate[]; + const parsed = JSON.parse(stored) as unknown; + if (!Array.isArray(parsed)) return []; + return parsed.filter(isEventTemplate); } catch { return []; } } +function writeCustomTemplates(templates: EventTemplate[]): void { + if (typeof window === "undefined") return; + try { + localStorage.setItem(STORAGE_KEY, JSON.stringify(templates)); + } catch { + // Storage can fail in private mode or when quota is exceeded. + // Template save/delete should not break the event creation flow. + } +} + +export function getTemplates(): EventTemplate[] { + if (typeof window === "undefined") return DEFAULT_TEMPLATES; + return [...DEFAULT_TEMPLATES, ...readCustomTemplates()]; +} + +export function getCustomTemplates(): EventTemplate[] { + return readCustomTemplates(); +} + export function saveTemplate(template: Omit<EventTemplate, "id">): EventTemplate { const custom = getCustomTemplates(); const newTemplate: EventTemplate = { - ...template, + ...normalizeTemplate(template), id: `custom-${Date.now()}`, }; custom.push(newTemplate); - localStorage.setItem(STORAGE_KEY, JSON.stringify(custom)); + writeCustomTemplates(custom); return newTemplate; } @@ -89,7 +152,7 @@ export function deleteTemplate(id: string): void { // Only allow deleting custom templates if (id.startsWith("preset-")) return; const custom = getCustomTemplates().filter((t) => t.id !== id); - localStorage.setItem(STORAGE_KEY, JSON.stringify(custom)); + writeCustomTemplates(custom); } export function applyTemplate( @@ -98,7 +161,7 @@ export function applyTemplate( ): Partial<EventData> { return { title: template.title, - date: date || new Date().toISOString().slice(0, 10), + date: date || format(new Date(), "yyyy-MM-dd"), start_time: template.start_time, duration_minutes: template.duration_minutes, participants: template.participants, diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 1ee8df6..92a5c1f 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -24,20 +24,33 @@ "react": "19.2.4", "react-dom": "19.2.4", "shadcn": "^4.12.0", + "sonner": "^2.0.7", "tailwind-merge": "^3.6.0", "tw-animate-css": "^1.4.0" }, "devDependencies": { "@tailwindcss/postcss": "^4", + "@testing-library/jest-dom": "^6.9.1", + "@testing-library/react": "^16.3.2", + "@testing-library/user-event": "^14.6.1", "@types/node": "^20", "@types/react": "^19", "@types/react-dom": "^19", "eslint": "^9", "eslint-config-next": "16.2.10", + "jsdom": "^29.1.1", "tailwindcss": "^4", - "typescript": "^5" + "typescript": "^5", + "vitest": "^4.1.10" } }, + "node_modules/@adobe/css-tools": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/@adobe/css-tools/-/css-tools-4.5.0.tgz", + "integrity": "sha512-6OzddxPio9UiWTCemp4N8cYLV2ZN1ncRnV1cVGtve7dhPOtRkleRyx32GQCYSwDYgaHU3USMm84tNsvKzRCa1Q==", + "dev": true, + "license": "MIT" + }, "node_modules/@alloc/quick-lru": { "version": "5.2.0", "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz", @@ -51,6 +64,57 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/@asamuzakjp/css-color": { + "version": "5.1.11", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-5.1.11.tgz", + "integrity": "sha512-KVw6qIiCTUQhByfTd78h2yD1/00waTmm9uy/R7Ck/ctUyAPj+AEDLkQIdJW0T8+qGgj3j5bpNKK7Q3G+LedJWg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/generational-cache": "^1.0.1", + "@csstools/css-calc": "^3.2.0", + "@csstools/css-color-parser": "^4.1.0", + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/dom-selector": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-7.1.1.tgz", + "integrity": "sha512-67RZDnYRc8H/8MLDgQCDE//zoqVFwajkepHZgmXrbwybzXOEwOWGPYGmALYl9J2DOLfFPPs6kKCqmbzV895hTQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/generational-cache": "^1.0.1", + "@asamuzakjp/nwsapi": "^2.3.9", + "bidi-js": "^1.0.3", + "css-tree": "^3.2.1", + "is-potential-custom-element-name": "^1.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/generational-cache": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@asamuzakjp/generational-cache/-/generational-cache-1.0.1.tgz", + "integrity": "sha512-wajfB8KqzMCN2KGNFdLkReeHncd0AslUSrvHVvvYWuU8ghncRJoA50kT3zP9MVL0+9g4/67H+cdvBskj9THPzg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/nwsapi": { + "version": "2.3.9", + "resolved": "https://registry.npmjs.org/@asamuzakjp/nwsapi/-/nwsapi-2.3.9.tgz", + "integrity": "sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==", + "dev": true, + "license": "MIT" + }, "node_modules/@babel/code-frame": { "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", @@ -525,6 +589,159 @@ } } }, + "node_modules/@bramus/specificity": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.2.tgz", + "integrity": "sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==", + "dev": true, + "license": "MIT", + "dependencies": { + "css-tree": "^3.0.0" + }, + "bin": { + "specificity": "bin/cli.js" + } + }, + "node_modules/@csstools/color-helpers": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.1.0.tgz", + "integrity": "sha512-064IFJdjTfUqnjpCVpMOdbr8FLQBhinbZj6yRv2An2E41O/pLEXqfFRWqGq/SxlE5PEUYTlvWsG2r8MswAVvkg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=20.19.0" + } + }, + "node_modules/@csstools/css-calc": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.2.1.tgz", + "integrity": "sha512-DtdHlgXh5ZkA43cwBcAm+huzgJiwx3ZTWVjBs94kwz2xKqSimDA3lBgCjphYgwgVUMWatSM0pDd8TILB1yrVVg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-color-parser": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.1.9.tgz", + "integrity": "sha512-paQcIaOO53Rk5+YrBaBjm/SgrV4INImjo2BT1DtQRYr+XeTRbeAYlS+jxXp9drqvKmtFnWRJKIalDLhZZDu42A==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "dependencies": { + "@csstools/color-helpers": "^6.1.0", + "@csstools/css-calc": "^3.2.1" + }, + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-parser-algorithms": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-4.0.0.tgz", + "integrity": "sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-syntax-patches-for-csstree": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.6.tgz", + "integrity": "sha512-TcJCWFbXLPpJYq6z7bfOyjWYJDiDg2/I4gyUC9pqPNqHFRIey0EB0q0L5cSnQDfWJg8Jd6VadakxdIez/3zkqQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "peerDependencies": { + "css-tree": "^3.2.1" + }, + "peerDependenciesMeta": { + "css-tree": { + "optional": true + } + } + }, + "node_modules/@csstools/css-tokenizer": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-4.0.0.tgz", + "integrity": "sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + } + }, "node_modules/@dotenvx/dotenvx": { "version": "1.75.1", "resolved": "https://registry.npmjs.org/@dotenvx/dotenvx/-/dotenvx-1.75.1.tgz", @@ -936,6 +1153,24 @@ "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, + "node_modules/@exodus/bytes": { + "version": "1.15.1", + "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.15.1.tgz", + "integrity": "sha512-S6mL0yNB/Abt9Ei4tq8gDhcczc4S3+vQ4ra7vxnAf+YHC02srtqxKKZghx2Dq6p0e66THKwR6r8N6P95wEty7Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + }, + "peerDependencies": { + "@noble/hashes": "^1.8.0 || ^2.0.0" + }, + "peerDependenciesMeta": { + "@noble/hashes": { + "optional": true + } + } + }, "node_modules/@floating-ui/core": { "version": "1.7.5", "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.5.tgz", @@ -1883,84 +2118,20 @@ "node": ">=12.4.0" } }, - "node_modules/@rtsao/scc": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@rtsao/scc/-/scc-1.1.0.tgz", - "integrity": "sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g==", + "node_modules/@oxc-project/types": { + "version": "0.138.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.138.0.tgz", + "integrity": "sha512-1a7ZKmrRTCoN1XMZ4L0PyyqrMnrNlLyPuOkdSX2MZg7IiIGRUyurNhAm73ptDOraoBcIordsIGKNPKUzy3ZmfA==", "dev": true, - "license": "MIT" - }, - "node_modules/@sec-ant/readable-stream": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/@sec-ant/readable-stream/-/readable-stream-0.4.1.tgz", - "integrity": "sha512-831qok9r2t8AlxLko40y2ebgSDhenenCatLVeW/uBtnHPyhHOvG0C7TvfgecV+wHzIm5KUICgzmVpWS+IMEAeg==", - "license": "MIT" - }, - "node_modules/@sindresorhus/merge-streams": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/@sindresorhus/merge-streams/-/merge-streams-4.0.0.tgz", - "integrity": "sha512-tlqY9xq5ukxTUZBmoOp+m61cqwQD5pHJtFY3Mn8CA8ps6yghLH/Hw8UPdqg4OLmFW3IFlcXnQNmo/dh8HzXYIQ==", "license": "MIT", - "engines": { - "node": ">=18" - }, "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/@swc/helpers": { - "version": "0.5.15", - "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz", - "integrity": "sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==", - "license": "Apache-2.0", - "dependencies": { - "tslib": "^2.8.0" - } - }, - "node_modules/@tailwindcss/node": { - "version": "4.3.2", - "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.3.2.tgz", - "integrity": "sha512-yWP/sqEcBLaD8JuA6zNwxoYKr75qxTioYwlRwekj5Jr/I5GXnoJfjetH/psLUIv74cYTH2lBUEzBkinthoYcBg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/remapping": "^2.3.5", - "enhanced-resolve": "5.21.6", - "jiti": "^2.7.0", - "lightningcss": "1.32.0", - "magic-string": "^0.30.21", - "source-map-js": "^1.2.1", - "tailwindcss": "4.3.2" - } - }, - "node_modules/@tailwindcss/oxide": { - "version": "4.3.2", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.3.2.tgz", - "integrity": "sha512-z8ZgnzX8gdNoWLBLqBPoh/sjnxkwvf9ZuWjnO0l0yIzbLa5/9S+eC5QxGZKRobVHIC3/1BoMWjHblqWjcgFgag==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 20" - }, - "optionalDependencies": { - "@tailwindcss/oxide-android-arm64": "4.3.2", - "@tailwindcss/oxide-darwin-arm64": "4.3.2", - "@tailwindcss/oxide-darwin-x64": "4.3.2", - "@tailwindcss/oxide-freebsd-x64": "4.3.2", - "@tailwindcss/oxide-linux-arm-gnueabihf": "4.3.2", - "@tailwindcss/oxide-linux-arm64-gnu": "4.3.2", - "@tailwindcss/oxide-linux-arm64-musl": "4.3.2", - "@tailwindcss/oxide-linux-x64-gnu": "4.3.2", - "@tailwindcss/oxide-linux-x64-musl": "4.3.2", - "@tailwindcss/oxide-wasm32-wasi": "4.3.2", - "@tailwindcss/oxide-win32-arm64-msvc": "4.3.2", - "@tailwindcss/oxide-win32-x64-msvc": "4.3.2" + "url": "https://github.com/sponsors/Boshen" } }, - "node_modules/@tailwindcss/oxide-android-arm64": { - "version": "4.3.2", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.3.2.tgz", - "integrity": "sha512-WHxqIuHpvZ5VtdX6GTl1Ik/Vp2YuN42Et+0CdeaVd/frQ9jAvGmvR8vLT+jk3e8/Q3x8kECB9+R17pgpp2BulA==", + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.1.4.tgz", + "integrity": "sha512-EZLpf/8y7GXkkra90ML47kzik/GMP3EMcE9bPyHmRfxLC6z9+aW5A8poCsoxjrT5GfEcNAAvWwUHjvP1pUQkfw==", "cpu": [ "arm64" ], @@ -1971,13 +2142,13 @@ "android" ], "engines": { - "node": ">= 20" + "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/@tailwindcss/oxide-darwin-arm64": { - "version": "4.3.2", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.3.2.tgz", - "integrity": "sha512-GZypeUY/IDJW3877KeM+O67vbXr3MBnbtEL4aYhNErv/JWZhye2vGSWWG9tB6iiqR2MqRNkY8IOUy4NdSZV26w==", + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.1.4.tgz", + "integrity": "sha512-aUi+HBvmYb7j8krl1+qJgkG8C17fO79gk3c+jPw4S8glRFc1DTija9S3EyaTSQUm5GJXYKDAsugBEhFHH2vYiQ==", "cpu": [ "arm64" ], @@ -1988,13 +2159,13 @@ "darwin" ], "engines": { - "node": ">= 20" + "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/@tailwindcss/oxide-darwin-x64": { - "version": "4.3.2", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.3.2.tgz", - "integrity": "sha512-UIIzmefR6KO1sDU7MzRqAxC8iBpft/VhkGjTjnhoS6k7Z3rQ9wEgA1ODSiyH/tcSYssulNm4Ci3hOeK1jH7ccQ==", + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.1.4.tgz", + "integrity": "sha512-F7hHC3gwY11+vByKPRWqwGbeXWVgKmL+pTGCinaEhdihzBV2aQ0fvZOch9cXYUOKuKKq429HeYXOqQLc7wFCEg==", "cpu": [ "x64" ], @@ -2005,13 +2176,13 @@ "darwin" ], "engines": { - "node": ">= 20" + "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/@tailwindcss/oxide-freebsd-x64": { - "version": "4.3.2", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.3.2.tgz", - "integrity": "sha512-GN+uAmcI6DNspnCDwtOAZrTz6oukJnp337qZvxqCGLd3BHBzJpO0ZbTLRvJNdztOeAmTzewewGIMPb0tk2R4WA==", + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.1.4.tgz", + "integrity": "sha512-sI5yw+7s92SK6odiEhD5lKCBlWcpjHS5qyqpVQbZAJ0fIzEUXrmbl3DH2ybR3PZogulNJF+COLtmA8hUfvkCCQ==", "cpu": [ "x64" ], @@ -2022,13 +2193,13 @@ "freebsd" ], "engines": { - "node": ">= 20" + "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": { - "version": "4.3.2", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.3.2.tgz", - "integrity": "sha512-4ABn7qSbdHRwTiDiuWNegCyb5+2FJ4vKIKc3DmKrvAFw7MU1Lm11dIkTPwUaFdTzc7IsOpDbqBrlh0x6y36U/w==", + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.1.4.tgz", + "integrity": "sha512-mCi0OKgEieFircrtVYmQAFGszRtMnZ6fpZAXrxanXAu7lqZcsK1E1RAaZNG0uKAnxox3B1f4EyQNnoyMfN1vAA==", "cpu": [ "arm" ], @@ -2039,47 +2210,433 @@ "linux" ], "engines": { - "node": ">= 20" + "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/@tailwindcss/oxide-linux-arm64-gnu": { - "version": "4.3.2", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.3.2.tgz", - "integrity": "sha512-wDgEIGwoM8w8pufh9LVt1PahDgNdKXrLC2qfAnV3vAmococ9RWbxeAw4pxPttd/TsJfwjyLf90Dg1y9y8I6Emw==", + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.1.4.tgz", + "integrity": "sha512-B9Ial3Kv5sh0SHnB1g/QWcUQCEvCF6QKGAl4zXypYj65mVI+B4AhFBwPtSN7pDrJeIx8Z7zdy4ntx+wQABom7w==", "cpu": [ "arm64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ "linux" ], "engines": { - "node": ">= 20" + "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/@tailwindcss/oxide-linux-arm64-musl": { - "version": "4.3.2", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.3.2.tgz", - "integrity": "sha512-J5Nuk0uZQIiMTJj3LEx4sAA9tMFUoXQZFv1J6An+QGYe53HKRJuFDi0rpq/tuouCZeAbOBY3kQ6g8qeD4TUjtA==", + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.1.4.tgz", + "integrity": "sha512-lZVym0PuHE1KZ22gmFTC15lAkrg9iTszR617oYRB/iPY1A56ywoJzVKOJBKaot5RiikCObmur6pogpse3gRcng==", "cpu": [ "arm64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ "linux" ], "engines": { - "node": ">= 20" + "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/@tailwindcss/oxide-linux-x64-gnu": { - "version": "4.3.2", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.3.2.tgz", - "integrity": "sha512-kqCZpSKOBEJO4mz7OqWoofBZeXTAwaVGPj0ErAj7CojmhKpWVWVOnrt9dE8odoIraZq4oj3ausM37kXi+Tow8w==", + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.1.4.tgz", + "integrity": "sha512-t2DNiLJWNTbnEHyUzTumldML6ET4/g16467LZoDDJ3tSxGvguL5/NyC2lCsNKuyRycg9XeDQF5SSv+TNOhQEXg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.1.4.tgz", + "integrity": "sha512-0WIRnL1Uw4BvTZRLQt+PVgo6ZKTJadlC2btP+/EOXv2f/DWbY0rEgl+y834mIVwP1FkTlWVTrGGJXf12lru7EQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.1.4.tgz", + "integrity": "sha512-JWtGshGfX+oENAKonoNkqEJX+7hC8yfhi9GUyPX1VX4mdh1y5r+ZiJLR5XzAB0aoP6s/PcILsGjKq8O0mm24bw==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.1.4.tgz", + "integrity": "sha512-rT6yQcxUuXs4CnbofqwHRRV0iem349rLMYpTjkgQGLjrY4ado/eDzwPZPTCgTOlF6Nkp8NEv70yLMTn6qkWxsQ==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.1.4.tgz", + "integrity": "sha512-KXMGoboq5cyaCQjDA4GLuRiOwBQ0EyFnJoVViLeZ45/3rFItRODEr+NdsBcVpll40hhNArlm/speWGRvj08LzA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-wasm32-wasi": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.1.4.tgz", + "integrity": "sha512-5K83rb36oJiY7BCyE9zLZtGcPV4g5wvq+xwdO0XPIwDVZI8cyB/AUjkNXGb92/rnmezEkjMOpgY61rtwjQtFwg==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "1.11.1", + "@emnapi/runtime": "1.11.1", + "@napi-rs/wasm-runtime": "^1.1.6" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-wasm32-wasi/node_modules/@emnapi/core": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.1.tgz", + "integrity": "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.2", + "tslib": "^2.4.0" + } + }, + "node_modules/@rolldown/binding-wasm32-wasi/node_modules/@emnapi/wasi-threads": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz", + "integrity": "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.1.4.tgz", + "integrity": "sha512-PnWBtw3TV5KOg69HQQDR0mnQuyCmSGR2pAB4DC1rPF808fgKeTUMj2EOEyKATpgiuxuR5APQmiDO7PDgEjTFSA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.1.4.tgz", + "integrity": "sha512-M1lpniBePobTfsa7Ks9a199e1akxsXn+GYBUKsEzv3YFzOm1HJAMNwKI3qr0Zq+mxwx9gOZoTdP1yXRYsZUocQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rtsao/scc": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@rtsao/scc/-/scc-1.1.0.tgz", + "integrity": "sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g==", + "dev": true, + "license": "MIT" + }, + "node_modules/@sec-ant/readable-stream": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/@sec-ant/readable-stream/-/readable-stream-0.4.1.tgz", + "integrity": "sha512-831qok9r2t8AlxLko40y2ebgSDhenenCatLVeW/uBtnHPyhHOvG0C7TvfgecV+wHzIm5KUICgzmVpWS+IMEAeg==", + "license": "MIT" + }, + "node_modules/@sindresorhus/merge-streams": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@sindresorhus/merge-streams/-/merge-streams-4.0.0.tgz", + "integrity": "sha512-tlqY9xq5ukxTUZBmoOp+m61cqwQD5pHJtFY3Mn8CA8ps6yghLH/Hw8UPdqg4OLmFW3IFlcXnQNmo/dh8HzXYIQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@swc/helpers": { + "version": "0.5.15", + "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz", + "integrity": "sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.8.0" + } + }, + "node_modules/@tailwindcss/node": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.3.2.tgz", + "integrity": "sha512-yWP/sqEcBLaD8JuA6zNwxoYKr75qxTioYwlRwekj5Jr/I5GXnoJfjetH/psLUIv74cYTH2lBUEzBkinthoYcBg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/remapping": "^2.3.5", + "enhanced-resolve": "5.21.6", + "jiti": "^2.7.0", + "lightningcss": "1.32.0", + "magic-string": "^0.30.21", + "source-map-js": "^1.2.1", + "tailwindcss": "4.3.2" + } + }, + "node_modules/@tailwindcss/oxide": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.3.2.tgz", + "integrity": "sha512-z8ZgnzX8gdNoWLBLqBPoh/sjnxkwvf9ZuWjnO0l0yIzbLa5/9S+eC5QxGZKRobVHIC3/1BoMWjHblqWjcgFgag==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 20" + }, + "optionalDependencies": { + "@tailwindcss/oxide-android-arm64": "4.3.2", + "@tailwindcss/oxide-darwin-arm64": "4.3.2", + "@tailwindcss/oxide-darwin-x64": "4.3.2", + "@tailwindcss/oxide-freebsd-x64": "4.3.2", + "@tailwindcss/oxide-linux-arm-gnueabihf": "4.3.2", + "@tailwindcss/oxide-linux-arm64-gnu": "4.3.2", + "@tailwindcss/oxide-linux-arm64-musl": "4.3.2", + "@tailwindcss/oxide-linux-x64-gnu": "4.3.2", + "@tailwindcss/oxide-linux-x64-musl": "4.3.2", + "@tailwindcss/oxide-wasm32-wasi": "4.3.2", + "@tailwindcss/oxide-win32-arm64-msvc": "4.3.2", + "@tailwindcss/oxide-win32-x64-msvc": "4.3.2" + } + }, + "node_modules/@tailwindcss/oxide-android-arm64": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.3.2.tgz", + "integrity": "sha512-WHxqIuHpvZ5VtdX6GTl1Ik/Vp2YuN42Et+0CdeaVd/frQ9jAvGmvR8vLT+jk3e8/Q3x8kECB9+R17pgpp2BulA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-arm64": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.3.2.tgz", + "integrity": "sha512-GZypeUY/IDJW3877KeM+O67vbXr3MBnbtEL4aYhNErv/JWZhye2vGSWWG9tB6iiqR2MqRNkY8IOUy4NdSZV26w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-x64": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.3.2.tgz", + "integrity": "sha512-UIIzmefR6KO1sDU7MzRqAxC8iBpft/VhkGjTjnhoS6k7Z3rQ9wEgA1ODSiyH/tcSYssulNm4Ci3hOeK1jH7ccQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-freebsd-x64": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.3.2.tgz", + "integrity": "sha512-GN+uAmcI6DNspnCDwtOAZrTz6oukJnp337qZvxqCGLd3BHBzJpO0ZbTLRvJNdztOeAmTzewewGIMPb0tk2R4WA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.3.2.tgz", + "integrity": "sha512-4ABn7qSbdHRwTiDiuWNegCyb5+2FJ4vKIKc3DmKrvAFw7MU1Lm11dIkTPwUaFdTzc7IsOpDbqBrlh0x6y36U/w==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-gnu": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.3.2.tgz", + "integrity": "sha512-wDgEIGwoM8w8pufh9LVt1PahDgNdKXrLC2qfAnV3vAmococ9RWbxeAw4pxPttd/TsJfwjyLf90Dg1y9y8I6Emw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-musl": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.3.2.tgz", + "integrity": "sha512-J5Nuk0uZQIiMTJj3LEx4sAA9tMFUoXQZFv1J6An+QGYe53HKRJuFDi0rpq/tuouCZeAbOBY3kQ6g8qeD4TUjtA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-gnu": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.3.2.tgz", + "integrity": "sha512-kqCZpSKOBEJO4mz7OqWoofBZeXTAwaVGPj0ErAj7CojmhKpWVWVOnrt9dE8odoIraZq4oj3ausM37kXi+Tow8w==", "cpu": [ "x64" ], @@ -2188,6 +2745,107 @@ "tailwindcss": "4.3.2" } }, + "node_modules/@testing-library/dom": { + "version": "10.4.1", + "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz", + "integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/code-frame": "^7.10.4", + "@babel/runtime": "^7.12.5", + "@types/aria-query": "^5.0.1", + "aria-query": "5.3.0", + "dom-accessibility-api": "^0.5.9", + "lz-string": "^1.5.0", + "picocolors": "1.1.1", + "pretty-format": "^27.0.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@testing-library/dom/node_modules/aria-query": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz", + "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==", + "dev": true, + "license": "Apache-2.0", + "peer": true, + "dependencies": { + "dequal": "^2.0.3" + } + }, + "node_modules/@testing-library/jest-dom": { + "version": "6.9.1", + "resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-6.9.1.tgz", + "integrity": "sha512-zIcONa+hVtVSSep9UT3jZ5rizo2BsxgyDYU7WFD5eICBE7no3881HGeb/QkGfsJs6JTkY1aQhT7rIPC7e+0nnA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@adobe/css-tools": "^4.4.0", + "aria-query": "^5.0.0", + "css.escape": "^1.5.1", + "dom-accessibility-api": "^0.6.3", + "picocolors": "^1.1.1", + "redent": "^3.0.0" + }, + "engines": { + "node": ">=14", + "npm": ">=6", + "yarn": ">=1" + } + }, + "node_modules/@testing-library/jest-dom/node_modules/dom-accessibility-api": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.6.3.tgz", + "integrity": "sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@testing-library/react": { + "version": "16.3.2", + "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-16.3.2.tgz", + "integrity": "sha512-XU5/SytQM+ykqMnAnvB2umaJNIOsLF3PVv//1Ew4CTcpz0/BRyy/af40qqrt7SjKpDdT1saBMc42CUok5gaw+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.12.5" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@testing-library/dom": "^10.0.0", + "@types/react": "^18.0.0 || ^19.0.0", + "@types/react-dom": "^18.0.0 || ^19.0.0", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@testing-library/user-event": { + "version": "14.6.1", + "resolved": "https://registry.npmjs.org/@testing-library/user-event/-/user-event-14.6.1.tgz", + "integrity": "sha512-vq7fv0rnt+QTXgPxr5Hjc210p6YKq2kmdziLgnsZGgLJ9e6VAShx1pACLuRjd/AS/sr7phAR58OIIpf0LlmQNw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12", + "npm": ">=6" + }, + "peerDependencies": { + "@testing-library/dom": ">=7.21.4" + } + }, "node_modules/@ts-morph/common": { "version": "0.27.0", "resolved": "https://registry.npmjs.org/@ts-morph/common/-/common-0.27.0.tgz", @@ -2274,6 +2932,32 @@ "tslib": "^2.4.0" } }, + "node_modules/@types/aria-query": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", + "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/estree": { "version": "1.0.9", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", @@ -2950,6 +3634,119 @@ "win32" ] }, + "node_modules/@vitest/expect": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.10.tgz", + "integrity": "sha512-YsCn+qAk1GWjQOWFEsEcL2gNQ0zmVmQu3T03qP6UyjhtmdtwtbuI+DASn/7iQB3HGTXkdBwGddzxPlmiql5vlA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.1.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.1.10", + "@vitest/utils": "4.1.10", + "chai": "^6.2.2", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.10.tgz", + "integrity": "sha512-v0xaezt+DKEmKfaxg133ldzADrwLGd7Ze1MfQQTYfvs8OqZIwbxyxaYURivwV7sWy5fqn3rH5uOrSp07bp44Ow==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "4.1.10", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.21" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.10.tgz", + "integrity": "sha512-W1HsjSH4MXQ9YfmmhLAoIYf1HRfekQCGngeIgcei6MP5QQGWUe0gkopdZQaVCFO+JDJMrAJGwa5pRpNpvy4P8Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.10.tgz", + "integrity": "sha512-IKI6kpIH+LmpROplyLwBBaCfMgOZOMsygVa6BARD6ahA04VRuJSa6OaVG7kRvSEMD870Vd91rSSw0eegtWyLGg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "4.1.10", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.10.tgz", + "integrity": "sha512-xRkfOT1qpTAi/Ti4Y1LtfRc3kEuqxGw59eN2jN9pRWMtS/XDevekhcFSqvQqjUNGksfjMJu3Y+oJ+4Ypn2OaJw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.10", + "@vitest/utils": "4.1.10", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.10.tgz", + "integrity": "sha512-PLf/Ugvoq5wO/b4rwYCR1h2PSIdXz7wnkQFMiUpLdtM7l6pqVFcQIBEHyT1+l+cj7mNwAfZHzqXqDyjvOuwbDw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.10.tgz", + "integrity": "sha512-fy9am/HWxbaGt/Sawrp90vt6Y6jQwf1RX77cz3uwoJwJVMli/e1IEwRPnMNJ7vKfPTwo0diXifkpPvwH9v7nGA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.10", + "convert-source-map": "^2.0.0", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, "node_modules/accepts": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", @@ -3252,6 +4049,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, "node_modules/ast-types": { "version": "0.16.1", "resolved": "https://registry.npmjs.org/ast-types/-/ast-types-0.16.1.tgz", @@ -3345,6 +4152,16 @@ "node": ">=6.0.0" } }, + "node_modules/bidi-js": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz", + "integrity": "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==", + "dev": true, + "license": "MIT", + "dependencies": { + "require-from-string": "^2.0.2" + } + }, "node_modules/body-parser": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.3.0.tgz", @@ -3539,6 +4356,16 @@ ], "license": "CC-BY-4.0" }, + "node_modules/chai": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/chalk": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", @@ -3836,6 +4663,27 @@ "node": ">= 8" } }, + "node_modules/css-tree": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz", + "integrity": "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==", + "dev": true, + "license": "MIT", + "dependencies": { + "mdn-data": "2.27.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0" + } + }, + "node_modules/css.escape": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/css.escape/-/css.escape-1.5.1.tgz", + "integrity": "sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==", + "dev": true, + "license": "MIT" + }, "node_modules/cssesc": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", @@ -3862,6 +4710,20 @@ "dev": true, "license": "BSD-2-Clause" }, + "node_modules/data-urls": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz", + "integrity": "sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^16.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, "node_modules/data-view-buffer": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/data-view-buffer/-/data-view-buffer-1.0.2.tgz", @@ -3958,6 +4820,13 @@ } } }, + "node_modules/decimal.js": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", + "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", + "dev": true, + "license": "MIT" + }, "node_modules/dedent": { "version": "1.7.2", "resolved": "https://registry.npmjs.org/dedent/-/dedent-1.7.2.tgz", @@ -4073,6 +4942,17 @@ "node": ">= 0.8" } }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=6" + } + }, "node_modules/detect-libc": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", @@ -4105,6 +4985,14 @@ "node": ">=0.10.0" } }, + "node_modules/dom-accessibility-api": { + "version": "0.5.16", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", + "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", + "dev": true, + "license": "MIT", + "peer": true + }, "node_modules/dot-prop": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/dot-prop/-/dot-prop-6.0.1.tgz", @@ -4201,6 +5089,19 @@ "node": ">=8.6" } }, + "node_modules/entities": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-8.0.0.tgz", + "integrity": "sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, "node_modules/env-paths": { "version": "2.2.1", "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz", @@ -4353,6 +5254,13 @@ "node": ">= 0.4" } }, + "node_modules/es-module-lexer": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.0.tgz", + "integrity": "sha512-KLdwQm2NvGLDkQDCGvmiQrhkd0JbMzXthwQAUgWjQuQdBLFa3eiBP5arXZyA+f8x+x7OXgud6bq2rxjGtHV2tw==", + "dev": true, + "license": "MIT" + }, "node_modules/es-object-atoms": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", @@ -4852,6 +5760,16 @@ "node": ">=4.0" } }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, "node_modules/esutils": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", @@ -4918,6 +5836,16 @@ "url": "https://github.com/sindresorhus/execa?sponsor=1" } }, + "node_modules/expect-type": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", + "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/express": { "version": "5.2.1", "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", @@ -5228,6 +6156,21 @@ "node": ">=14.14" } }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, "node_modules/function-bind": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", @@ -5583,6 +6526,19 @@ "node": ">=16.9.0" } }, + "node_modules/html-encoding-sniffer": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-6.0.0.tgz", + "integrity": "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.6.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, "node_modules/http-errors": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", @@ -5663,6 +6619,16 @@ "node": ">=0.8.19" } }, + "node_modules/indent-string": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz", + "integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/inherits": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", @@ -6069,6 +7035,13 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/is-potential-custom-element-name": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", + "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", + "dev": true, + "license": "MIT" + }, "node_modules/is-promise": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", @@ -6349,6 +7322,57 @@ "js-yaml": "bin/js-yaml.js" } }, + "node_modules/jsdom": { + "version": "29.1.1", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-29.1.1.tgz", + "integrity": "sha512-ECi4Fi2f7BdJtUKTflYRTiaMxIB0O6zfR1fX0GXpUrf6flp8QIYn1UT20YQqdSOfk2dfkCwS8LAFoJDEppNK5Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/css-color": "^5.1.11", + "@asamuzakjp/dom-selector": "^7.1.1", + "@bramus/specificity": "^2.4.2", + "@csstools/css-syntax-patches-for-csstree": "^1.1.3", + "@exodus/bytes": "^1.15.0", + "css-tree": "^3.2.1", + "data-urls": "^7.0.0", + "decimal.js": "^10.6.0", + "html-encoding-sniffer": "^6.0.0", + "is-potential-custom-element-name": "^1.0.1", + "lru-cache": "^11.3.5", + "parse5": "^8.0.1", + "saxes": "^6.0.0", + "symbol-tree": "^3.2.4", + "tough-cookie": "^6.0.1", + "undici": "^7.25.0", + "w3c-xmlserializer": "^5.0.0", + "webidl-conversions": "^8.0.1", + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^16.0.1", + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24.0.0" + }, + "peerDependencies": { + "canvas": "^3.0.0" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, + "node_modules/jsdom/node_modules/lru-cache": { + "version": "11.5.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.1.tgz", + "integrity": "sha512-RPimw/7aMdv2oqRrxKwvZXcPfwBrn/JZ2xYcY9Hus/6LaS3VOAKVWKWgNLCFSiOm1ESXinjsDlidVU7JlnCN2A==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, "node_modules/jsesc": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", @@ -6848,6 +7872,17 @@ "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, + "node_modules/lz-string": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", + "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", + "dev": true, + "license": "MIT", + "peer": true, + "bin": { + "lz-string": "bin/bin.js" + } + }, "node_modules/magic-string": { "version": "0.30.21", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", @@ -6867,6 +7902,13 @@ "node": ">= 0.4" } }, + "node_modules/mdn-data": { + "version": "2.27.1", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz", + "integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==", + "dev": true, + "license": "CC0-1.0" + }, "node_modules/media-typer": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz", @@ -6962,6 +8004,16 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/min-indent": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz", + "integrity": "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, "node_modules/minimatch": { "version": "3.1.5", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", @@ -7332,6 +8384,20 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/obug": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.3.tgz", + "integrity": "sha512-9miFgM2OFba7hB+pRgvtV84pYTBaoTHohvmIgiRt6dRIzbwEOIaNaP+dIlGs2fNFoB0SeISs0Jz5WFVRid6Xyg==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT", + "engines": { + "node": ">=12.20.0" + } + }, "node_modules/on-finished": { "version": "2.4.1", "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", @@ -7578,6 +8644,19 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/parse5": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.1.tgz", + "integrity": "sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "entities": "^8.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, "node_modules/parseurl": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", @@ -7629,6 +8708,13 @@ "url": "https://opencollective.com/express" } }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -7812,6 +8898,44 @@ "node": ">= 0.8.0" } }, + "node_modules/pretty-format": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz", + "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "ansi-regex": "^5.0.1", + "ansi-styles": "^5.0.0", + "react-is": "^17.0.1" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/pretty-format/node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/pretty-format/node_modules/react-is": { + "version": "17.0.2", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", + "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", + "dev": true, + "license": "MIT", + "peer": true + }, "node_modules/pretty-ms": { "version": "9.3.0", "resolved": "https://registry.npmjs.org/pretty-ms/-/pretty-ms-9.3.0.tgz", @@ -7982,14 +9106,28 @@ "integrity": "sha512-dEWRjcINDu/F4l2dYx57ugBtD7HV9KXESyxhzw/MqWLeglJrsjJKqACPyUPg+6AF8mIgm+Zi0dZ3ACoIg+QtpA==", "license": "MIT", "dependencies": { - "ast-types": "^0.16.1", - "esprima": "~4.0.0", - "source-map": "~0.6.1", - "tiny-invariant": "^1.3.3", - "tslib": "^2.0.1" + "ast-types": "^0.16.1", + "esprima": "~4.0.0", + "source-map": "~0.6.1", + "tiny-invariant": "^1.3.3", + "tslib": "^2.0.1" + }, + "engines": { + "node": ">= 4" + } + }, + "node_modules/redent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz", + "integrity": "sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==", + "dev": true, + "license": "MIT", + "dependencies": { + "indent-string": "^4.0.0", + "strip-indent": "^3.0.0" }, "engines": { - "node": ">= 4" + "node": ">=8" } }, "node_modules/reflect.getprototypeof": { @@ -8135,6 +9273,40 @@ "node": ">=0.10.0" } }, + "node_modules/rolldown": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.1.4.tgz", + "integrity": "sha512-IjZYiLxZwpnhwhdBH2ugdTGVSdhCQUmLxLoqyjiL0JxYjyRst+5a0P3xfrTxJ5F638j4Mvvw5FAX5XE6eHpXbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.138.0", + "@rolldown/pluginutils": "^1.0.0" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm64": "1.1.4", + "@rolldown/binding-darwin-arm64": "1.1.4", + "@rolldown/binding-darwin-x64": "1.1.4", + "@rolldown/binding-freebsd-x64": "1.1.4", + "@rolldown/binding-linux-arm-gnueabihf": "1.1.4", + "@rolldown/binding-linux-arm64-gnu": "1.1.4", + "@rolldown/binding-linux-arm64-musl": "1.1.4", + "@rolldown/binding-linux-ppc64-gnu": "1.1.4", + "@rolldown/binding-linux-s390x-gnu": "1.1.4", + "@rolldown/binding-linux-x64-gnu": "1.1.4", + "@rolldown/binding-linux-x64-musl": "1.1.4", + "@rolldown/binding-openharmony-arm64": "1.1.4", + "@rolldown/binding-wasm32-wasi": "1.1.4", + "@rolldown/binding-win32-arm64-msvc": "1.1.4", + "@rolldown/binding-win32-x64-msvc": "1.1.4" + } + }, "node_modules/router": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", @@ -8247,6 +9419,19 @@ "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", "license": "MIT" }, + "node_modules/saxes": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", + "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", + "dev": true, + "license": "ISC", + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=v12.22.7" + } + }, "node_modules/scheduler": { "version": "0.27.0", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", @@ -8610,6 +9795,13 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, "node_modules/signal-exit": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", @@ -8628,6 +9820,16 @@ "integrity": "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==", "license": "MIT" }, + "node_modules/sonner": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/sonner/-/sonner-2.0.7.tgz", + "integrity": "sha512-W6ZN4p58k8aDKA4XPcx2hpIQXBRAgyiWVkYhT7CvK6D3iAu7xjvVyhQHg2/iaKJZ1XVJ4r7XuwGL+WGEK37i9w==", + "license": "MIT", + "peerDependencies": { + "react": "^18.0.0 || ^19.0.0 || ^19.0.0-rc", + "react-dom": "^18.0.0 || ^19.0.0 || ^19.0.0-rc" + } + }, "node_modules/source-map": { "version": "0.6.1", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", @@ -8653,6 +9855,13 @@ "dev": true, "license": "MIT" }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, "node_modules/statuses": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", @@ -8662,6 +9871,13 @@ "node": ">= 0.8" } }, + "node_modules/std-env": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.1.0.tgz", + "integrity": "sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==", + "dev": true, + "license": "MIT" + }, "node_modules/stdin-discarder": { "version": "0.2.2", "resolved": "https://registry.npmjs.org/stdin-discarder/-/stdin-discarder-0.2.2.tgz", @@ -8914,6 +10130,19 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/strip-indent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-3.0.0.tgz", + "integrity": "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "min-indent": "^1.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/strip-json-comments": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", @@ -8976,6 +10205,13 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/symbol-tree": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", + "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", + "dev": true, + "license": "MIT" + }, "node_modules/systeminformation": { "version": "5.31.11", "resolved": "https://registry.npmjs.org/systeminformation/-/systeminformation-5.31.11.tgz", @@ -9039,6 +10275,23 @@ "integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==", "license": "MIT" }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.2.4.tgz", + "integrity": "sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/tinyglobby": { "version": "0.2.17", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", @@ -9087,6 +10340,36 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/tinyrainbow": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz", + "integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tldts": { + "version": "7.4.6", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.4.6.tgz", + "integrity": "sha512-rbP0Gyx8b3Ae9yO//CU2wbSnQNoQ66m1nJdSbSHmnwKwzkkz/u8mERYU8T2rmlmy+bJvRNn84yNCW8gYqox44Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "tldts-core": "^7.4.6" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/tldts-core": { + "version": "7.4.6", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.4.6.tgz", + "integrity": "sha512-TkQNGJIhlEphpHCjKodMTSe23egUZr/g+flI2qkLgiJ/maAzSgXypSLRTNH3nCmqgayEmtcJBiLcfODSAr1xoA==", + "dev": true, + "license": "MIT" + }, "node_modules/to-regex-range": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", @@ -9108,6 +10391,32 @@ "node": ">=0.6" } }, + "node_modules/tough-cookie": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.1.tgz", + "integrity": "sha512-LktZQb3IeoUWB9lqR5EWTHgW/VTITCXg4D21M+lvybRVdylLrRMnqaIONLVb5mav8vM19m44HIcGq4qASeu2Qw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "tldts": "^7.0.5" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/tr46": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-6.0.0.tgz", + "integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/ts-api-utils": { "version": "2.5.0", "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz", @@ -9508,6 +10817,248 @@ "node": ">= 0.8" } }, + "node_modules/vite": { + "version": "8.1.3", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.1.3.tgz", + "integrity": "sha512-Ds+gBRbj0lwRO2Y5hwnUBdxSwlAve9LeRyU4sNnAr0ewW0gWF0n5bgXgUzbgZ49MV9BVUAQUFYVcDUcilUExMA==", + "dev": true, + "license": "MIT", + "dependencies": { + "lightningcss": "^1.32.0", + "picomatch": "^4.0.4", + "postcss": "^8.5.16", + "rolldown": "~1.1.3", + "tinyglobby": "^0.2.17" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.3.0", + "esbuild": "^0.27.0 || ^0.28.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vite/node_modules/picomatch": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", + "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/vitest": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.10.tgz", + "integrity": "sha512-R9jUTe5S4Qb0HCd4TNqpC7oGcrMssMRGXLW80ubjWsW9VH5GF8y1Y0SFLY9AbqSk6nt0PnOx4H4WNJYZ13GUPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "4.1.10", + "@vitest/mocker": "4.1.10", + "@vitest/pretty-format": "4.1.10", + "@vitest/runner": "4.1.10", + "@vitest/snapshot": "4.1.10", + "@vitest/spy": "4.1.10", + "@vitest/utils": "4.1.10", + "es-module-lexer": "^2.0.0", + "expect-type": "^1.3.0", + "magic-string": "^0.30.21", + "obug": "^2.1.1", + "pathe": "^2.0.3", + "picomatch": "^4.0.3", + "std-env": "^4.0.0-rc.1", + "tinybench": "^2.9.0", + "tinyexec": "^1.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.1.0", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "4.1.10", + "@vitest/browser-preview": "4.1.10", + "@vitest/browser-webdriverio": "4.1.10", + "@vitest/coverage-istanbul": "4.1.10", + "@vitest/coverage-v8": "4.1.10", + "@vitest/ui": "4.1.10", + "happy-dom": "*", + "jsdom": "*", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/coverage-istanbul": { + "optional": true + }, + "@vitest/coverage-v8": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + }, + "vite": { + "optional": false + } + } + }, + "node_modules/vitest/node_modules/picomatch": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", + "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/w3c-xmlserializer": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", + "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/webidl-conversions": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz", + "integrity": "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20" + } + }, + "node_modules/whatwg-mimetype": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz", + "integrity": "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + } + }, + "node_modules/whatwg-url": { + "version": "16.0.1", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-16.0.1.tgz", + "integrity": "sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.11.0", + "tr46": "^6.0.0", + "webidl-conversions": "^8.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", @@ -9612,6 +11163,23 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/word-wrap": { "version": "1.2.5", "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", @@ -9644,6 +11212,23 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/xml-name-validator": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", + "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/xmlchars": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", + "dev": true, + "license": "MIT" + }, "node_modules/yallist": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", diff --git a/frontend/package.json b/frontend/package.json index 45a83f5..d95b889 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -6,7 +6,8 @@ "dev": "next dev", "build": "next build", "start": "next start", - "lint": "eslint" + "lint": "eslint", + "test": "vitest run" }, "dependencies": { "@base-ui/react": "^1.6.0", @@ -25,17 +26,23 @@ "react": "19.2.4", "react-dom": "19.2.4", "shadcn": "^4.12.0", + "sonner": "^2.0.7", "tailwind-merge": "^3.6.0", "tw-animate-css": "^1.4.0" }, "devDependencies": { "@tailwindcss/postcss": "^4", + "@testing-library/jest-dom": "^6.9.1", + "@testing-library/react": "^16.3.2", + "@testing-library/user-event": "^14.6.1", "@types/node": "^20", "@types/react": "^19", "@types/react-dom": "^19", "eslint": "^9", "eslint-config-next": "16.2.10", + "jsdom": "^29.1.1", "tailwindcss": "^4", - "typescript": "^5" + "typescript": "^5", + "vitest": "^4.1.10" } } diff --git a/frontend/vitest.config.ts b/frontend/vitest.config.ts new file mode 100644 index 0000000..96bf6c7 --- /dev/null +++ b/frontend/vitest.config.ts @@ -0,0 +1,16 @@ +import { defineConfig } from "vitest/config"; +import path from "node:path"; + +export default defineConfig({ + test: { + globals: true, + environment: "jsdom", + setupFiles: ["./vitest.setup.ts"], + restoreMocks: true, + }, + resolve: { + alias: { + "@": path.resolve(__dirname, "."), + }, + }, +}); diff --git a/frontend/vitest.setup.ts b/frontend/vitest.setup.ts new file mode 100644 index 0000000..7e942c4 --- /dev/null +++ b/frontend/vitest.setup.ts @@ -0,0 +1,18 @@ +import "@testing-library/jest-dom/vitest"; + +if (!globalThis.PointerEvent) { + globalThis.PointerEvent = MouseEvent as typeof PointerEvent; +} + +if (!window.matchMedia) { + window.matchMedia = (query: string) => ({ + matches: query.includes("prefers-reduced-motion"), + media: query, + onchange: null, + addEventListener: () => undefined, + removeEventListener: () => undefined, + addListener: () => undefined, + removeListener: () => undefined, + dispatchEvent: () => false, + }); +} diff --git a/supabase/migrations/20260706120000_event_details.sql b/supabase/migrations/20260706120000_event_details.sql new file mode 100644 index 0000000..4d88fc1 --- /dev/null +++ b/supabase/migrations/20260706120000_event_details.sql @@ -0,0 +1,31 @@ +ALTER TABLE public.events + ADD COLUMN IF NOT EXISTS description TEXT NOT NULL DEFAULT '', + ADD COLUMN IF NOT EXISTS location_url TEXT DEFAULT NULL, + ADD COLUMN IF NOT EXISTS priority TEXT NOT NULL DEFAULT 'normal', + ADD COLUMN IF NOT EXISTS tags TEXT[] NOT NULL DEFAULT '{}', + ADD COLUMN IF NOT EXISTS reminder_minutes INTEGER DEFAULT NULL; + +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM pg_constraint WHERE conname = 'events_priority_check' + ) THEN + ALTER TABLE public.events + ADD CONSTRAINT events_priority_check + CHECK (priority IN ('low', 'normal', 'important')); + END IF; + + IF NOT EXISTS ( + SELECT 1 FROM pg_constraint WHERE conname = 'events_reminder_minutes_check' + ) THEN + ALTER TABLE public.events + ADD CONSTRAINT events_reminder_minutes_check + CHECK ( + reminder_minutes IS NULL + OR (reminder_minutes >= 0 AND reminder_minutes <= 10080) + ); + END IF; +END +$$; + +CREATE INDEX IF NOT EXISTS idx_events_tags ON public.events USING GIN (tags);