Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
39 commits
Select commit Hold shift + click to select a range
6c29869
fix: harden calendar API requests
codex Jul 6, 2026
021877e
feat: add rich calendar event details
codex Jul 6, 2026
45a8a33
feat: add native calendar AI tools
codex Jul 6, 2026
03f6135
feat: add calendar AI chat and reminders
codex Jul 6, 2026
e795be1
fix: restore event dialog desktop positioning
codex Jul 6, 2026
455205c
fix: harden reminders and assistant updates
codex Jul 6, 2026
db171d3
feat: support AI calendar update previews
codex Jul 6, 2026
37dd12c
feat: expand AI update field parsing
codex Jul 6, 2026
10cf38c
chore: clean frontend lint warnings
codex Jul 6, 2026
c2c55a0
fix: harden stored calendar preferences
codex Jul 6, 2026
1ea86e1
fix: clear stale access tokens
codex Jul 6, 2026
4b62e5d
fix: validate assistant native tool inputs
codex Jul 6, 2026
b2fb7d9
fix: validate event query dates
codex Jul 6, 2026
2cdab02
fix: validate stored user preferences
codex Jul 6, 2026
576177e
fix: validate custom event templates
codex Jul 6, 2026
d86188a
fix: normalize meeting links
codex Jul 6, 2026
4755a27
fix: validate analytics date query
codex Jul 6, 2026
cb2cd1d
fix: remove deprecated utcnow from ics export
codex Jul 6, 2026
194586c
fix: refresh auth before calendar export
codex Jul 6, 2026
c67a35f
fix: reject blank event titles
codex Jul 6, 2026
c83fa0a
fix: add timeout to auth refresh
codex Jul 6, 2026
e64b741
fix: reject blank assistant text
codex Jul 6, 2026
9e912ae
fix: harden frontend runtime compatibility
freddy-tama Jul 6, 2026
8fea21c
fix: disable invalid event saves
freddy-tama Jul 6, 2026
01b2567
fix: harden frontend edge cases
freddy-tama Jul 6, 2026
4b4cb5b
fix: harden assistant calendar edge cases
freddy-tama Jul 6, 2026
1b5a548
fix: handle overnight calendar edge cases
freddy-tama Jul 6, 2026
f13c5e2
fix: align calendar edge-case persistence
freddy-tama Jul 6, 2026
60d6d35
fix: preserve overnight events in date ranges
freddy-tama Jul 6, 2026
2887ce9
fix: improve assistant event matching
freddy-tama Jul 6, 2026
42cb1c0
fix: harden auth session recovery
freddy-tama Jul 6, 2026
e7c9568
fix: harden calendar interactions and reminders
freddy-tama Jul 6, 2026
c82858f
fix: harden natural language time parsing
freddy-tama Jul 6, 2026
517e602
fix: align AI provider config and recurrence tools
freddy-tama Jul 6, 2026
b2cb2b7
fix: reuse authenticated export on profile
freddy-tama Jul 6, 2026
9af4874
fix: preserve calendar recurrence interoperability
freddy-tama Jul 6, 2026
72c5524
fix: keep legacy calendar data visible
freddy-tama Jul 6, 2026
1561a53
fix: normalize legacy stored event metadata
freddy-tama Jul 6, 2026
0090411
fix: preserve recurring event identity
freddy-tama Jul 6, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions LOOP.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
19 changes: 17 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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"*
Expand All @@ -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

Expand Down Expand Up @@ -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 |
Expand Down Expand Up @@ -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
Expand Down
5 changes: 5 additions & 0 deletions backend/app/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 = ""
Expand All @@ -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()

Expand Down
108 changes: 108 additions & 0 deletions backend/app/core/assistant_tools.py
Original file line number Diff line number Diff line change
@@ -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}")
51 changes: 25 additions & 26 deletions backend/app/core/conflicts.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""
Expand All @@ -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

Expand Down Expand Up @@ -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
Expand All @@ -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] = []
Expand All @@ -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
Expand Down
54 changes: 51 additions & 3 deletions backend/app/core/ics_export.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)."""
Expand All @@ -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",
Expand All @@ -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")
Expand All @@ -63,15 +101,25 @@ 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}")
lines.append(f"DTSTAMP:{now_stamp}")
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")
Expand Down
Loading