Heavily customized version of smokesignal - https://whtwnd.com/kayrozen.com/3lpwe4ymowg2t
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

Bookmark Event Calendar Implementation Plan#

Based on the smokesignal architecture and the ATproto bookmark lexicon, here's a comprehensive implementation plan for event bookmark lists with custom calendars.

Overview#

The feature enables users to bookmark events from community.lexicon.calendar.event directly by their AT-URI and organize them using tags. Events are displayed in a timeline view with an integrated mini-calendar component, built with HTMX and using the existing ATproto infrastructure.

Data Consistency Strategy: Full ATproto source-of-truth with local caching for performance. Local storage serves only as a performance optimization layer, with ATproto as the authoritative data source.

Bookmark Model: Bookmarks reference event AT-URIs directly (e.g., at://did:plc:xyz/community.lexicon.calendar.event/abc123), not posts containing events. This allows compatibility with other platforms.

Organization: Custom lists are implemented using ATproto's tag-based filtering via getActorBookmarks rather than separate list records.

Architecture Clarification: Bookmark Calendars vs View Modes#

Core Concept: Separate data organization (bookmark calendars) from presentation modes (view types).

Bookmark Calendars: Named collections of bookmarked events (e.g., "Summer Festivals", "Work Events")

  • User-facing term: "Calendar" (e.g., "My Summer Festivals Calendar")
  • Stored as bookmark_calendars with tag-based filtering rules
  • Each calendar has metadata: name, description, tags, platform privacy settings
  • Calendars are the organizational unit users create and manage
  • Implementation detail: These are collections/lists, not calendar grids

View Modes: Different ways to display any bookmark calendar

  • Timeline View: Chronological list with mini-calendar widget (default)
  • Calendar Grid View: Traditional month/week calendar layout
  • List View: Simple table format (future enhancement)
  • Views are presentation choices, not separate data entities

User Experience:

  • Users create and manage "Calendars"
  • Users can view their calendars in "Timeline" or "Calendar Grid" mode
  • The default view for individual calendars is Timeline
  • The default view for "All Bookmarks" is Calendar Grid

Implementation Strategy:

  • Data Layer: bookmark_calendars table stores calendar definitions with tag rules
  • Presentation Layer: Multiple template sets for each view mode
  • URL Structure: /bookmark-calendars/{calendar_id}?view={timeline|calendar|list}
  • Default Behavior: Timeline view for individual calendars, calendar grid for "All Bookmarks"

User Scenarios Analysis#

Scenario 1: Music Festival Enthusiast#

User: Sarah, follows multiple music venues and artists#

Goal: Track upcoming concerts and festivals in her area#

  • Sarah sees a concert announcement on Bluesky
  • She bookmarks it with tags: #concerts, #local, #rock
  • She creates a "Summer Festivals" list using the #festivals tag
  • Sarah views her calendar showing all bookmarked events in timeline format
  • She filters by #local to see only nearby events
  • The mini-calendar highlights dates with events

Scenario 2: Community Event Organizer#

User: Marcus, community center coordinator#

Goal: Track local community events and plan around them#

  • Marcus follows local organizations posting events
  • He bookmarks relevant posts with tags: #community, #workshops, #family-friendly
  • He creates custom calendars: "Kids Events", "Adult Workshops", "Seasonal Celebrations"
  • Timeline view helps him avoid scheduling conflicts
  • He shares his curated event calendars with community members

Scenario 3: Professional dance music event promoter#

User : Mark, nightclub promoter#

Goal : Schedule and promote his events#

  • He bookmarks relevant events with tags: #techno, #drumandbass, #allages
  • He creates custom calendars: "City wide festival", "Dance Music Workshops", "Seasonal Festival"
  • Timeline view helps him avoid scheduling conflicts and promote his brand
  • He shares his curated event calendars on social media.

Design Decisions & Clarifications#

1. ATproto Lexicon Strategy The community.lexicon.bookmarks lexicon is part of a community standardization effort for NSIDs. This provides a solid foundation for interoperability across ATproto applications.

2. Sync Strategy Purpose The sync strategy ensures data consistency between ATproto (source of truth) and local cache (performance optimization). Sync is triggered:

  • On first page load (if cache is stale)
  • When explicitly requested by user
  • After bookmark operations to refresh local state

3. User Experience Approach

  • Tag Management: Simple text-based tags, user-created without autocomplete for flexibility
  • Bulk Operations: Not implemented initially to keep scope manageable
  • ATproto Privacy Model: All bookmark data (events, tags, timestamps) is public on ATproto network by design
  • Platform Privacy: Calendar visibility settings only control discovery within smokesignal, not ATproto visibility
  • Sharing: All ATproto data is public, enabling natural calendar sharing through tag filtering

4. Performance Strategy

  • Timeline Pagination: Essential for handling large bookmark collections
  • Calendar Widget: Fetch only events for the current displayed month/period
  • Timezone Handling: Leverage existing user timezone and event timezone data

5. Error Handling Strategy

  • Deleted Events: Handle gracefully (similar to existing RSVP system approach)
  • Network Failures: Implement retry logic and graceful degradation to cached data
  • Malformed Data: Leverage existing event validation patterns

6. System Integration

  • RSVP Independence: Bookmarks and RSVPs remain separate systems
  • No Cross-Influence: Bookmarks don't affect recommendations or RSVP workflows initially
  • Future Calendar Export: iCal feed generation for external calendar integration

7. Privacy Model Clarification

  • ATproto Network: All bookmark data (events, tags, timestamps) is inherently public on ATproto
  • Platform Privacy: Calendar "privacy" settings only control visibility within smokesignal platform
  • User Transparency: Clear notices inform users that their bookmark data is always discoverable on ATproto
  • Design Philosophy: Embrace ATproto's public nature while providing platform-level organization controls

Core Architecture#

1. Database Schema Extensions#

Design Note: We store both bookmark_aturi (ATproto record URI) and event_aturi (event being bookmarked) for different purposes:

  • bookmark_aturi: Required for ATproto operations (delete, update bookmark record)
  • event_aturi: Required for local queries and joins with events table
  • This dual storage enables efficient local operations while maintaining ATproto record management

New Tables:

-- Event bookmarks table (local cache for performance)
CREATE TABLE event_bookmarks (
    id SERIAL PRIMARY KEY,
    did VARCHAR(256) NOT NULL,
    bookmark_aturi VARCHAR(1024) NOT NULL, -- ATproto bookmark record URI
    event_aturi VARCHAR(1024) NOT NULL, -- Direct event AT-URI being bookmarked
    tags TEXT[] NOT NULL DEFAULT '{}',
    created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
    synced_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(), -- Last sync with ATproto
    
    FOREIGN KEY (event_aturi) REFERENCES events(aturi) ON DELETE CASCADE,
    UNIQUE(did, bookmark_aturi), -- Prevent duplicate bookmark records
    UNIQUE(did, event_aturi) -- Prevent duplicate event bookmarks
);

-- Remove bookmark_lists table - using ATproto tags instead

-- Enhanced schema for bookmark lists (user-created collections)
CREATE TABLE bookmark_lists (
    id SERIAL PRIMARY KEY,
    did VARCHAR(256) NOT NULL,
    list_id VARCHAR(64) NOT NULL, -- Unique identifier like "summer-festivals-2025"
    name VARCHAR(255) NOT NULL, -- User-friendly name like "Summer Festivals"
    description TEXT,
    tags TEXT[] NOT NULL DEFAULT '{}', -- Multiple tags that define this list
    tag_operator VARCHAR(5) NOT NULL DEFAULT 'OR' CHECK(tag_operator IN ('AND', 'OR')), -- How tags are combined
    is_public BOOLEAN NOT NULL DEFAULT false, -- Controls smokesignal platform visibility only; ATproto data is always public
    created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
    updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
    
    UNIQUE(did, list_id), -- Prevent duplicate list IDs for user
    CHECK(array_length(tags, 1) > 0) -- Ensure at least one tag per list
);

-- Additional indexes for bookmark lists
CREATE INDEX idx_bookmark_lists_did ON bookmark_lists(did);
CREATE INDEX idx_bookmark_lists_public ON bookmark_lists(is_public) WHERE is_public = true;
CREATE INDEX idx_bookmark_lists_tags ON bookmark_lists USING GIN(tags); -- GIN index for array queries

2. ATproto Integration Layer#

Bookmark Lexicon Implementation:

// src/atproto/lexicon/community_lexicon_bookmarks.rs
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};

pub const BOOKMARK_NSID: &str = "community.lexicon.bookmarks.bookmark";
pub const GET_BOOKMARKS_NSID: &str = "community.lexicon.bookmarks.getActorBookmarks";

#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct Bookmark {
    pub subject: String, // Event AT-URI (e.g., at://did:plc:xyz/community.lexicon.calendar.event/abc123)
    #[serde(rename = "createdAt")]
    pub created_at: DateTime<Utc>,
    pub tags: Vec<String>, // Tags are required for organization
}

#[derive(Debug, Serialize, Deserialize)]
pub struct GetActorBookmarksParams {
    #[serde(skip_serializing_if = "Option::is_none")]
    pub tags: Option<Vec<String>>, // Filter by specific tags
    #[serde(skip_serializing_if = "Option::is_none")]
    pub limit: Option<i32>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub cursor: Option<String>,
}

#[derive(Debug, Serialize, Deserialize)]
pub struct GetActorBookmarksResponse {
    pub bookmarks: Vec<BookmarkRecord>, // Returns full bookmark records with AT-URIs
    #[serde(skip_serializing_if = "Option::is_none")]
    pub cursor: Option<String>,
}

#[derive(Debug, Serialize, Deserialize)]
pub struct BookmarkRecord {
    pub uri: String, // AT-URI of the bookmark record itself
    pub cid: String,
    pub value: Bookmark, // The bookmark data
    #[serde(rename = "indexedAt")]
    pub indexed_at: DateTime<Utc>,
}

3. Service Layer#

Event Bookmark Service:

// src/services/event_bookmarks.rs
use anyhow::Result;
use chrono::{DateTime, Utc};

pub struct EventBookmarkService {
    storage: Arc<StoragePool>,
    atproto_client: Arc<AtprotoClient>,
}

impl EventBookmarkService {
    pub async fn bookmark_event(
        &self,
        did: &str,
        event_aturi: &str,
        tags: Vec<String>,
    ) -> Result<String, BookmarkError> {
        // 1. Create bookmark record in ATproto (source of truth)
        let bookmark = Bookmark {
            subject: event_aturi.to_string(), // Direct event AT-URI
            created_at: Utc::now(),
            tags: tags.clone(),
        };
        
        let bookmark_aturi = self.atproto_client.create_record(
            did,
            BOOKMARK_NSID,
            &bookmark,
        ).await?;
        
        // 2. Cache locally for performance
        storage::event_bookmarks::insert(
            &self.storage,
            did,
            &bookmark_aturi,
            event_aturi,
            &tags,
        ).await?;
        
        Ok(bookmark_aturi)
    }
    
    pub async fn get_bookmarked_events(
        &self,
        did: &str,
        tags: Option<Vec<String>>,
        date_range: Option<(DateTime<Utc>, DateTime<Utc>)>,
        limit: Option<i32>,
        offset: Option<i32>,
        force_sync: bool,
    ) -> Result<PaginatedBookmarkedEvents, BookmarkError> {
        if force_sync || self.needs_sync(did).await? {
            self.sync_bookmarks_from_atproto(did).await?;
        }
        
        // Fetch from local cache with filters and pagination
        let (bookmarks, total_count) = storage::event_bookmarks::get_by_filters_paginated(
            &self.storage,
            did,
            tags,
            date_range,
            limit.unwrap_or(20), // Default page size
            offset.unwrap_or(0),
        ).await?;
        
        // Enrich with event details
        let mut enriched = Vec::new();
        for bookmark in bookmarks {
            if let Ok(event) = storage::event::get_by_aturi(&self.storage, &bookmark.event_aturi).await {
                enriched.push(BookmarkedEvent {
                    bookmark,
                    event,
                });
            }
        }
        
        Ok(PaginatedBookmarkedEvents {
            events: enriched,
            total_count,
            has_more: offset.unwrap_or(0) + limit.unwrap_or(20) < total_count as i32,
        })
    }
    
    pub async fn sync_bookmarks_from_atproto(&self, did: &str) -> Result<(), BookmarkError> {
        // Fetch all bookmarks from ATproto (source of truth)
        let mut cursor = None;
        let mut all_bookmarks = Vec::new();
        
        loop {
            let params = GetActorBookmarksParams {
                tags: None, // Get all bookmarks
                limit: Some(100),
                cursor: cursor.clone(),
            };
            
            let response = self.atproto_client.get_actor_bookmarks(did, &params).await?;
            
            for bookmark_record in response.bookmarks {
                // Only process event bookmarks
                if bookmark_record.value.subject.contains("community.lexicon.calendar.event") {
                    all_bookmarks.push(bookmark_record);
                }
            }
            
            cursor = response.cursor;
            if cursor.is_none() {
                break;
            }
        }
        
        // Update local cache
        storage::event_bookmarks::sync_from_atproto(&self.storage, did, &all_bookmarks).await?;
        
        Ok(())
    }
    
    pub async fn remove_bookmark(
        &self,
        did: &str,
        bookmark_aturi: &str,
    ) -> Result<(), BookmarkError> {
        // 1. Delete from ATproto (source of truth)
        self.atproto_client.delete_record(did, bookmark_aturi).await?;
        
        // 2. Remove from local cache
        storage::event_bookmarks::delete_by_bookmark_aturi(&self.storage, did, bookmark_aturi).await?;
        
        Ok(())
    }
    
    pub async fn get_available_tags(&self, did: &str) -> Result<Vec<String>, BookmarkError> {
        // Get tags from ATproto to ensure accuracy
        let params = GetActorBookmarksParams {
            tags: None,
            limit: Some(1000), // Get a large sample
            cursor: None,
        };
        
        let response = self.atproto_client.get_actor_bookmarks(did, &params).await?;
        
        let mut tags = std::collections::HashSet::new();
        for bookmark_record in response.bookmarks {
            if bookmark_record.value.subject.contains("community.lexicon.calendar.event") {
                for tag in bookmark_record.value.tags {
                    tags.insert(tag);
                }
            }
        }
        
        Ok(tags.into_iter().collect())
    }
    
    pub async fn export_bookmarks_ical(
        &self,
        did: &str,
        tags: Option<Vec<String>>,
        date_range: Option<(DateTime<Utc>, DateTime<Utc>)>,
        language: &str,
    ) -> Result<String, BookmarkError> {
        // Get bookmarked events (force sync to ensure latest data)
        let paginated_events = self.get_bookmarked_events(
            did,
            tags.clone(),
            date_range,
            None, // No limit for export
            None, // No offset for export
            true, // Force sync for export
        ).await?;
        
        // Create calendar using existing iCal infrastructure
        let calendar_name = LOCALES.lookup(language, "ical-bookmark-calendar-name");
        let mut calendar = ICalendar::new("2.0", &calendar_name);
        
        // Add each bookmarked event to the calendar
        for bookmarked_event in paginated_events.events {
            let event = &bookmarked_event.event;
            let bookmark = &bookmarked_event.bookmark;
            
            // Create iCal event using existing logic from handle_ical_event.rs
            let mut ical_event = IcsEvent::new(
                format!("bookmark-{}@smokesignal.events", bookmark.id),
                format_timestamp(&Utc::now()),
            );
            
            // Set event properties using existing infrastructure
            let event_title = if event.name.trim().is_empty() {
                LOCALES.lookup(language, "ical-untitled-event")
            } else {
                event.name.clone()
            };
            ical_event.push(Summary::new(event_title));
            
            // Add bookmark tags to description
            let mut description = event.description_short.clone().unwrap_or_default();
            if !bookmark.tags.is_empty() {
                let tags_text = LOCALES.lookup_with_args(language, "ical-bookmark-tags", &[
                    ("tags", bookmark.tags.join(", ").into())
                ]);
                description = format!("{}\n\n{}", description, tags_text);
            }
            ical_event.push(Description::new(description));
            
            // Set start time if available
            if let Some(starts_at_str) = &event.starts_at_machine {
                if let Ok(starts_at) = DateTime::parse_from_rfc3339(starts_at_str) {
                    ical_event.push(DtStart::new(format_timestamp(&starts_at.with_timezone(&Utc))));
                }
            }
            
            // Set location
            let event_location = event.location.clone().unwrap_or_else(|| {
                match event.mode.as_deref() {
                    Some("virtual") => LOCALES.lookup(language, "ical-online-event"),
                    _ => LOCALES.lookup(language, "ical-location-tba"),
                }
            });
            ical_event.push(Location::new(event_location));
            
            // Set organizer using existing AT Protocol format
            if let Some(organizer_name) = &event.organizer_display_name {
                let organizer_field = format!("CN={}:at://unknown", organizer_name);
                ical_event.push(Organizer::new(organizer_field));
            }
            
            calendar.add_event(ical_event);
        }
        
        Ok(calendar.to_string())
    }
    
    async fn needs_sync(&self, did: &str) -> Result<bool, BookmarkError> {
        // Check if local cache is stale (e.g., older than 5 minutes)
        storage::event_bookmarks::is_cache_stale(&self.storage, did, Duration::minutes(5)).await
    }
}

#[derive(Debug, Clone)]
pub struct BookmarkedEvent {
    pub bookmark: EventBookmark,
    pub event: HydratedEvent,
}

#[derive(Debug, Clone)]
pub struct PaginatedBookmarkedEvents {
    pub events: Vec<BookmarkedEvent>,
    pub total_count: i64,
    pub has_more: bool,
}

#[derive(Debug, Clone)]
pub struct EventBookmark {
    pub id: i32,
    pub did: String,
    pub bookmark_aturi: String,
    pub event_aturi: String,
    pub tags: Vec<String>,
    pub created_at: DateTime<Utc>,
    pub synced_at: DateTime<Utc>,
}

#[derive(Debug, Clone)]
pub struct HydratedEvent {
    pub aturi: String,
    pub name: String,
    pub site_url: String,
    pub starts_at_human: Option<String>,
    pub starts_at_machine: Option<String>,
    pub ends_at_human: Option<String>,
    pub status: Option<String>, // planned, scheduled, cancelled, etc.
    pub mode: Option<String>,   // inperson, virtual, hybrid
    pub collection: String,     // lexicon type
    pub organizer_display_name: Option<String>,
    pub organizer_did: Option<String>,
    pub description_short: Option<String>,
    pub location: Option<String>,
    pub count_going: i64,
    pub count_interested: i64,
    pub count_not_going: i64,
}

4. Storage Layer Implementation#

Event Bookmarks Storage:

// src/storage/event_bookmarks.rs
use super::{StoragePool, StorageError};
use chrono::{DateTime, Utc, Duration};

pub async fn insert(
    pool: &StoragePool,
    did: &str,
    bookmark_aturi: &str,
    event_aturi: &str,
    tags: &[String],
) -> Result<(), StorageError> {
    sqlx::query!(
        r#"
        INSERT INTO event_bookmarks (did, bookmark_aturi, event_aturi, tags)
        VALUES ($1, $2, $3, $4)
        ON CONFLICT (did, bookmark_aturi) 
        DO UPDATE SET 
            tags = EXCLUDED.tags,
            synced_at = NOW()
        "#,
        did,
        bookmark_aturi,
        event_aturi,
        tags
    )
    .execute(pool)
    .await?;
    
    Ok(())
}

pub async fn sync_from_atproto(
    pool: &StoragePool,
    did: &str,
    bookmark_records: &[BookmarkRecord],
) -> Result<(), StorageError> {
    let mut tx = pool.begin().await?;
    
    // Clear existing bookmarks for this user
    sqlx::query!(
        "DELETE FROM event_bookmarks WHERE did = $1",
        did
    )
    .execute(&mut *tx)
    .await?;
    
    // Insert fresh data from ATproto
    for record in bookmark_records {
        sqlx::query!(
            r#"
            INSERT INTO event_bookmarks (did, bookmark_aturi, event_aturi, tags, created_at, synced_at)
            VALUES ($1, $2, $3, $4, $5, NOW())
            "#,
            did,
            record.uri,
            record.value.subject,
            &record.value.tags,
            record.value.created_at
        )
        .execute(&mut *tx)
        .await?;
    }
    
    tx.commit().await?;
    Ok(())
}

pub async fn get_by_filters_paginated(
    pool: &StoragePool,
    did: &str,
    tags: Option<Vec<String>>,
    date_range: Option<(DateTime<Utc>, DateTime<Utc>)>,
    limit: i32,
    offset: i32,
) -> Result<(Vec<EventBookmark>, i64), StorageError> {
    // First get total count
    let mut count_query = QueryBuilder::new(
        r#"
        SELECT COUNT(*)
        FROM event_bookmarks eb
        JOIN events e ON eb.event_aturi = e.aturi
        WHERE eb.did = 
        "#
    );
    
    count_query.push_bind(did);
    
    // Add same filters as main query
    if let Some(filter_tags) = &tags {
        count_query.push(" AND eb.tags && ");
        count_query.push_bind(filter_tags.clone());
    }
    
    if let Some((start, end)) = date_range {
        count_query.push(r#" AND (e.record->>'startsAt')::timestamp >= "#);
        count_query.push_bind(start);
        count_query.push(" AND (e.record->>'startsAt')::timestamp <= ");
        count_query.push_bind(end);
    }
    
    let total_count: i64 = count_query
        .build_query_scalar()
        .fetch_one(pool)
        .await?;
    
    // Then get paginated results
    let mut query = QueryBuilder::new(
        r#"
        SELECT eb.*, e.record, e.name as event_name
        FROM event_bookmarks eb
        JOIN events e ON eb.event_aturi = e.aturi
        WHERE eb.did = 
        "#
    );
    
    query.push_bind(did);
    
    // Add tag filtering
    if let Some(filter_tags) = tags {
        query.push(" AND eb.tags && ");
        query.push_bind(filter_tags);
    }
    
    // Add date range filtering
    if let Some((start, end)) = date_range {
        query.push(r#" AND (e.record->>'startsAt')::timestamp >= "#);
        query.push_bind(start);
        query.push(" AND (e.record->>'startsAt')::timestamp <= ");
        query.push_bind(end);
    }
    
    query.push(" ORDER BY (e.record->>'startsAt')::timestamp ASC");
    query.push(" LIMIT ");
    query.push_bind(limit);
    query.push(" OFFSET ");
    query.push_bind(offset);
    
    let bookmarks = query
        .build_query_as::<EventBookmark>()
        .fetch_all(pool)
        .await?;
    
    Ok((bookmarks, total_count))
}

pub async fn get_by_filters(
    pool: &StoragePool,
    did: &str,
    tags: Option<Vec<String>>,
    date_range: Option<(DateTime<Utc>, DateTime<Utc>)>,
) -> Result<Vec<EventBookmark>, StorageError> {
    let (bookmarks, _) = get_by_filters_paginated(pool, did, tags, date_range, 1000, 0).await?;
    Ok(bookmarks)
}

pub async fn delete_by_bookmark_aturi(
    pool: &StoragePool,
    did: &str,
    bookmark_aturi: &str,
) -> Result<(), StorageError> {
    sqlx::query!(
        "DELETE FROM event_bookmarks WHERE did = $1 AND bookmark_aturi = $2",
        did,
        bookmark_aturi
    )
    .execute(pool)
    .await?;
    
    Ok(())
}

pub async fn is_cache_stale(
    pool: &StoragePool,
    did: &str,
    max_age: Duration,
) -> Result<bool, StorageError> {
    let cutoff = Utc::now() - max_age;
    
    let result = sqlx::query!(
        r#"
        SELECT COUNT(*) as count 
        FROM event_bookmarks 
        WHERE did = $1 AND synced_at > $2
        "#,
        did,
        cutoff
    )
    .fetch_one(pool)
    .await?;
    
    Ok(result.count.unwrap_or(0) == 0)
}

Custom Calendars Storage:

// src/storage/bookmark_calendars.rs
use super::{StoragePool, StorageError};
use chrono::{DateTime, Utc};

pub async fn insert(
    pool: &StoragePool,
    calendar: &BookmarkCalendar,
) -> Result<BookmarkCalendar, StorageError> {
    let result = sqlx::query!(
        r#"
        INSERT INTO bookmark_calendars (did, calendar_id, name, description, tags, tag_operator, is_public, created_at, updated_at)
        VALUES ($1, $2, $3, $4, $5, $6, $7, NOW(), NOW())
        RETURNING id, did, calendar_id, name, description, tags, tag_operator, is_public, created_at, updated_at
        "#,
        calendar.did,
        calendar.calendar_id,
        calendar.name,
        calendar.description,
        &calendar.tags,
        calendar.tag_operator,
        calendar.is_public
    )
    .fetch_one(pool)
    .await?;
    
    Ok(BookmarkCalendar {
        id: result.id,
        did: result.did,
        calendar_id: result.calendar_id,
        name: result.name,
        description: result.description,
        tags: result.tags,
        tag_operator: result.tag_operator,
        is_public: result.is_public,
        created_at: result.created_at,
        updated_at: result.updated_at,
    })
}

pub async fn get_by_calendar_id(
    pool: &StoragePool,
    did: &str,
    calendar_id: &str,
) -> Result<BookmarkCalendar, StorageError> {
    let result = sqlx::query!(
        r#"
        SELECT * FROM bookmark_calendars WHERE did = $1 AND calendar_id = $2
        "#,
        did,
        calendar_id
    )
    .fetch_one(pool)
    .await?;
    
    Ok(BookmarkCalendar {
        id: result.id,
        did: result.did,
        calendar_id: result.calendar_id,
        name: result.name,
        description: result.description,
        tags: result.tags,
        tag_operator: result.tag_operator,
        is_public: result.is_public,
        created_at: result.created_at,
        updated_at: result.updated_at,
    })
}

pub async fn get_by_user(
    pool: &StoragePool,
    did: &str,
    include_private: bool,
) -> Result<Vec<BookmarkCalendar>, StorageError> {
    let mut query = r#"SELECT * FROM bookmark_calendars WHERE did = $1"#.to_string();
    
    if !include_private {
        query.push_str(" AND is_public = true");
    }
    
    let results = sqlx::query_as::<_, BookmarkCalendar>(&query)
        .bind(did)
        .fetch_all(pool)
        .await?;
    
    Ok(results)
}

pub async fn get_public_paginated(
    pool: &StoragePool,
    limit: i32,
    offset: i32,
) -> Result<(Vec<BookmarkCalendar>, i64), StorageError> {
    let query = r#"
        SELECT * FROM bookmark_calendars 
        WHERE is_public = true
        ORDER BY created_at DESC
        LIMIT $1 OFFSET $2
    "#;
    
    let results = sqlx::query_as::<_, BookmarkCalendar>(query)
        .bind(limit)
        .bind(offset)
        .fetch_all(pool)
        .await?;
    
    let count_query = r#"SELECT COUNT(*) FROM bookmark_calendars WHERE is_public = true"#;
    let total_count: i64 = sqlx::query_scalar(count_query)
        .fetch_one(pool)
        .await?;
    
    Ok((results, total_count))
}

pub async fn update(
    pool: &StoragePool,
    calendar: &BookmarkCalendar,
) -> Result<(), StorageError> {
    sqlx::query!(
        r#"
        UPDATE bookmark_calendars 
        SET name = $1, description = $2, tags = $3, tag_operator = $4, is_public = $5, updated_at = NOW()
        WHERE did = $6 AND calendar_id = $7
        "#,
        calendar.name,
        calendar.description,
        &calendar.tags,
        calendar.tag_operator,
        calendar.is_public,
        calendar.did,
        calendar.calendar_id
    )
    .execute(pool)
    .await?;
    
    Ok(())
}

pub async fn delete(
    pool: &StoragePool,
    did: &str,
    calendar_id: &str,
) -> Result<(), StorageError> {
    sqlx::query!(
        r#"
        DELETE FROM bookmark_calendars WHERE did = $1 AND calendar_id = $2
        "#,
        did,
        calendar_id
    )
    .execute(pool)
    .await?;
    
    Ok(())
}

pub async fn update_event_count(
    pool: &StoragePool,
    calendar_id: i32,
    increment: i32,
) -> Result<(), StorageError> {
    sqlx::query!(
        r#"
        UPDATE bookmark_calendar_stats 
        SET event_count = event_count + $1, last_updated = NOW()
        WHERE calendar_id = $2
        "#,
        increment,
        calendar_id
    )
    .execute(pool)
    .await?;
    
    Ok(())
}

pub async fn get_calendar_stats(
    pool: &StoragePool,
    calendar_id: i32,
) -> Result<BookmarkCalendarStats, StorageError> {
    let result = sqlx::query!(
        r#"
        SELECT * FROM bookmark_calendar_stats WHERE calendar_id = $1
        "#,
        calendar_id
    )
    .fetch_one(pool)
    .await?;
    
    Ok(BookmarkCalendarStats {
        calendar_id: result.calendar_id,
        event_count: result.event_count,
        follower_count: result.follower_count,
        last_updated: result.last_updated,
    })
}

5. HTTP Handlers#

Bookmark Management Handlers:

// src/http/handle_bookmark_events.rs
use axum::{extract::{Query, State}, response::Html, Json};
use serde::Deserialize;

#[derive(Deserialize)]
pub struct BookmarkEventParams {
    event_aturi: String,
    tags: Option<String>, // Comma-separated tags
}

pub async fn handle_bookmark_event(
    State(ctx): State<Arc<AppContext>>,
    Query(params): Query<BookmarkEventParams>,
) -> Result<Html<String>, HttpError> {
    let session = get_session_or_redirect(&ctx, &headers).await?;
    
    let tags = params.tags
        .map(|t| t.split(',').map(|s| s.trim().to_string()).collect())
        .unwrap_or_default();
    
    let bookmark_aturi = ctx.bookmark_service.bookmark_event(
        &session.did,
        &params.event_aturi,
        tags,
    ).await?;
    
    // Return HTMX fragment showing success
    let renderer = create_renderer!(ctx, session.language, true);
    let html = renderer.render("bookmark_success", &json!({  // Will resolve to bookmark_success.{locale}.partial.html
        "bookmarked": true,
        "bookmark_aturi": bookmark_aturi
    }))?;
    
    Ok(Html(html))
}

#[derive(Deserialize)]
pub struct CalendarViewParams {
    tags: Option<String>,
    view: Option<String>, // "timeline" or "calendar"
    start_date: Option<String>,
    end_date: Option<String>,
}

pub async fn handle_bookmark_calendar(
    State(ctx): State<Arc<AppContext>>,
    Query(params): Query<CalendarViewParams>,
) -> Result<Html<String>, HttpError> {
    let session = get_session_or_redirect(&ctx, &headers).await?;
    
    let tags = params.tags
        .map(|t| t.split(',').map(|s| s.trim().to_string()).collect());
    
    let date_range = parse_date_range(params.start_date, params.end_date)?;
    
    // Force sync on explicit calendar view to ensure fresh data
    let force_sync = !is_htmx_request(&headers);
    
    let bookmarked_events = ctx.bookmark_service.get_bookmarked_events(
        &session.did,
        tags.clone(),
        date_range,
        force_sync,
    ).await?;
    
    // Get available tags from ATproto for filtering UI
    let available_tags = ctx.bookmark_service.get_available_tags(&session.did).await?;
    
    let renderer = create_renderer!(ctx, session.language, is_htmx_request);
    let template = match params.view.as_deref() {
        Some("calendar") => "bookmark_calendar_view",
        _ => "bookmark_timeline",  // Will resolve to bookmark_timeline.{locale}.html
    };
    
    let html = renderer.render(template, &json!({
        "events": bookmarked_events,
        "available_tags": available_tags,
        "current_tags": params.tags,
        "view_mode": params.view.unwrap_or_else(|| "timeline".to_string()),
    }))?;
    
    Ok(Html(html))
}

pub async fn handle_remove_bookmark(
    State(ctx): State<Arc<AppContext>>,
    Path(bookmark_aturi): Path<String>,
) -> Result<Html<String>, HttpError> {
    let session = get_session_or_redirect(&ctx, &headers).await?;
    
    ctx.bookmark_service.remove_bookmark(
        &session.did,
        &bookmark_aturi,
    ).await?;
    
    // Return empty response for HTMX to remove the element
    Ok(Html(String::new()))
}

pub async fn handle_bookmark_calendar_ical(
    State(ctx): State<Arc<AppContext>>,
    Query(params): Query<CalendarViewParams>,
) -> Result<impl IntoResponse, HttpError> {
    let session = get_session_or_redirect(&ctx, &headers).await?;
    
    let tags = params.tags
        .map(|t| t.split(',').map(|s| s.trim().to_string()).collect());
    
    let date_range = parse_date_range(params.start_date, params.end_date)?;
    
    // Export bookmarks as iCal
    let ical_content = ctx.bookmark_service.export_bookmarks_ical(
        &session.did,
        tags.clone(),
        date_range,
        &session.language,
    ).await?;
    
    // Generate filename based on tags or use default
    let filename = if let Some(tag_list) = &tags {
        format!("bookmarks-{}.ics", tag_list.join("-"))
    } else {
        "bookmarks.ics".to_string()
    };
    
    // Create response with appropriate headers (reusing pattern from handle_ical_event.rs)
    let mut headers = HeaderMap::new();
    headers.insert(
        header::CONTENT_TYPE,
        "text/calendar; charset=utf-8".parse().unwrap(),
    );
    headers.insert(
        header::CONTENT_DISPOSITION,
        format!("attachment; filename=\"{}\"", filename)
            .parse()
            .unwrap(),
    );
    
    Ok((StatusCode::OK, headers, ical_content))
}

Custom Calendar Handlers:

// src/http/handle_bookmark_calendars.rs
use axum::{extract::{Path, State}, response::Html, Json};
use serde::Deserialize;

#[derive(Deserialize)]
pub struct CreateBookmarkCalendarParams {
    name: String,
    description: Option<String>,
    tags: Vec<String>,
    tag_operator: Option<String>, // "AND" or "OR", defaults to "OR"
    is_public: Option<bool>,
}

pub async fn handle_create_bookmark_calendar(
    State(ctx): State<Arc<AppContext>>,
    Json(payload): Json<CreateBookmarkCalendarParams>,
) -> Result<Html<String>, HttpError> {
    let session = get_session_or_redirect(&ctx, &headers).await?;
    
    let calendar = ctx.bookmark_calendar_service.create_calendar(
        &session.did,
        &payload.name,
        payload.description.as_deref(),
        &payload.tags,
        &payload.tag_operator.unwrap_or_else(|| "OR".to_string()),
        payload.is_public.unwrap_or(false),
    ).await?;
    
    // Return HTMX fragment to update calendars container
    let renderer = create_renderer!(ctx, session.language, true);
    let html = renderer.render("bookmark_calendar_item", &json!({  // Will resolve to bookmark_calendar_item.{locale}.partial.html
        "calendar": calendar,
    }))?;
    
    Ok(Html(html))
}

pub async fn handle_view_bookmark_calendar(
    State(ctx): State<Arc<AppContext>>,
    Path(calendar_id): Path<String>,
) -> Result<Html<String>, HttpError> {
    let session = get_session_or_redirect(&ctx, &headers).await?;
    
    let calendar = ctx.bookmark_calendar_service.get_by_calendar_id(&session.did, &calendar_id).await?;
    
    // Fetch events in the calendar
    let paginated_events = ctx.bookmark_calendar_service.get_calendar_events(
        &session.did,
        &calendar_id,
        Some(20),
        Some(0),
    ).await?;
    
    let renderer = create_renderer!(ctx, session.language, is_htmx_request);
    let html = renderer.render("bookmark_calendar_view", &json!({  // Will resolve to bookmark_calendar_view.{locale}.html
        "calendar": calendar,
        "events": paginated_events.events,
    }))?;
    
    Ok(Html(html))
}

pub async fn handle_delete_bookmark_calendar(
    State(ctx): State<Arc<AppContext>>,
    Path(calendar_id): Path<String>,
) -> Result<Html<String>, HttpError> {
    let session = get_session_or_redirect(&ctx, &headers).await?;
    
    ctx.bookmark_calendar_service.delete(
        &session.did,
        &calendar_id,
    ).await?;
    
    // Return empty response for HTMX to remove the element
    Ok(Html(String::new()))
}

pub async fn handle_add_event_to_calendar(
    State(ctx): State<Arc<AppContext>>,
    Path(calendar_id): Path<String>,
    Json(payload): Json<AddEventToCalendarParams>,
) -> Result<Html<String>, HttpError> {
    let session = get_session_or_redirect(&ctx, &headers).await?;
    
    let bookmark_aturi = ctx.bookmark_calendar_service.add_event_to_calendar(
        &session.did,
        &calendar_id,
        &payload.event_aturi,
    ).await?;
    
    // Return HTMX fragment to update event count in calendar
    let renderer = create_renderer!(ctx, session.language, true);
    let html = renderer.render("bookmark_calendar_event_count", &json!({  // Will resolve to bookmark_calendar_event_count.{locale}.partial.html
        "calendar_id": calendar_id,
    }))?;
    
    Ok(Html(html))
}

pub async fn handle_export_calendar_ical(
    State(ctx): State<Arc<AppContext>>,
    Path(calendar_id): Path<String>,
) -> Result<impl IntoResponse, HttpError> {
    let session = get_session_or_redirect(&ctx, &headers).await?;
    
    // Get the calendar to export
    let calendar = ctx.bookmark_calendar_service.get_by_calendar_id(&session.did, &calendar_id).await?;
    
    // Export bookmarks as iCal
    let ical_content = ctx.bookmark_service.export_bookmarks_ical(
        &session.did,
        Some(calendar.tags),
        None, // No date range
        &session.language,
    ).await?;
    
    // Generate filename based on calendar name
    let filename = format!("{}.ics", calendar.name.replace(" ", "-"));
    
    // Create response with appropriate headers
    let mut headers = HeaderMap::new();
    headers.insert(
        header::CONTENT_TYPE,
        "text/calendar; charset=utf-8".parse().unwrap(),
    );
    headers.insert(
        header::CONTENT_DISPOSITION,
        format!("attachment; filename=\"{}\"", filename)
            .parse()
            .unwrap(),
    );
    
    Ok((StatusCode::OK, headers, ical_content))
}

6. Frontend Templates#

Timeline View Template:

<!-- templates/bookmark_timeline.en-us.html (main page with base layout) -->
<!-- Also need: bookmark_timeline.fr-ca.html, bookmark_timeline.en-us.bare.html, bookmark_timeline.fr-ca.bare.html -->
<div class="bookmark-calendar-container">
    <div class="columns">
        <!-- Left: Mini Calendar -->
        <div class="column is-one-quarter">
            <div class="calendar-widget">
                <div class="calendar-header">
                    <h3 class="title is-5">{{ t("calendar-navigation") }}</h3>
                </div>
                <div class="calendar-grid" id="mini-calendar">
                    {% include "mini_calendar.html" %}
                </div>
                
                <!-- Tag Filter -->
                <div class="tag-filter mt-4">
                    <h4 class="subtitle is-6">{{ t("filter-by-tags") }}</h4>
                    <div class="field is-grouped is-grouped-multiline">
                        {% for tag in available_tags %}
                        <div class="control">
                            <label class="checkbox">
                                <input type="checkbox" 
                                       name="tags" 
                                       value="{{ tag }}"
                                       {% if tag in current_tags %}checked{% endif %}
                                       hx-get="/bookmark-calendar"
                                       hx-trigger="change"
                                       hx-target="#timeline-content"
                                       hx-include="[name='tags']:checked">
                                {{ tag }}
                            </label>
                        </div>
                        {% endfor %}
                    </div>
                </div>
            </div>
        </div>
        
        <!-- Right: Timeline -->
        <div class="column">
            <div class="timeline-container">
                <div class="level">
                    <div class="level-left">
                        <h2 class="title is-4">{{ t("bookmarked-events") }}</h2>
                    </div>
                    <div class="level-right">
                        <div class="field has-addons">
                            <div class="control">
                                <button class="button is-primary is-small"
                                        hx-get="/bookmark-calendar?view=timeline"
                                        hx-target="#main-content">
                                    {{ t("timeline-view") }}
                                </button>
                            </div>
                            <div class="control">
                                <button class="button is-light is-small"
                                        hx-get="/bookmark-calendar?view=calendar"
                                        hx-target="#main-content">
                                    {{ t("calendar-view") }}
                                </button>
                            </div>
                        </div>
                    </div>
                </div>
                
                <div id="timeline-content">
                    {% include "bookmark_timeline." + current_locale + ".partial.html" %}
                </div>
            </div>
        </div>
    </div>
</div>

Timeline Events Fragment:

<!-- templates/bookmark_timeline.en-us.partial.html (HTMX partial for timeline content) -->
<!-- Also need: bookmark_timeline.fr-ca.partial.html -->
<div class="timeline">
    {% for bookmarked_event in events %}
    <div class="timeline-item">
        <div class="timeline-marker is-icon">
            <i class="fas fa-bookmark" style="color: #ff6b6b;"></i>
        </div>
        <div class="timeline-content">
            <div class="event-card">
                <!-- Bookmark Tags Header -->
                <div class="bookmark-tags-header">
                    {% for tag in bookmarked_event.bookmark.tags %}
                    <span class="tag is-primary is-small">{{ tag }}</span>
                    {% endfor %}
                    <div class="bookmark-actions">
                        <button class="button is-danger is-small"
                                hx-delete="/bookmarks/{{ bookmarked_event.bookmark.bookmark_aturi | urlencode }}"
                                hx-target="closest .timeline-item"
                                hx-swap="outerHTML"
                                hx-confirm="{{ t('confirm-remove-bookmark') }}"
                                title="{{ t('remove-bookmark') }}">
                            <i class="fas fa-trash"></i>
                        </button>
                    </div>
                </div>

                <div class="event-card-content">
                    <!-- Time and Date -->
                    {% if bookmarked_event.event.starts_at_human %}
                    <div class="event-time">
                        <i class="fas fa-clock"></i>
                        <time class="dt-start" {% if bookmarked_event.event.starts_at_machine %}datetime="{{ bookmarked_event.event.starts_at_machine }}"{% endif %}>
                            {{ bookmarked_event.event.starts_at_human }}
                        </time>
                    </div>
                    {% endif %}
                    
                    <!-- Event Title -->
                    <h3 class="event-title">
                        <a href="{{ base }}{{ bookmarked_event.event.site_url }}" hx-boost="true">
                            {% autoescape false %}{{ bookmarked_event.event.name }}{% endautoescape %}
                        </a>
                    </h3>
                    
                    <!-- Event Tags (Status, Mode, etc.) -->
                    <div class="event-tags">
                        <!-- Status Tag -->
                        {% if bookmarked_event.event.status == "planned" %}
                        <span class="event-tag tag-planned" title="{{ t('status-planned') }}">
                            <i class="fas fa-calendar-days"></i> {{ t("status-planned") }}
                        </span>
                        {% elif bookmarked_event.event.status == "scheduled" %}
                        <span class="event-tag tag-scheduled" title="{{ t('status-scheduled') }}">
                            <i class="fas fa-calendar-check"></i> {{ t("status-scheduled") }}
                        </span>
                        {% elif bookmarked_event.event.status == "rescheduled" %}
                        <span class="event-tag tag-rescheduled" title="{{ t('status-rescheduled') }}">
                            <i class="fas fa-calendar-plus"></i> {{ t("status-rescheduled") }}
                        </span>
                        {% elif bookmarked_event.event.status == "cancelled" %}
                        <span class="event-tag tag-cancelled" title="{{ t('status-cancelled') }}">
                            <i class="fas fa-calendar-xmark"></i> {{ t("status-cancelled") }}
                        </span>
                        {% elif bookmarked_event.event.status == "postponed" %}
                        <span class="event-tag tag-postponed" title="{{ t('status-postponed') }}">
                            <i class="fas fa-calendar-minus"></i> {{ t("status-postponed") }}
                        </span>
                        {% endif %}

                        <!-- Mode Tag -->
                        {% if bookmarked_event.event.mode == "inperson" %}
                        <span class="event-tag tag-inperson">
                            <i class="fas fa-users"></i> {{ t("mode-in-person") }}
                        </span>
                        {% elif bookmarked_event.event.mode == "virtual" %}
                        <span class="event-tag tag-virtual">
                            <i class="fas fa-video"></i> {{ t("mode-virtual") }}
                        </span>
                        {% elif bookmarked_event.event.mode == "hybrid" %}
                        <span class="event-tag tag-hybrid">
                            <i class="fas fa-globe"></i> {{ t("mode-hybrid") }}
                        </span>
                        {% endif %}

                        <!-- Legacy Tag -->
                        {% if bookmarked_event.event.collection != "community.lexicon.calendar.event" %}
                        <span class="event-tag tag-legacy">{{ t("status-legacy") }}</span>
                        {% endif %}
                    </div>

                    <!-- Organizer Info -->
                    {% if bookmarked_event.event.organizer_display_name %}
                    <div class="event-organizer">
                        <div class="organizer-avatar">
                            {{ bookmarked_event.event.organizer_display_name|first|upper }}
                        </div>
                        <span class="organizer-name">
                            <a href="{{ base }}/{{ bookmarked_event.event.organizer_did }}" hx-boost="true" style="color: inherit; text-decoration: none;">
                                {{ bookmarked_event.event.organizer_display_name }}
                            </a>
                        </span>
                    </div>
                    {% endif %}

                    <!-- Event Description Preview -->
                    {% if bookmarked_event.event.description_short %}
                    <div class="event-description">
                        {% autoescape false %}{{ bookmarked_event.event.description_short }}{% endautoescape %}
                    </div>
                    {% endif %}

                    <!-- Location -->
                    {% if bookmarked_event.event.location %}
                    <div class="event-location" style="color: #aaa; font-size: 0.875rem; margin-bottom: 1rem;">
                        <i class="fas fa-map-marker-alt" style="color: #7dd87f; margin-right: 0.5rem;"></i>
                        {{ bookmarked_event.event.location }}
                    </div>
                    {% endif %}

                    <!-- End Time -->
                    {% if bookmarked_event.event.ends_at_human %}
                    <div class="event-end-time" style="color: #888; font-size: 0.75rem; margin-bottom: 1rem;">
                        <i class="fas fa-clock" style="margin-right: 0.25rem;"></i>
                        {{ t("ends-at", time=bookmarked_event.event.ends_at_human) }}
                    </div>
                    {% endif %}

                    <!-- Event Statistics -->
                    <div class="event-stats">
                        <div class="stat" title="{{ t('event-count-going', count=bookmarked_event.event.count_going) }}">
                            <i class="fas fa-star"></i>
                            <span>{{ bookmarked_event.event.count_going }}</span>
                        </div>
                        <div class="stat" title="{{ t('event-count-interested', count=bookmarked_event.event.count_interested) }}">
                            <i class="fas fa-eye"></i>
                            <span>{{ bookmarked_event.event.count_interested }}</span>
                        </div>
                        <div class="stat" title="{{ t('event-count-not-going', count=bookmarked_event.event.count_not_going | default(0)) }}">
                            <i class="fas fa-ban"></i>
                            <span>{{ bookmarked_event.event.count_not_going | default(0) }}</span>
                        </div>
                    </div>

                    <!-- Action Buttons -->
                    <div class="event-actions">
                        <a href="{{ base }}{{ bookmarked_event.event.site_url }}" class="action-btn" hx-boost="true">
                            <i class="fas fa-eye"></i>
                            {{ t("view-event") }}
                        </a>
                    </div>

                    <!-- Bookmark Metadata -->
                    <div class="bookmark-metadata">
                        <small class="has-text-grey">
                            <i class="fas fa-bookmark"></i>
                            {{ t("bookmarked-on", date=bookmarked_event.bookmark.created_at | date_format(locale)) }}
                        </small>
                    </div>
                </div>
            </div>
        </div>
    </div>
    {% endfor %}
</div>

Mini Calendar Component:

<!-- templates/mini_calendar.html (locale-agnostic calendar widget) -->
<div class="calendar-mini">
    <div class="calendar-nav">
        <button class="button is-small is-text" 
                hx-get="/bookmark-calendar/calendar-nav?month=-1"
                hx-target="#mini-calendar">
            <i class="fa fa-chevron-left"></i>
        </button>
        <span class="has-text-weight-semibold">{{ current_month | month_name(locale) }} {{ current_year }}</span>
        <button class="button is-small is-text"
                hx-get="/bookmark-calendar/calendar-nav?month=1"
                hx-target="#mini-calendar">
            <i class="fa fa-chevron-right"></i>
        </button>
    </div>
    
    <table class="calendar-grid">
        <thead>
            <tr>
                {% for day in weekdays %}
                <th class="has-text-centered">{{ day | truncate(1) }}</th>
                {% endfor %}
            </tr>
        </thead>
        <tbody>
            {% for week in calendar_weeks %}
            <tr>
                {% for day in week %}
                <td class="calendar-day 
                    {% if day.has_events %}has-events{% endif %}
                    {% if day.is_today %}is-today{% endif %}
                    {% if day.is_selected %}is-selected{% endif %}">
                    <button class="calendar-day-btn"
                            {% if day.has_events %}
                            hx-get="/bookmark-calendar?date={{ day.date | date_format('Y-m-d') }}"
                            hx-target="#timeline-content"
                            {% endif %}>
                        {{ day.number }}
                        {% if day.event_count > 0 %}
                        <span class="event-indicator">{{ day.event_count }}</span>
                        {% endif %}
                    </button>
                </td>
                {% endfor %}
            </tr>
            {% endfor %}
        </tbody>
    </table>
</div>

Profile Lists Section:

<!-- templates/profile_bookmark_lists.en-us.incl.html (include fragment for profile pages) -->
<!-- Also need: profile_bookmark_lists.fr-ca.incl.html -->
<div class="profile-section">
    <div class="section-header">
        <h3 class="title is-4">
            <i class="fas fa-bookmark"></i>
            {{ t("bookmark-lists") }}
        </h3>
        {% if is_own_profile %}
        <a href="/bookmark-lists/new" class="button is-primary is-small">
            <i class="fas fa-plus"></i>
            {{ t("create-list") }}
        </a>
        {% endif %}
    </div>
    
    {% if lists %}
    <div class="bookmark-lists-grid">
        {% for list_with_stats in lists %}
        <div class="list-card">
            <div class="list-card-header">
                <h4 class="list-name">
                    <a href="/bookmark-lists/{{ list_with_stats.list.list_id }}">
                        {{ list_with_stats.list.name }}
                    </a>
                </h4>
                <div class="list-stats">
                    <span class="tag is-light">
                        {{ list_with_stats.event_count }} {{ t("events") }}
                    </span>
                </div>
            </div>
            
            {% if list_with_stats.list.description %}
            <p class="list-description">{{ list_with_stats.list.description }}</p>
            {% endif %}
            
            <div class="list-card-footer">
                <small class="has-text-grey">
                    {{ t("created") }} {{ list_with_stats.list.created_at | date }}
                </small>
                <div class="list-actions">
                    <a href="/bookmark-lists/{{ list_with_stats.list.list_id }}.ics" 
                       class="button is-small is-outlined"
                       title="{{ t('export-calendar') }}">
                        <i class="fas fa-calendar-alt"></i>
                    </a>
                    <button class="button is-small is-outlined share-list-btn"
                            data-list-url="{{ base_url }}/bookmark-lists/{{ list_with_stats.list.list_id }}"
                            title="{{ t('share-list') }}">
                        <i class="fas fa-share"></i>
                    </button>
                </div>
            </div>
        </div>
        {% endfor %}
    </div>
    {% else %}
    <div class="empty-state">
        <p class="has-text-grey">{{ t("no-public-lists") }}</p>
    </div>
    {% endif %}
</div>

Enhanced List Creation Modal:

<!-- templates/bookmark_list_modal.en-us.partial.html (HTMX modal partial) -->
<!-- Also need: bookmark_list_modal.fr-ca.partial.html -->
<div class="modal" id="create-list-modal">
    <div class="modal-background"></div>
    <div class="modal-card">
        <header class="modal-card-head">
            <p class="modal-card-title">{{ t("create-bookmark-list") }}</p>
            <button class="delete" onclick="closeListModal()"></button>
        </header>
        <section class="modal-card-body">
            <form id="create-list-form">
                <div class="field">
                    <label class="label">{{ t("list-name") }}</label>
                    <div class="control">
                        <input class="input" type="text" name="name" 
                               placeholder="{{ t('list-name-placeholder') }}" required>
                    </div>
                    <p class="help">{{ t("list-name-help") }}</p>
                </div>
                
                <div class="field">
                    <label class="label">{{ t("list-tags") }}</label>
                    <div class="control">
                        <div class="tags-input-container">
                            <div id="selected-list-tags" class="tags-display"></div>
                            <input class="input" type="text" id="new-list-tag-input" 
                                   placeholder="{{ t('add-tag-placeholder') }}"
                                   onkeypress="handleTagInput(event)">
                        </div>
                    </div>
                    <p class="help">{{ t("list-tags-help") }}</p>
                </div>
                
                <div class="field">
                    <label class="label">{{ t("list-description") }}</label>
                    <div class="control">
                        <textarea class="textarea" name="description" 
                                  placeholder="{{ t('list-description-placeholder') }}"></textarea>
                    </div>
                </div>
                
                <div class="field">
                    <div class="control">
                        <label class="checkbox">
                            <input type="checkbox" name="is_public">
                            {{ t("make-calendar-public") }}
                        </label>
                    </div>
                    <p class="help">{{ t("public-calendar-help") }}</p>
                    <div class="notification is-info is-light">
                        <p class="is-size-7">
                            <strong>{{ t("atproto-privacy-notice") }}</strong><br>
                            {{ t("atproto-privacy-explanation") }}
                        </p>
                    </div>
                </div>
            </form>
        </section>
        <footer class="modal-card-foot">
            <button class="button is-primary" onclick="submitListForm()">
                {{ t("create-list") }}
            </button>
            <button class="button" onclick="closeListModal()">
                {{ t("cancel") }}
            </button>
        </footer>
    </div>
</div>

Enhanced JavaScript for Tag Management:

// static/bookmark-calendars.js
let calendarTags = [];

function handleTagInput(event) {
    if (event.key === 'Enter' || event.key === ',') {
        event.preventDefault();
        addCalendarTag();
    }
}

function addCalendarTag() {
    const input = document.getElementById('new-calendar-tag-input');
    const tag = input.value.trim().toLowerCase();
    
    if (tag && !calendarTags.includes(tag)) {
        calendarTags.push(tag);
        renderCalendarTags();
        input.value = '';
    }
}

function removeCalendarTag(tag) {
    calendarTags = calendarTags.filter(t => t !== tag);
    renderCalendarTags();
}

function renderCalendarTags() {
    const container = document.getElementById('selected-calendar-tags');
    container.innerHTML = calendarTags.map(tag => `
        <span class="tag is-primary">
            ${tag}
            <button class="delete is-small" onclick="removeCalendarTag('${tag}')"></button>
        </span>
    `).join('');
}

function submitCalendarForm() {
    const form = document.getElementById('create-calendar-form');
    const formData = new FormData(form);
    
    const payload = {
        name: formData.get('name'),
        description: formData.get('description'),
        tags: calendarTags,
        tag_operator: formData.get('tag_operator') || 'OR',
        is_public: formData.has('is_public')
    };
    
    fetch('/bookmark-calendars', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify(payload)
    })
    .then(response => response.text())
    .then(html => {
        document.getElementById('calendars-container').insertAdjacentHTML('afterbegin', html);
        closeCalendarModal();
        showNotification('{{ t("calendar-created") }}', 'success');
    })
    .catch(error => {
        showNotification('{{ t("error-creating-calendar") }}', 'error');
    });
}

// Smart calendar suggestions based on existing tags
function suggestCalendarTags(eventTags) {
    const suggestions = document.getElementById('tag-suggestions');
    if (!suggestions) return;
    
    // Show relevant existing tags as suggestions
    const existingTags = [...new Set(eventTags)];
    suggestions.innerHTML = existingTags.map(tag => `
        <button class="button is-small is-light" onclick="addSuggestedTag('${tag}')">
            ${tag}
        </button>
    `).join('');
}

function addSuggestedTag(tag) {
    if (!calendarTags.includes(tag)) {
        calendarTags.push(tag);
        renderCalendarTags();
    }
}

Internationalization#

Translation Keys:

# i18n/en-us/bookmarks.ftl
bookmark-event = Bookmark Event
bookmark-calendar = Event Calendar
bookmarked-events = Bookmarked Events
timeline-view = Timeline
calendar-view = Calendar
filter-by-tags = Filter by Tags
calendar-navigation = Calendar
remove-bookmark = Remove Bookmark
confirm-remove-bookmark = Are you sure you want to remove this bookmark?
add-to-calendar = Add to Calendar
create-new-calendar = Create New Calendar
bookmark-success = Event bookmarked successfully!
no-bookmarked-events = No bookmarked events found
bookmark-tags = Tags (comma-separated)
bookmark-calendar-name = Calendar Name
bookmark-calendar-description = Description (optional)
make-calendar-public = Make this calendar public
bookmarked-on = Bookmarked on {$date}
ends-at = Ends at {$time}

# Enhanced calendar management
bookmark-calendars = Custom Calendars
create-calendar = Create Calendar
create-bookmark-calendar = Create Custom Calendar
create-new-calendar = Create New Calendar
calendar-name = Calendar Name
calendar-name-placeholder = e.g. Summer Festivals, Work Events
calendar-name-help = Choose a descriptive name for your calendar
calendar-tags = Tags
add-tag-placeholder = Add a tag and press Enter
calendar-tags-help = Add tags to organize your calendar
calendar-description-placeholder = What types of events will you collect?
make-calendar-public = Make this calendar public
public-calendar-help = Public calendars appear on your profile and can be discovered by others
atproto-privacy-notice = Privacy Notice
atproto-privacy-explanation = Your bookmarks and tags are always public on the ATproto network. This setting only controls whether this calendar appears on your smokesignal profile.
my-calendars = My Calendars
public-calendars = Public Calendars
no-public-calendars = No public calendars yet
export-calendar = Export Calendar
share-calendar = Share Calendar
events = events
created = Created on
calendar-updated = Calendar updated successfully
calendar-created = Calendar created successfully
event-added-to-calendar = Event added to calendar
# i18n/fr-ca/bookmarks.ftl
bookmark-event = Marquer l'événement
bookmark-calendar = Calendrier des événements
bookmarked-events = Événements marqués
timeline-view = Vue chronologique
calendar-view = Vue calendrier
filter-by-tags = Filtrer par étiquettes
calendar-navigation = Navigation
remove-bookmark = Retirer le marque-page
confirm-remove-bookmark = Êtes-vous sûr·e de vouloir retirer ce marque-page?
add-to-calendar = Ajouter au calendrier
create-new-calendar = Créer un nouveau calendrier
bookmark-success = Événement marqué avec succès!
no-bookmarked-events = Aucun événement marqué trouvé
bookmark-tags = Étiquettes (séparées par des virgules)
bookmark-calendar-name = Nom du calendrier
bookmark-calendar-description = Description (optionnelle)
make-calendar-public = Rendre ce calendrier public
bookmarked-on = Marqué le {$date}
ends-at = Se termine à {$time}

# Enhanced calendar management
bookmark-calendars = Calendriers personnalisés
create-calendar = Créer un calendrier
create-bookmark-calendar = Créer un calendrier personnalisé
create-new-calendar = Créer un nouveau calendrier
calendar-name = Nom du calendrier
calendar-name-placeholder = ex. Festivals d'été, Événements de travail
calendar-name-help = Choisissez un nom descriptif pour votre calendrier
calendar-tags = Étiquettes
add-tag-placeholder = Ajouter une étiquette et appuyer sur Entrée
calendar-tags-help = Ajoutez des étiquettes pour organiser votre calendrier
calendar-description-placeholder = Quels types d'événements allez-vous collecter ?
make-calendar-public = Rendre ce calendrier public
public-calendar-help = Les calendriers publics apparaissent sur votre profil et peuvent être découverts par d'autres
atproto-privacy-notice = Avis de confidentialité
atproto-privacy-explanation = Vos signets et étiquettes sont toujours publics sur le réseau ATproto. Ce paramètre contrôle seulement si ce calendrier apparaît sur votre profil smokesignal.
my-calendars = Mes calendriers
public-calendars = Calendriers publics
no-public-calendars = Pas encore de calendriers publics
export-calendar = Exporter le calendrier
share-calendar = Partager le calendrier
events = événements
created = Créé le
calendar-updated = Calendrier mis à jour avec succès
calendar-created = Calendrier créé avec succès
event-added-to-calendar = Événement ajouté au calendrier
# i18n/en-us/bookmarks.ftl (additions)
calendar-tags = Tags
calendar-tags-help = Add multiple tags to organize this calendar (press Enter or comma to add)
add-tag-placeholder = Add a tag...
tag-suggestions = Suggested tags
calendar-created-success = Calendar created with {$count} tags
calendar-updated-success = Calendar updated with {$count} tags
tag-match-all = Show events with ALL tags
tag-match-any = Show events with ANY tags
tag-strategy = Tag matching
tag-strategy-help = Choose how events are matched to this calendar
tag-operator = Tag operator
tag-operator-help = Combine tags with AND (all required) or OR (any required)
error-creating-calendar = Error creating calendar
error-max-tags = Maximum 10 tags allowed per calendar
error-empty-tag = Empty tags are not allowed
error-tag-too-long = Tag too long (maximum 50 characters)
# i18n/fr-ca/bookmarks.ftl (additions)
calendar-tags = Étiquettes
calendar-tags-help = Ajoutez plusieurs étiquettes pour organiser ce calendrier (appuyez sur Entrée ou virgule pour ajouter)
add-tag-placeholder = Ajouter une étiquette...
tag-suggestions = Étiquettes suggérées
calendar-created-success = Calendrier créé avec {$count} étiquettes
calendar-updated-success = Calendrier mis à jour avec {$count} étiquettes
tag-match-all = Afficher les événements avec TOUTES les étiquettes
tag-match-any = Afficher les événements avec N'IMPORTE QUELLE étiquette
tag-strategy = Correspondance d'étiquettes
tag-strategy-help = Choisissez comment les événements correspondent à ce calendrier
tag-operator = Opérateur d'étiquettes
tag-operator-help = Combinez les étiquettes avec ET (toutes requises) ou OU (n'importe laquelle requise)
error-creating-calendar = Erreur lors de la création du calendrier
error-max-tags = Maximum 10 étiquettes autorisées par calendrier
error-empty-tag = Les étiquettes vides ne sont pas autorisées
error-tag-too-long = Étiquette trop longue (maximum 50 caractères)

Required Template Files Summary#

Based on the i18n templating system used in smokesignal, the following template files need to be created for both en-us and fr-ca locales:

Core Calendar Templates (Timeline View)#

templates/bookmark_calendar_timeline.en-us.html         # Calendar shown in timeline mode (main page)
templates/bookmark_calendar_timeline.fr-ca.html         # French version
templates/bookmark_calendar_timeline.en-us.bare.html    # Bare layout version
templates/bookmark_calendar_timeline.fr-ca.bare.html    # French bare version
templates/bookmark_calendar_timeline.en-us.partial.html # HTMX partial updates
templates/bookmark_calendar_timeline.fr-ca.partial.html # French partial updates

Core Calendar Templates (Calendar Grid View)#

templates/bookmark_calendar_grid.en-us.html         # Calendar shown in grid mode
templates/bookmark_calendar_grid.fr-ca.html         # French version
templates/bookmark_calendar_grid.en-us.bare.html    # Bare layout version
templates/bookmark_calendar_grid.fr-ca.bare.html    # French bare version
templates/bookmark_calendar_grid.en-us.partial.html # HTMX partial updates
templates/bookmark_calendar_grid.fr-ca.partial.html # French partial updates

Bookmark Success Templates#

templates/bookmark_success.en-us.partial.html  # Success feedback for HTMX
templates/bookmark_success.fr-ca.partial.html  # French version

Calendar Management Templates#

templates/bookmark_calendar_item.en-us.partial.html     # Calendar item for HTMX updates
templates/bookmark_calendar_item.fr-ca.partial.html     # French version

templates/bookmark_calendar_event_count.en-us.partial.html  # Event count updates
templates/bookmark_calendar_event_count.fr-ca.partial.html  # French version

templates/bookmark_calendar_modal.en-us.partial.html    # Calendar creation modal
templates/bookmark_calendar_modal.fr-ca.partial.html    # French version

Profile Integration Templates#

templates/profile_bookmark_calendars.en-us.incl.html   # Profile section include
templates/profile_bookmark_calendars.fr-ca.incl.html   # French version

Locale-Agnostic Components#

templates/mini_calendar.html                    # Calendar widget (no i18n needed)

Template Usage Pattern#

The Rust route handlers will use the template naming convention:

  • "bookmark_calendar_timeline" resolves to bookmark_calendar_timeline.{locale}.html (calendar in timeline view)
  • "bookmark_calendar_grid" resolves to bookmark_calendar_grid.{locale}.html (calendar in grid view)
  • Partials automatically get .partial.html suffix
  • Include fragments get .incl.html suffix

Key Point: All templates represent bookmark calendars - the suffix indicates the view mode, not different data types.

Integration with Existing System#

These templates will integrate with the existing template structure by:

  1. Extending base.{locale}.html for full pages
  2. Extending bare.{locale}.html for modal/popup content
  3. Using {% include %} for shared components
  4. Following existing CSS class patterns (Bulma framework)
  5. Using the t() function for all user-facing text
  6. Supporting HTMX attributes for dynamic updates

HTTP Routes#

The bookmark feature requires the following new routes to be added to src/http/server.rs:

// Add to the Router in build_router function:
.route("/bookmarks", get(handle_bookmark_calendar))
.route("/bookmarks", post(handle_bookmark_event))
.route("/bookmarks/:bookmark_aturi", delete(handle_remove_bookmark))
.route("/bookmarks/calendar-nav", get(handle_calendar_navigation))
.route("/bookmarks.ics", get(handle_bookmark_calendar_ical))

// Custom Calendar Management
.route("/bookmark-calendars", get(handle_bookmark_calendars_index))
.route("/bookmark-calendars", post(handle_create_bookmark_calendar))
.route("/bookmark-calendars/:calendar_id", get(handle_view_bookmark_calendar))
.route("/bookmark-calendars/:calendar_id", delete(handle_delete_bookmark_calendar))
.route("/bookmark-calendars/:calendar_id.ics", get(handle_export_calendar_ical))
.route("/bookmark-calendars/:calendar_id/events", post(handle_add_event_to_calendar))
.route("/bookmark-calendars/:calendar_id/events/:event_aturi", delete(handle_remove_event_from_calendar))

The bookmarks feature should be integrated into the existing navigation system by adding a dropdown menu that clearly distinguishes between browsing events and managing personal calendars.

Update Navigation Templates#

Add to templates/nav.en-us.html and templates/nav.fr-ca.html:

<!-- Replace the existing events nav item with a dropdown -->
<div class="navbar-item has-dropdown is-hoverable">
    <a class="navbar-link">
        <span class="icon">
            <i class="fas fa-calendar"></i>
        </span>
        {{ t("nav-events-calendars") }}
    </a>
    <div class="navbar-dropdown">
        <a class="navbar-item" href="/events" hx-boost="true">
            <span class="icon">
                <i class="fas fa-list"></i>
            </span>
            {{ t("nav-browse-events") }}
        </a>
        {% if current_handle %}
        <hr class="navbar-divider">
        <a class="navbar-item" href="/bookmark-calendars" hx-boost="true">
            <span class="icon">
                <i class="fas fa-bookmark"></i>
            </span>
            {{ t("nav-my-calendars") }}
        </a>
        <a class="navbar-item" href="/bookmarks" hx-boost="true">
            <span class="icon">
                <i class="fas fa-heart"></i>
            </span>
            {{ t("nav-all-bookmarks") }}
        </a>
        {% endif %}
    </div>
</div>

i18n Updates for Navigation#

Add to i18n/en-us/common.ftl:

nav-events-calendars = Events & Calendars
nav-browse-events = Browse Events
nav-my-calendars = My Calendars
nav-all-bookmarks = All Bookmarks

Add to i18n/fr-ca/common.ftl:

nav-events-calendars = Événements et calendriers
nav-browse-events = Parcourir les événements
nav-my-calendars = Mes calendriers
nav-all-bookmarks = Tous les signets