πŸ“„ calendar-button-ux-spec.md 28,671 bytes Apr 28, 2026 πŸ“‹ Raw

πŸ“… "Add to Calendar" Button β€” UX Specification

Author: Daedalus 🎨
Director: Matt
Date: 2026-04-28
Status: Ready for Socrates implementation
Depends on: Icarus Telegram bot, calendar_sync.py, appointment_parser.py


1. Context & Problem

When Icarus processes a document (photo, PDF, email) and detects an appointment or event, the briefing card currently just tells the user about it. There's no way to act on that information from within Telegram.

The ask: Add an "Add to Calendar" button to briefing cards that contain appointment/event data, with a confirmation flow that shows exactly what was added and offers undo.


2. Trigger Conditions

The calendar button row appears on a briefing message only when the pipeline extracts appointment-eligible data:

Condition Show Button?
category is "appointment" or "event" βœ… Yes
key_details contains date or start field βœ… Yes
suggested_actions contains "Add to calendar" or calendar-adjacent verb βœ… Yes
category is "info" with no date ❌ No
category is "reminder" with no date ❌ No

Logic: If the LLM produces a parsed appointment (via appointment_parser.py) with a valid start datetime, the button row is rendered. If no appointment can be constructed (missing date, ambiguous time), the button row is omitted and the briefing falls back to text-only.


3. Briefing Card Layout (Updated)

Before (text-only):

πŸ“‹ *Dental Cleaning*

Sullivan's dental cleaning appointment at West Side Dental.

*Key Details:*
β€’ Date: May 3, 2026
β€’ Time: 2:00 PM
β€’ Location: West Side Dental

*Confidence: 95%*

After (with action buttons):

πŸ“‹ *Dental Cleaning*

Sullivan's dental cleaning appointment at West Side Dental.

*Key Details:*
β€’ Date: May 3, 2026
β€’ Time: 2:00 PM
β€’ Location: West Side Dental

*Confidence: 95%*

[πŸ—“οΈ Add to Calendar]  [✏️ Edit Details]

Layout Rules

Element Specification
Button row Single row, two buttons
Primary button πŸ—“οΈ Add to Calendar β€” leftmost, always present when triggered
Secondary button ✏️ Edit Details β€” rightmost, present when editable fields exist
Message format MarkdownV2 (consistent with recipe toggle)
Keyboard type InlineKeyboardMarkup
Maximum buttons 2 per row (Telegram mobile UX)

4. Button States & Callback Data

Callback Data Format

calendar:add:{briefing_id}     β†’ Add extracted appointment to Google Calendar
calendar:edit:{briefing_id}    β†’ Open edit flow (future phase)
calendar:delete:{event_id}     β†’ Remove event from calendar (undo)
calendar:done:{briefing_id}    β†’ Dismiss confirmation card

Where {briefing_id} is a unique ID generated from the briefing (hash of summary + date), and {event_id} is the Google Calendar event ID returned after insertion.

State Machine

β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚                    INITIAL STATE                         β”‚
β”‚                                                         β”‚
β”‚  Briefing card with inline keyboard:                    β”‚
β”‚  [πŸ—“οΈ Add to Calendar]  [✏️ Edit Details]              β”‚
β”‚                                                         β”‚
β”‚  User taps "Add to Calendar"                            β”‚
β”‚         β”‚                                               β”‚
β”‚         β–Ό                                               β”‚
β”‚  LOADING STATE                                          β”‚
β”‚  Message edits to:                                      β”‚
β”‚  "⏳ Adding to calendar..."                             β”‚
β”‚  Keyboard: [⏳ Adding...] (single disabled-style btn)   β”‚
β”‚                                                         β”‚
β”‚         β”‚                                               β”‚
β”‚         β”œβ”€β”€ success ──→ SUCCESS STATE                   β”‚
β”‚         └── error ────→ ERROR STATE                     β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

5. Confirmation Card (Success State)

After the event is successfully added to Google Calendar, the message is edited in place (same chat message, same message_id) to show:

βœ… *Added to Calendar*

_Dental Cleaning_
May 3, 2026 at 2:00 PM
West Side Dental

[✏️ Edit Event]  [πŸ—‘οΈ Delete]

Specification

Element Format
Header βœ… *Added to Calendar*
Event title _Title_ (italic in MarkdownV2)
Date/time Single line: May 3, 2026 at 2:00 PM
Location Below date (omitted if empty)
Button row 1 [✏️ Edit Event] [πŸ—‘οΈ Delete]
No "Done" button Telegram inline keyboards auto-dismiss after timeout or user scrolls away

Design rationale: Showing the exact event details (not just "Success!") builds trust. The user can verify at a glance that the right date, time, and location were captured. The Delete button provides immediate undo β€” no need to open Google Calendar.

Message Text Template (MarkdownV2)

βœ… *Added to Calendar*

_{title}*
{date\_formatted} at {time\_formatted}
{location\_line}

_Confidence: {confidence}%_

Where:
- {title} = appointment summary (escaped for MarkdownV2)
- {date_formatted} = human-readable date (e.g., "May 3, 2026")
- {time_formatted} = human-readable time (e.g., "2:00 PM")
- {location_line} = location if present, empty line otherwise
- {confidence} = extraction confidence percentage

Location line: Only rendered if location is non-empty:

πŸ“ {location}     ← with pin emoji if location exists

Inline Keyboard (Success State)

keyboard = {
    "inline_keyboard": [
        [
            {"text": "✏️ Edit Event", "callback_data": f"calendar:edit:{event_id}"},
            {"text": "πŸ—‘οΈ Delete", "callback_data": f"calendar:delete:{event_id}"},
        ]
    ]
}

6. Delete Confirmation (Undo Flow)

When the user taps πŸ—‘οΈ Delete:

Step 1: Confirm intent

The message edits to show a deletion confirmation:

⚠️ *Delete Event?*

_Dental Cleaning_
May 3, 2026 at 2:00 PM
West Side Dental

This will remove the event from your calendar.

[βœ… Yes, Delete]  [❌ Cancel]

Step 2: On confirm

Message edits to:

πŸ—‘οΈ *Event Deleted*

_Dental Cleaning_ has been removed from the calendar.

(No inline keyboard β€” final state.)

Step 2: On cancel

Message reverts to the Success State card (Section 5).

Callback Data

calendar:confirm_delete:{event_id}     β†’ Confirm deletion
calendar:cancel_delete:{briefing_id}   β†’ Cancel, revert to success state

7. Error State

If calendar insertion fails (API error, auth issue, network timeout):

❌ *Could Not Add to Calendar*

_Dental Cleaning_
May 3, 2026 at 2:00 PM

Error: {user_friendly_error_message}

[πŸ”„ Retry]  [πŸ“‹ Copy Details]
Element Specification
Header ❌ *Could Not Add to Calendar*
Event details Title, date, time (so user can manually add)
Error message User-friendly, NOT a stack trace
Retry button πŸ”„ Retry β†’ re-attempts the same calendar insertion
Copy button πŸ“‹ Copy Details β†’ sends the event details as plain text in a new message so user can paste into Google Calendar manually

Error Categories & Messages

Error User Message
Google API 401/403 "Calendar permission denied. Check that the service account has edit access."
Google API 409 (conflict) "This event conflicts with an existing calendar event."
Google API 429 (rate limit) "Calendar is busy. Try again in a moment."
Network timeout "Couldn't reach Google Calendar. Check your connection."
Generic/unknown "Something went wrong adding this event."

Callback Data (Error State)

calendar:retry:{briefing_id}           β†’ Re-attempt calendar insertion
calendar:copy_details:{briefing_id}    β†’ Send event details as plain text message

8. Edit Flow (Future Phase β€” Stub)

The ✏️ Edit Event button on the confirmation card and ✏️ Edit Details on the initial card are stubs for a future phase. For now:

Initial card ✏️ Edit Details:
- Callback: calendar:edit:{briefing_id}
- Response: answerCallbackQuery("Edit flow coming soon!") with toast text
- No message edit β€” button just shows a temporary toast

Confirmation card ✏️ Edit Event:
- Same stub behavior

Future implementation will allow inline editing of:
- Date/time
- Title
- Location
- Duration

This is deliberately deferred. The primary action (Add to Calendar) is the MVP.


9. Loading State

When the user taps πŸ—“οΈ Add to Calendar, the message immediately edits to show a loading state while the backend processes:

⏳ *Adding to Calendar\.\.\.*

_Dental Cleaning_
May 3, 2026 at 2:00 PM
West Side Dental

Inline keyboard during loading:

keyboard = {
    "inline_keyboard": [
        [
            {"text": "⏳ Adding...", "callback_data": "calendar:noop"}
        ]
    ]
}

Design rationale:
- Immediate visual feedback that something is happening
- The ⏳ Adding... button is non-functional (callback_data is noop) β€” prevents double-taps
- Message is edited in-place (same message_id) β€” no new message spam
- If the operation takes >5 seconds, the text remains as-is (no timeout message needed β€” the Telegram API call will either succeed or fail)

Transition: On success, the same message is edited again to the Success State. On error, edited to Error State. No intermediate "new" messages.


10. Data Flow (Sequence)

User sends document/photo to Icarus bot
    β”‚
    β–Ό
Vision pipeline processes document
    β”‚
    β–Ό
Briefing generated with extracted appointment data
    β”‚
    β”œβ”€β”€ Has appointment data? ──YES──► Build briefing + inline keyboard
    β”‚                                      β”‚
    β”‚                                      β–Ό
    β”‚                               Send message with:
    β”‚                               - MarkdownV2 briefing text
    β”‚                               - InlineKeyboardMarkup with calendar buttons
    β”‚
    └── No appointment data ──► Send text-only briefing (no keyboard)

User taps [πŸ—“οΈ Add to Calendar]
    β”‚
    β–Ό
Callback: calendar:add:{briefing_id}
    β”‚
    β–Ό
1. Edit message β†’ Loading state (⏳)
2. answerCallbackQuery() (no toast β€” visual edit is sufficient)
    β”‚
    β–Ό
3. Call calendar_sync.create_event() with extracted appointment data
    β”‚
    β”œβ”€β”€ SUCCESS ──► Edit message β†’ Success card with [✏️ Edit] [πŸ—‘οΈ Delete]
    β”‚                  answerCallbackQuery("βœ… Added!")
    β”‚
    └── ERROR ────► Edit message β†’ Error card with [πŸ”„ Retry] [πŸ“‹ Copy]
                     answerCallbackQuery("❌ Failed to add")

11. Appointment Data Extraction

The calendar button needs structured appointment data. The appointment_parser.py module already extracts this. The briefing card must carry this data alongside the rendered text.

Briefing Response Schema (Extended)

The /vision/briefing API response (and the internal briefing dict) must include:

{
    "title": "Dental Cleaning",
    "summary": "Sullivan's dental cleaning appointment...",
    "key_details": {
        "date": "2026-05-03",
        "time": "2:00 PM",
        "location": "West Side Dental",
        "contact": "(920) 555-0123",
    },
    "suggested_actions": ["Sign permission slip"],
    "confidence": 0.95,
    "category": "appointment",

    # NEW: Structured appointment data (null if not an appointment)
    "appointment": {
        "summary": "Dental Cleaning",
        "start": "2026-05-03T14:00:00-05:00",  # ISO 8601, Chicago timezone
        "end": "2026-05-03T14:30:00-05:00",      # ISO 8601, Chicago timezone
        "location": "West Side Dental",
        "description": "Sullivan's dental cleaning appointment. Source: photo sent via Telegram.",
        "who": ["sully"],
    },

    # NEW: Unique ID for callback data (deterministic hash)
    "briefing_id": "dental-cleaning-20260503-ab3f"
}

briefing_id Generation

import hashlib

def generate_briefing_id(summary: str, start_iso: str) -> str:
    """Generate a deterministic, URL-safe briefing ID."""
    raw = f"{summary}|{start_iso}".lower().strip()
    hash_part = hashlib.sha256(raw.encode()).hexdigest()[:6]
    slug = summary.lower().replace(" ", "-")[:20]
    return f"{slug}-{hash_part}"

This ensures:
- Same document β†’ same briefing_id (idempotent)
- Different appointments β†’ different IDs (no collision)
- URL-safe for callback_data (max 64 bytes per Telegram spec)

What Socrates Needs to Implement

  1. Extend the briefing pipeline to extract structured appointment data alongside the text briefing when category == "appointment" or "event".
  2. Add briefing_id to the briefing response.
  3. Create a temporary state store (SQLite, like recipe_toggle) to map briefing_id β†’ appointment data, so the callback handler can reconstruct the event for calendar insertion.
  4. Implement callback handlers for calendar:add, calendar:delete, calendar:confirm_delete, calendar:cancel_delete, calendar:retry, calendar:noop, and calendar:copy_details.
  5. Wire up the Telegram handler to render inline keyboards when appointment data is present.

12. SQLite Schema (Calendar State)

CREATE TABLE calendar_pending (
    briefing_id TEXT PRIMARY KEY,
    chat_id INTEGER NOT NULL,
    message_id INTEGER NOT NULL,
    summary TEXT NOT NULL,
    start_iso TEXT NOT NULL,        -- ISO 8601 datetime (Chicago timezone)
    end_iso TEXT NOT NULL,           -- ISO 8601 datetime (Chicago timezone)
    location TEXT DEFAULT '',
    description TEXT DEFAULT '',
    who TEXT DEFAULT '[]',           -- JSON array of family member IDs
    source TEXT DEFAULT '',           -- "photo", "pdf", "email"
    confidence REAL DEFAULT 0.0,
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);

CREATE TABLE calendar_events (
    event_id TEXT PRIMARY KEY,        -- Google Calendar event ID
    briefing_id TEXT NOT NULL,
    chat_id INTEGER NOT NULL,
    message_id INTEGER NOT NULL,      -- The confirmation message ID
    summary TEXT NOT NULL,
    start_iso TEXT NOT NULL,
    added_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
  • calendar_pending: Stored when briefing is sent, used by calendar:add callback to create the event.
  • calendar_events: Stored after successful insertion, used by calendar:delete callback to find and delete the event.
  • TTL: calendar_pending rows expire after 24 hours (stale briefings). calendar_events rows persist for undo capability.

13. Callback Handler Routing

Extend the existing process_update callback routing in handler.py:

# Current routing
if cb_data.startswith("toggle:"):
    await handle_toggle(callback, bot)
elif cb_data.startswith("commit:"):
    await handle_commit(callback, bot)
elif cb_data.startswith("cancel:"):
    await handle_cancel(callback, bot)
elif cb_data == "clear:groceries":
    await handle_clear_groceries(callback, bot)

# NEW: Calendar action routing
elif cb_data.startswith("calendar:add:"):
    await handle_calendar_add(callback, bot)
elif cb_data.startswith("calendar:delete:"):
    await handle_calendar_delete(callback, bot)
elif cb_data.startswith("calendar:confirm_delete:"):
    await handle_calendar_confirm_delete(callback, bot)
elif cb_data.startswith("calendar:cancel_delete:"):
    await handle_calendar_cancel_delete(callback, bot)
elif cb_data.startswith("calendar:retry:"):
    await handle_calendar_retry(callback, bot)
elif cb_data.startswith("calendar:copy_details:"):
    await handle_calendar_copy_details(callback, bot)
elif cb_data.startswith("calendar:edit:"):
    await handle_calendar_edit(callback, bot)  # stub
elif cb_data == "calendar:noop":
    await bot.answer_callback_query(callback["id"], "")

14. Visual Design Tokens

Button Styling (Telegram Limitations)

Telegram does not support custom colors or fonts on inline keyboard buttons. All styling is achieved through emoji + text composition:

Button Text Emoji Visual Weight
Primary action πŸ—“οΈ Add to Calendar πŸ—“οΈ High β€” emoji draws eye
Secondary action ✏️ Edit Details ✏️ Low β€” muted, secondary
Loading state ⏳ Adding... ⏳ Transitional β€” clear wait state
Success edit ✏️ Edit Event ✏️ Low β€” secondary action
Success delete πŸ—‘οΈ Delete πŸ—‘οΈ Medium β€” destructive but accessible
Confirm delete βœ… Yes, Delete βœ… High β€” requires explicit confirmation
Cancel delete ❌ Cancel ❌ Medium β€” escape hatch
Retry πŸ”„ Retry πŸ”„ High β€” error recovery
Copy details πŸ“‹ Copy Details πŸ“‹ Low β€” fallback action

Message Card Typography

Element Format Example
Status header βœ… *Bold* βœ… *Added to Calendar*
Event title _Italic_ _Dental Cleaning_
Date/time Plain text May 3, 2026 at 2:00 PM
Location πŸ“ {location} πŸ“ West Side Dental
Confidence _Italic_ (muted) _Confidence: 95%_
Error text ❌ *Bold* ❌ *Could Not Add to Calendar*

Emoji Palette (consistent with Icarus existing patterns)

Concept Emoji Usage
Calendar πŸ—“οΈ Primary action button
Success βœ… Confirmation headers, success states
Loading ⏳ In-progress states
Error ❌ Error headers, destructive actions
Edit ✏️ Edit buttons
Delete πŸ—‘οΈ Delete buttons
Location πŸ“ Location pin (in message body)
Document πŸ“‹ Briefing header, copy details
Warning ⚠️ Confirmation prompts

15. Edge Cases

Case Behavior
Briefing has date but no time Default to all-day event. Show "All day" in confirmation card.
Briefing has ambiguous date Do not show calendar button. Fall back to text-only briefing.
Briefing has date + time but no end time Default to 1-hour duration. Show time as "2:00 PM – 3:00 PM (1 hr)" in confirmation.
Briefing has multiple appointments Show calendar button. On tap, create ALL events. Confirmation card lists all events.
Calendar API is unreachable Show error state with retry option.
User taps "Add" on a stale briefing (>24h) answerCallbackQuery("This briefing has expired. Send the document again.") + edit message to show expired state.
Event already exists (duplicate) Detect via calendar_events table or Google Calendar search. Show: ℹ️ This event is already in your calendar. with [✏️ Edit] [πŸ—‘οΈ Delete] buttons.
User taps "Add" while already loading Second tap is ignored (callback_data is calendar:noop during loading).
Multi-who appointment Create single event, add all who members to description. Don't create separate events per person.

16. Animation Spec

Telegram inline keyboard buttons don't support custom animations. The "animation" is achieved through message edits (same message_id, updated text + keyboard):

State 1 (Initial):
  Briefing text + [πŸ—“οΈ Add to Calendar] [✏️ Edit Details]

  ⬇️ user taps "Add to Calendar"

State 2 (Loading):
  "⏳ Adding to Calendar..." text + [⏳ Adding...]

  ⬇️ backend returns success (typically 1-3 seconds)

State 3 (Success):
  "βœ… Added to Calendar" + event details + [✏️ Edit Event] [πŸ—‘οΈ Delete]

  ⬇️ optional: user taps "Delete"

State 4 (Confirm Delete):
  "⚠️ Delete Event?" + event details + [βœ… Yes, Delete] [❌ Cancel]

  ⬇️ user taps "Yes, Delete"

State 5 (Deleted):
  "πŸ—‘οΈ Event Deleted" + title (no keyboard, final state)

Timing constraints:
- Loading β†’ Success transition: typically 1-3 seconds (Google Calendar API latency)
- Callback query acknowledgment: immediate (answerCallbackQuery called within 200ms)
- Message edit: as soon as calendar API responds (no artificial delay)


17. Message Templates (MarkdownV2)

All text must be properly escaped using the escape_markdown() function from recipe_toggle.py (reused for consistency).

Initial Briefing Card (with calendar buttons)

def build_briefing_with_calendar(briefing: dict, briefing_id: str) -> tuple[str, dict]:
    """Build briefing message text + inline keyboard for appointment briefings."""

    title = escape_markdown(briefing["title"])
    summary = escape_markdown(briefing["summary"])
    confidence = int(briefing.get("confidence", 0.5) * 100)

    lines = [
        f"πŸ“‹ *{title}*",
        "",
        summary,
        "",
    ]

    # Key details
    key_details = briefing.get("key_details", {})
    if key_details:
        for key, value in key_details.items():
            if value:
                label = escape_markdown(key.replace("_", " ").title())
                val = escape_markdown(str(value))
                lines.append(f"β€’ {label}: {val}")
        lines.append("")

    # Confidence
    lines.append(f"_Confidence: {confidence}%_")

    # Keyboard
    keyboard = {
        "inline_keyboard": [
            [
                {"text": "πŸ—“οΈ Add to Calendar", "callback_data": f"calendar:add:{briefing_id}"},
                {"text": "✏️ Edit Details", "callback_data": f"calendar:edit:{briefing_id}"},
            ]
        ]
    }

    return "\n".join(lines), keyboard

Success Confirmation Card

def build_calendar_success(event: dict, event_id: str) -> tuple[str, dict]:
    """Build success confirmation message + keyboard."""

    summary = escape_markdown(event["summary"])
    date_str = escape_markdown(event["date_formatted"])  # "May 3, 2026"
    time_str = escape_markdown(event["time_formatted"])  # "2:00 PM"
    location = escape_markdown(event.get("location", ""))
    confidence = event.get("confidence", 0)

    lines = [
        f"βœ… *Added to Calendar*",
        "",
        f"_{summary}_",
        f"{date_str} at {time_str}",
    ]

    if location:
        lines.append(f"πŸ“ {location}")

    lines.append("")
    lines.append(f"_Confidence: {confidence}%_")

    keyboard = {
        "inline_keyboard": [
            [
                {"text": "✏️ Edit Event", "callback_data": f"calendar:edit:{event_id}"},
                {"text": "πŸ—‘οΈ Delete", "callback_data": f"calendar:delete:{event_id}"},
            ]
        ]
    }

    return "\n".join(lines), keyboard

Error Card

def build_calendar_error(briefing_id: str, event: dict, error_msg: str) -> tuple[str, dict]:
    """Build error message + retry/copy keyboard."""

    summary = escape_markdown(event["summary"])
    date_str = escape_markdown(event["date_formatted"])
    time_str = escape_markdown(event["time_formatted"])
    error = escape_markdown(error_msg)

    lines = [
        f"❌ *Could Not Add to Calendar*",
        "",
        f"_{summary}_",
        f"{date_str} at {time_str}",
        "",
        f"Error: {error}",
    ]

    keyboard = {
        "inline_keyboard": [
            [
                {"text": "πŸ”„ Retry", "callback_data": f"calendar:retry:{briefing_id}"},
                {"text": "πŸ“‹ Copy Details", "callback_data": f"calendar:copy_details:{briefing_id}"},
            ]
        ]
    }

    return "\n".join(lines), keyboard

Delete Confirmation Card

def build_delete_confirmation(event: dict, event_id: str) -> tuple[str, dict]:
    """Build delete confirmation message + keyboard."""

    summary = escape_markdown(event["summary"])
    date_str = escape_markdown(event["date_formatted"])
    time_str = escape_markdown(event["time_formatted"])
    location = escape_markdown(event.get("location", ""))

    lines = [
        f"⚠️ *Delete Event\\?*",
        "",
        f"_{summary}_",
        f"{date_str} at {time_str}",
    ]

    if location:
        lines.append(f"πŸ“ {location}")

    lines.append("")
    lines.append("This will remove the event from your calendar\\.")

    keyboard = {
        "inline_keyboard": [
            [
                {"text": "βœ… Yes, Delete", "callback_data": f"calendar:confirm_delete:{event_id}"},
                {"text": "❌ Cancel", "callback_data": f"calendar:cancel_delete:{briefing_id}"},
            ]
        ]
    }

    return "\n".join(lines), keyboard

18. Conflict Detection Integration

When the user taps πŸ—“οΈ Add to Calendar, the handler should:

  1. Check for conflicts using conflict_engine.detect_conflicts() with the new event's time range
  2. If no conflicts β†’ proceed with insertion β†’ Success State
  3. If conflicts found β†’ show conflict warning before insertion:
⚠️ *Calendar Conflict Detected*

_Dental Cleaning_ (May 3, 2:00 PM – 2:30 PM)
conflicts with:

β€’ _Soccer Practice_ (2:00 PM – 3:00 PM)

[πŸ—“οΈ Add Anyway]  [❌ Cancel]

Callback data:

calendar:add_anyway:{briefing_id}    β†’ Force add despite conflict
calendar:cancel:{briefing_id}        β†’ Cancel, revert to initial state

This mirrors the existing conflict detection in conflict_engine.py and integrates naturally into the calendar flow.


19. Accessibility Considerations

  • All emoji are supplementary β€” button text is always meaningful without the emoji
  • Color is never the sole indicator of state (emoji + text together)
  • Loading state uses both text change and button change (not just a spinner)
  • Delete confirmation requires explicit two-tap confirmation (no single-tap destructive actions)
  • Error messages are actionable (retry or copy), not dead ends

20. Implementation Checklist (for Socrates)

  • [ ] Extend appointment_parser.py output or briefing pipeline to include structured appointment dict
  • [ ] Add briefing_id generation to briefing pipeline
  • [ ] Create SQLite tables: calendar_pending, calendar_events
  • [ ] Store pending appointment data when briefing is sent with calendar button
  • [ ] Implement handle_calendar_add() callback handler
  • [ ] Implement handle_calendar_delete() β†’ handle_calendar_confirm_delete() flow
  • [ ] Implement handle_calendar_retry() callback handler
  • [ ] Implement handle_calendar_copy_details() callback handler
  • [ ] Implement handle_calendar_edit() stub (toast: "Coming soon!")
  • [ ] Add calendar callback routing to process_update() in handler.py
  • [ ] Add conflict check before calendar insertion
  • [ ] Build and test MarkdownV2 message templates
  • [ ] Add 24-hour TTL cleanup for calendar_pending rows
  • [ ] Test: appointment with all fields (title, date, time, location)
  • [ ] Test: appointment with date but no time (all-day event)
  • [ ] Test: appointment with date + time but no end time (default 1 hour)
  • [ ] Test: calendar API timeout β†’ error state β†’ retry
  • [ ] Test: duplicate event detection
  • [ ] Test: conflict detection before insertion

21. File References

File What to modify
icarus/core/telegram/handler.py Add calendar callback routing, import new handlers
icarus/core/handlers/calendar_actions.py NEW β€” All calendar callback handlers
icarus/core/db/calendar_state.py NEW β€” SQLite CRUD for calendar_pending and calendar_events
icarus/core/conflict_engine.py No changes (reuse detect_conflicts())
icarus/core/calendar_sync.py No changes (reuse create_event())
icarus/core/vision/pipeline.py Add appointment dict and briefing_id to response

This spec is the UX contract. Socrates builds to this, Daedalus reviews the result.

β€” Daedalus 🎨