feat: add calender event tooltips on hover instead of notifications**

- full calender system with events
- Implemented tooltip system for calendar event tabs
- Added smart positioning with viewport awareness
- Included fade-in animations and responsive design
- Enhanced user experience with contextual information display
- Added proper cleanup and memory management
This commit is contained in:
dralkh 2025-10-08 14:29:17 +03:00
parent 10579cfc9d
commit a4aff67faa
17 changed files with 4985 additions and 13978 deletions

View file

@ -26,6 +26,15 @@ https://github.com/user-attachments/assets/10bb251e-360c-41ae-a128-e90f3f3e2576
* **Comprehensive Review Management:**
* A dedicated sidebar view displays upcoming reviews, estimated completion times, and allows for easy navigation with configurable hotkey support.
* Calendar view for visualizing your review schedule with standardized UTC date calculations.
* **📅 Calendar Events Organization:**
* **Full Event Management**: Create, edit, and delete calendar events with comprehensive CRUD operations
* **Event Categories**: Color-coded categories (Work, Personal, Study, Meeting, Health, Social, Other) for visual organization
* **Recurring Events**: Support for daily, weekly, monthly, and yearly recurrence patterns with end dates
* **Visual Event Display**: Events shown as colored tabs in calendar cells with hover interactions
* **Upcoming Events List**: Organized list of upcoming events below calendar grid with day-based grouping
* **Quick Event Creation**: Hover plus button on calendar days for fast event creation with pre-filled dates
* **Event Details Modal**: Comprehensive event creation/editing interface with all event properties
* **Real-time Updates**: Calendar refreshes instantly after event operations with proper state management
* **Flexible Note Addition:**
* Add individual notes or entire folders to your review schedule via the right-click context menu.
* Use Obsidian commands to add the current note or its folder.
@ -82,6 +91,15 @@ npm install && npm run build && node install.js --d /home/user/Documents/vault/.
2. Start, pause, reset, or skip Pomodoro sessions (Work, Short Break, Long Break).
3. The timer and current session type are displayed in the sidebar.
#### Managing Calendar Events
1. **Create Events**: Click the plus button in the calendar header or hover over calendar days to reveal the quick-add button
2. **View Events**: Events appear as colored tabs in calendar cells and in the upcoming events list below the calendar
3. **Edit Events**: Double-click event tabs in calendar cells to open the edit modal
4. **Delete Events**: Use the delete button in the edit modal (no confirmation required for quick workflow)
5. **Event Categories**: Choose from predefined categories with automatic color coding or set custom colors
6. **Recurring Events**: Set recurrence patterns (daily, weekly, monthly, yearly) with optional end dates
7. **Event Details**: Add title, description, date, time, location, and category for comprehensive event management
## Core Concepts
### Scheduling Algorithms: Optimize Your Learning
@ -98,6 +116,7 @@ The Spaceforge settings tab allows you to customize:
* **Scheduling Algorithm:** Select and configure FSRS or SM-2 parameters with standardized UTC date calculations.
* **MCQ Generation:** Enable/disable MCQs, select API provider, enter API keys and models, configure scoring behavior.
* **Pomodoro Timer:** Enable/disable, set durations for work/break sessions, sound notifications.
* **Calendar Events:** Enable/disable calendar events, set default event category, configure event display options.
* **Data Management:** Set a custom path for plugin data, import/export data, manage data integrity.
## Commands & Shortcuts
@ -109,6 +128,7 @@ Spaceforge adds several commands to the Obsidian command palette:
* `Spaceforge: Add Current Note to Review Schedule`
* `Spaceforge: Add Current Note's Folder to Review Schedule`
* `Spaceforge: Open Review Sidebar`
* `Spaceforge: Create Calendar Event`
You can assign custom keyboard shortcuts to these commands via Obsidian's hotkey settings. Spaceforge now includes enhanced navigation command execution with configurable hotkey support for improved workflow efficiency.
@ -141,6 +161,10 @@ This plugin is licensed under the MIT License.
* **Customizable Sidebar Sections:** Allow users to show/hide or reorder sections within the Spaceforge sidebar for a personalized study environment.
* **More Informative Note Items:** Display more details on note items (e.g., icons for MCQ availability, leech status, FSRS/SM-2 type; hover for more stats) for quick insights.
* **Calendar View Enhancements:** More interactive calendar with direct review options, visual density indicators, and hover details for better schedule management.
* **Event Reminders:** Notifications and alerts for upcoming calendar events with customizable timing.
* **Calendar Synchronization:** Integration with external calendar services (Google Calendar, Outlook, etc.).
* **Advanced Event Filtering:** Filter events by category, date range, or search terms for better organization.
* **Event Templates**: Pre-defined event templates for common activities (meetings, study sessions, etc.).
* **Pomodoro UI Refinements:** Visual timer, session history, and more sound customization options for an improved focus timer experience.
**IV. Integrations & Data Management:**

1603
main.js

File diff suppressed because it is too large Load diff

13
main.ts
View file

@ -24,6 +24,7 @@ import { ReviewHistoryService } from './services/review-history-service';
import { ReviewSessionService } from './services/review-session-service';
import { MCQService } from './services/mcq-service';
import { PomodoroService } from './services/pomodoro-service';
import { CalendarEventService } from './services/calendar-event-service';
/**
* Spaceforge: Spaced Repetition Plugin for Obsidian
@ -37,6 +38,7 @@ export default class SpaceforgePlugin extends Plugin {
reviewSessionService: ReviewSessionService;
mcqService: MCQService;
pomodoroService: PomodoroService;
calendarEventService: CalendarEventService;
private readonly stylesheetPath: string = "styles.css";
private readonly stylesheetId: string = "spaceforge-styles";
@ -85,6 +87,11 @@ export default class SpaceforgePlugin extends Plugin {
// Initialize PomodoroService after settings are loaded
this.pomodoroService = new PomodoroService(this);
// Initialize CalendarEventService after settings are loaded
this.calendarEventService = new CalendarEventService();
const eventsArray = Object.values(this.pluginState.calendarEvents);
this.calendarEventService.initialize(eventsArray);
this.registerView(
@ -373,6 +380,11 @@ export default class SpaceforgePlugin extends Plugin {
this.pluginState = { ...this.pluginState, ...(rawLoadedData as Partial<PluginStateData>) };
}
// Ensure calendarEvents exists for legacy data
if (!this.pluginState.calendarEvents) {
this.pluginState.calendarEvents = {};
}
// Ensure pomodoro state is initialized if not present in loaded data
this.pluginState.pomodoroCurrentMode = this.pluginState.pomodoroCurrentMode || DEFAULT_PLUGIN_STATE_DATA.pomodoroCurrentMode;
this.pluginState.pomodoroTimeLeftInSeconds = this.pluginState.pomodoroTimeLeftInSeconds || DEFAULT_PLUGIN_STATE_DATA.pomodoroTimeLeftInSeconds;
@ -471,6 +483,7 @@ export default class SpaceforgePlugin extends Plugin {
pomodoroUserOverrideHours: typeof this.pluginState.pomodoroUserOverrideHours === 'number' ? this.pluginState.pomodoroUserOverrideHours : DEFAULT_PLUGIN_STATE_DATA.pomodoroUserOverrideHours,
pomodoroUserOverrideMinutes: typeof this.pluginState.pomodoroUserOverrideMinutes === 'number' ? this.pluginState.pomodoroUserOverrideMinutes : DEFAULT_PLUGIN_STATE_DATA.pomodoroUserOverrideMinutes,
pomodoroUserAddToEstimation: typeof this.pluginState.pomodoroUserAddToEstimation === 'boolean' ? this.pluginState.pomodoroUserAddToEstimation : DEFAULT_PLUGIN_STATE_DATA.pomodoroUserAddToEstimation,
calendarEvents: this.calendarEventService ? Object.fromEntries(this.calendarEventService.getAllEvents().map(event => [event.id, event])) : {},
version: this.manifest.version,
};
this.pluginState = currentPluginState; // Update the live pluginState

View file

@ -1,7 +1,7 @@
{
"id": "spaceforge",
"name": "Spaceforge",
"version": "1.0.2",
"version": "1.0.3",
"minAppVersion": "0.15.0",
"description": "A spaced repetition FSRS & SM-2 Algorithm powered by AI plugin for efficient knowledge review with pomodoro timer",
"author": "dralkh",

124
models/calendar-event.ts Normal file
View file

@ -0,0 +1,124 @@
/**
* Event categories with predefined colors
*/
export enum EventCategory {
Work = 'work',
Personal = 'personal',
Study = 'study',
Meeting = 'meeting',
Health = 'health',
Social = 'social',
Other = 'other'
}
/**
* Event recurrence patterns
*/
export enum EventRecurrence {
None = 'none',
Daily = 'daily',
Weekly = 'weekly',
Monthly = 'monthly',
Yearly = 'yearly'
}
/**
* Default colors for event categories
*/
export const EVENT_CATEGORY_COLORS: Record<EventCategory, string> = {
[EventCategory.Work]: '#4A90E2',
[EventCategory.Personal]: '#7B68EE',
[EventCategory.Study]: '#50C878',
[EventCategory.Meeting]: '#FF6B6B',
[EventCategory.Health]: '#FF9F40',
[EventCategory.Social]: '#FF69B4',
[EventCategory.Other]: '#95A5A6'
};
/**
* Calendar event interface
*/
export interface CalendarEvent {
/**
* Unique identifier for the event
*/
id: string;
/**
* Event title
*/
title: string;
/**
* Event description (optional)
*/
description?: string;
/**
* Event date (timestamp)
*/
date: number;
/**
* Event time in HH:MM format (optional)
*/
time?: string;
/**
* Event category
*/
category: EventCategory;
/**
* Custom color (overrides category color if set)
*/
color?: string;
/**
* Event recurrence pattern
*/
recurrence: EventRecurrence;
/**
* End date for recurring events (optional)
*/
recurrenceEndDate?: number;
/**
* Whether the event is all-day
*/
isAllDay: boolean;
/**
* Event location (optional)
*/
location?: string;
/**
* Creation timestamp
*/
createdAt: number;
/**
* Last modification timestamp
*/
updatedAt: number;
}
/**
* Interface for grouped events by date
*/
export interface DateEvents {
timestamp: number;
events: CalendarEvent[];
}
/**
* Interface for upcoming event display
*/
export interface UpcomingEvent {
event: CalendarEvent;
daysUntil: number;
isToday: boolean;
isTomorrow: boolean;
}

View file

@ -2,6 +2,7 @@ import { SpaceforgeSettings, DEFAULT_SETTINGS } from './settings';
import { ReviewSchedule, ReviewHistoryItem } from './review-schedule';
import { ReviewSessionStore } from './review-session';
import { MCQSet, MCQSession } from './mcq';
import { CalendarEvent } from './calendar-event';
/**
* Interface for the plugin's operational state (excluding settings).
@ -34,6 +35,9 @@ export interface PluginStateData {
pomodoroUserOverrideHours: number; // User-specified hours for override
pomodoroUserOverrideMinutes: number; // User-specified minutes for override
pomodoroUserAddToEstimation: boolean; // Whether to add user time to estimation or replace it
// Calendar Events
calendarEvents: Record<string, CalendarEvent>;
}
/**
@ -66,6 +70,9 @@ export const DEFAULT_PLUGIN_STATE_DATA: PluginStateData = {
pomodoroUserOverrideHours: 0,
pomodoroUserOverrideMinutes: 0,
pomodoroUserAddToEstimation: false,
// Calendar Events Defaults
calendarEvents: {},
};
/**

View file

@ -310,6 +310,31 @@ export interface SpaceforgeSettings {
key: string | null;
};
navigationCommandDelay: number; // in milliseconds
// --- Calendar Events Settings ---
/**
* Enable calendar events feature
* Default: true
*/
enableCalendarEvents: boolean;
/**
* Show upcoming events below calendar
* Default: true
*/
showUpcomingEvents: boolean;
/**
* Number of days to show in upcoming events list
* Default: 7
*/
upcomingEventsDays: number;
/**
* Default event category
* Default: 'personal'
*/
defaultEventCategory: string;
}
/**
@ -403,4 +428,10 @@ export const DEFAULT_SETTINGS: SpaceforgeSettings = {
key: null,
},
navigationCommandDelay: 500,
// Calendar Events Defaults
enableCalendarEvents: true,
showUpcomingEvents: true,
upcomingEventsDays: 7,
defaultEventCategory: 'personal',
};

File diff suppressed because it is too large Load diff

View file

@ -1,10 +0,0 @@
{
"id": "spaceforge",
"name": "Spaceforge",
"version": "1.0.2",
"minAppVersion": "0.15.0",
"description": "A spaced repetition FSRS & SM-2 Algorithm powered by AI plugin for efficient knowledge review with pomodoro timer",
"author": "dralkh",
"authorUrl": "https://github.com/dralkh",
"isDesktopOnly": false
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,350 @@
import { CalendarEvent, DateEvents, UpcomingEvent, EventCategory, EventRecurrence } from '../models/calendar-event';
import { DateUtils } from '../utils/dates';
/**
* Service for managing calendar events
*/
export class CalendarEventService {
/**
* Storage for calendar events
*/
private events: Map<string, CalendarEvent> = new Map();
/**
* Initialize the service with existing events
*
* @param events Array of existing events
*/
initialize(events: CalendarEvent[] = []): void {
this.events.clear();
events.forEach(event => {
this.events.set(event.id, event);
});
}
/**
* Get all events
*
* @returns Array of all events
*/
getAllEvents(): CalendarEvent[] {
return Array.from(this.events.values());
}
/**
* Get event by ID
*
* @param id Event ID
* @returns Event or null if not found
*/
getEventById(id: string): CalendarEvent | null {
return this.events.get(id) || null;
}
/**
* Create a new event
*
* @param eventData Event data (without id, createdAt, updatedAt)
* @returns Created event
*/
createEvent(eventData: Omit<CalendarEvent, 'id' | 'createdAt' | 'updatedAt'>): CalendarEvent {
const now = Date.now();
const event: CalendarEvent = {
...eventData,
id: this.generateId(),
createdAt: now,
updatedAt: now
};
this.events.set(event.id, event);
return event;
}
/**
* Update an existing event
*
* @param id Event ID
* @param updates Partial event data to update
* @returns Updated event or null if not found
*/
updateEvent(id: string, updates: Partial<CalendarEvent>): CalendarEvent | null {
const existingEvent = this.events.get(id);
if (!existingEvent) {
return null;
}
const updatedEvent: CalendarEvent = {
...existingEvent,
...updates,
id, // Ensure ID doesn't change
createdAt: existingEvent.createdAt, // Preserve creation time
updatedAt: Date.now()
};
this.events.set(id, updatedEvent);
return updatedEvent;
}
/**
* Delete an event
*
* @param id Event ID
* @returns True if deleted, false if not found
*/
deleteEvent(id: string): boolean {
return this.events.delete(id);
}
/**
* Get events for a specific date range
*
* @param startDate Start date timestamp
* @param endDate End date timestamp
* @returns Array of events in the date range
*/
getEventsInRange(startDate: number, endDate: number): CalendarEvent[] {
return this.getAllEvents().filter(event => {
const eventDate = DateUtils.startOfDay(new Date(event.date));
const start = DateUtils.startOfDay(new Date(startDate));
const end = DateUtils.startOfDay(new Date(endDate));
return eventDate >= start && eventDate <= end;
});
}
/**
* Get events for a specific date
*
* @param date Date timestamp
* @returns Array of events for the date
*/
getEventsForDate(date: number): CalendarEvent[] {
const targetDate = DateUtils.startOfDay(new Date(date));
return this.getAllEvents().filter(event => {
const eventDate = DateUtils.startOfDay(new Date(event.date));
return eventDate === targetDate;
});
}
/**
* Get events grouped by date for a date range
*
* @param startDate Start date timestamp
* @param endDate End date timestamp
* @returns Map of date keys to DateEvents
*/
getEventsGroupedByDate(startDate: number, endDate: number): Map<string, DateEvents> {
const eventsByDate = new Map<string, DateEvents>();
const eventsInRange = this.getEventsInRange(startDate, endDate);
eventsInRange.forEach(event => {
const eventDateStart = DateUtils.startOfDay(new Date(event.date));
const dateKey = eventDateStart.toString();
if (!eventsByDate.has(dateKey)) {
eventsByDate.set(dateKey, {
timestamp: eventDateStart,
events: []
});
}
const dateEvents = eventsByDate.get(dateKey);
if (dateEvents) {
dateEvents.events.push(event);
}
});
return eventsByDate;
}
/**
* Get upcoming events
*
* @param daysAhead Number of days ahead to look
* @param fromDate Optional start date (defaults to today)
* @returns Array of upcoming events
*/
getUpcomingEvents(daysAhead: number = 7, fromDate: Date = new Date()): UpcomingEvent[] {
const today = DateUtils.startOfDay(fromDate);
const endDate = DateUtils.addDays(today, daysAhead);
const eventsInRange = this.getEventsInRange(today, endDate);
return eventsInRange
.map(event => {
const eventDate = DateUtils.startOfDay(new Date(event.date));
const daysUntil = Math.floor((eventDate - today) / (24 * 60 * 60 * 1000));
return {
event,
daysUntil,
isToday: daysUntil === 0,
isTomorrow: daysUntil === 1
};
})
.sort((a, b) => {
// Sort by days until, then by time
if (a.daysUntil !== b.daysUntil) {
return a.daysUntil - b.daysUntil;
}
// If same day, sort by time (all-day events first)
if (a.event.isAllDay && !b.event.isAllDay) return -1;
if (!a.event.isAllDay && b.event.isAllDay) return 1;
// If both have times, sort by time
if (a.event.time && b.event.time) {
return a.event.time.localeCompare(b.event.time);
}
return 0;
});
}
/**
* Generate recurring event instances
*
* @param event Recurring event
* @param startDate Start date for generating instances
* @param endDate End date for generating instances
* @returns Array of event instances
*/
generateRecurringInstances(event: CalendarEvent, startDate: number, endDate: number): CalendarEvent[] {
if (event.recurrence === EventRecurrence.None) {
return [event];
}
const instances: CalendarEvent[] = [];
const start = DateUtils.startOfDay(new Date(startDate));
const end = DateUtils.startOfDay(new Date(endDate));
const eventDate = DateUtils.startOfDay(new Date(event.date));
const recurrenceEnd = event.recurrenceEndDate ? DateUtils.startOfDay(new Date(event.recurrenceEndDate)) : end;
let currentDate = new Date(eventDate);
while (currentDate.getTime() <= Math.min(end, recurrenceEnd)) {
if (currentDate.getTime() >= start) {
const instance: CalendarEvent = {
...event,
date: currentDate.getTime(),
id: `${event.id}-${currentDate.getTime()}` // Unique ID for instance
};
instances.push(instance);
}
// Move to next occurrence
switch (event.recurrence) {
case EventRecurrence.Daily:
currentDate.setDate(currentDate.getDate() + 1);
break;
case EventRecurrence.Weekly:
currentDate.setDate(currentDate.getDate() + 7);
break;
case EventRecurrence.Monthly:
currentDate.setMonth(currentDate.getMonth() + 1);
break;
case EventRecurrence.Yearly:
currentDate.setFullYear(currentDate.getFullYear() + 1);
break;
}
}
return instances;
}
/**
* Get events by category
*
* @param category Event category
* @returns Array of events in the category
*/
getEventsByCategory(category: EventCategory): CalendarEvent[] {
return this.getAllEvents().filter(event => event.category === category);
}
/**
* Search events by title or description
*
* @param query Search query
* @returns Array of matching events
*/
searchEvents(query: string): CalendarEvent[] {
const lowerQuery = query.toLowerCase();
return this.getAllEvents().filter(event =>
event.title.toLowerCase().includes(lowerQuery) ||
(event.description && event.description.toLowerCase().includes(lowerQuery))
);
}
/**
* Export events to JSON
*
* @returns JSON string of all events
*/
exportEvents(): string {
return JSON.stringify(this.getAllEvents(), null, 2);
}
/**
* Import events from JSON
*
* @param json JSON string of events
* @returns Number of imported events
*/
importEvents(json: string): number {
try {
const events: CalendarEvent[] = JSON.parse(json);
let importedCount = 0;
events.forEach(event => {
if (event.id && event.title && event.date) {
this.events.set(event.id, event);
importedCount++;
}
});
return importedCount;
} catch (error) {
console.error('Failed to import events:', error);
return 0;
}
}
/**
* Generate a unique ID for new events
*
* @returns Unique ID string
*/
private generateId(): string {
return `event-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
}
/**
* Get event color (custom color or category color)
*
* @param event Event
* @returns Color hex code
*/
getEventColor(event: CalendarEvent): string {
return event.color || this.getCategoryColor(event.category);
}
/**
* Get category color
*
* @param category Event category
* @returns Color hex code
*/
getCategoryColor(category: EventCategory): string {
const colors = {
[EventCategory.Work]: '#4A90E2',
[EventCategory.Personal]: '#7B68EE',
[EventCategory.Study]: '#50C878',
[EventCategory.Meeting]: '#FF6B6B',
[EventCategory.Health]: '#FF9F40',
[EventCategory.Social]: '#FF69B4',
[EventCategory.Other]: '#95A5A6'
};
return colors[category] || colors[EventCategory.Other];
}
}

View file

@ -12,6 +12,8 @@
--sf-danger-light: rgba(244, 67, 54, 0.15);
--sf-info: #2196f3;
--sf-info-light: rgba(33, 150, 243, 0.15);
--sf-overdue-accent: #ff8c42;
--sf-overdue-bg: rgba(255, 140, 66, 0.08);
--sf-text: var(--text-normal, #333);
--sf-text-muted: var(--text-muted, #888);
--sf-text-on-primary: var(--text-on-accent, white);
@ -40,6 +42,7 @@
--sf-warning-light: rgba(255, 152, 0, 0.2);
--sf-danger-light: rgba(244, 67, 54, 0.2);
--sf-info-light: rgba(33, 150, 243, 0.2);
--sf-overdue-bg: rgba(255, 140, 66, 0.12);
--sf-bg-primary: var(--background-primary-alt);
--sf-bg-secondary: var(--background-secondary-alt);
}
@ -945,7 +948,7 @@ button.disabled:hover {
border: 1px solid var(--background-modifier-border);
}
.review-date-section-overdue {
border-left: 3px solid var(--text-error);
border-left: 3px solid var(--sf-overdue-accent);
background-color: var(--background-secondary);
}
.review-date-header {
@ -976,7 +979,7 @@ button.disabled:hover {
min-width: 0;
}
.review-date-section-overdue .review-date-header h3 {
color: var(--text-error);
color: var(--sf-overdue-accent);
}
.review-date-postpone-all {
padding: 4px 10px;
@ -1030,8 +1033,8 @@ button.disabled:hover {
border-color: var(--interactive-accent);
}
.review-note-item.overdue-note {
background-color: var(--background-modifier-error-hover);
border-left: 3px solid var(--text-error);
background-color: var(--sf-overdue-bg);
border-left: 3px solid var(--sf-overdue-accent);
}
.review-note-title {
font-weight: 500;
@ -1913,6 +1916,888 @@ button.disabled:hover {
padding: 1px 3px;
}
}
.calendar-events-container {
display: flex;
flex-wrap: wrap;
gap: 2px;
margin-top: 2px;
align-items: center;
}
.calendar-event-tab {
font-size: 9px;
padding: 1px 3px;
border-radius: 2px;
color: white;
cursor: pointer;
transition: transform 0.2s ease, opacity 0.2s ease;
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-weight: 500;
line-height: 1.2;
}
.calendar-event-tab:hover {
transform: scale(1.05);
opacity: 0.9;
z-index: 10;
}
.calendar-events-more {
font-size: 8px;
padding: 1px 3px;
border-radius: 2px;
background-color: var(--text-muted);
color: var(--background-primary);
font-weight: 600;
min-width: 12px;
text-align: center;
line-height: 1.2;
}
.calendar-day.has-events {
border-left: 3px solid var(--interactive-accent);
}
.upcoming-events-wrapper {
margin-top: var(--sf-space-md);
padding-top: var(--sf-space-md);
border-top: 1px solid var(--background-modifier-border);
}
.upcoming-events-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: var(--sf-space-sm);
padding-bottom: var(--sf-space-sm);
border-bottom: 1px solid var(--background-modifier-border-hover);
}
.upcoming-events-title {
font-weight: 600;
font-size: 16px;
color: var(--text-normal);
display: flex;
align-items: center;
gap: var(--sf-space-xs);
}
.upcoming-events-subtitle {
font-size: 12px;
color: var(--text-muted);
font-weight: 400;
}
.upcoming-events-container {
max-height: 300px;
overflow-y: auto;
padding-right: var(--sf-space-xs);
}
.upcoming-events-container::-webkit-scrollbar {
width: 4px;
}
.upcoming-events-container::-webkit-scrollbar-track {
background: var(--background-secondary);
border-radius: 2px;
}
.upcoming-events-container::-webkit-scrollbar-thumb {
background: var(--text-muted);
border-radius: 2px;
}
.upcoming-events-container::-webkit-scrollbar-thumb:hover {
background: var(--text-faint);
}
.upcoming-events-empty {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: var(--sf-space-lg) var(--sf-space-md);
text-align: center;
color: var(--text-muted);
}
.upcoming-events-empty-icon {
font-size: 24px;
margin-bottom: var(--sf-space-sm);
opacity: 0.5;
}
.upcoming-events-empty-text {
font-weight: 500;
margin-bottom: var(--sf-space-xs);
color: var(--text-muted);
}
.upcoming-events-empty-subtext {
font-size: 12px;
color: var(--text-faint);
}
.upcoming-events-day-section {
margin-bottom: var(--sf-space-md);
border-radius: var(--radius-m);
background-color: var(--background-secondary);
overflow: hidden;
transition: var(--sf-transition);
}
.upcoming-events-day-section:hover {
background-color: var(--background-modifier-hover);
}
.upcoming-events-day-section.today {
background-color: rgba(var(--interactive-accent-rgb), 0.1);
border: 1px solid rgba(var(--interactive-accent-rgb), 0.2);
}
.upcoming-events-day-section.tomorrow {
background-color: rgba(var(--interactive-accent-rgb), 0.05);
}
.upcoming-events-day-header {
display: flex;
align-items: center;
padding: var(--sf-space-sm) var(--sf-space-md);
background-color: var(--background-modifier-border);
border-bottom: 1px solid var(--background-modifier-border-hover);
}
.upcoming-events-day-title {
font-weight: 600;
font-size: 14px;
color: var(--text-normal);
}
.upcoming-events-day-section.today .upcoming-events-day-title {
color: var(--interactive-accent);
}
.upcoming-events-day-events {
padding: var(--sf-space-xs);
}
.upcoming-events-event-item {
display: flex;
align-items: flex-start;
padding: var(--sf-space-sm) var(--sf-space-md);
margin: var(--sf-space-xs) 0;
border-radius: var(--radius-s);
cursor: pointer;
transition: var(--sf-transition);
background-color: var(--background-primary);
border: 1px solid transparent;
}
.upcoming-events-event-item:hover {
background-color: var(--background-modifier-hover);
border-color: var(--background-modifier-border);
transform: translateX(2px);
}
.upcoming-events-event-color {
width: 4px;
height: 100%;
min-height: 32px;
border-radius: 2px;
margin-right: var(--sf-space-sm);
flex-shrink: 0;
margin-top: 2px;
margin-bottom: 2px;
}
.upcoming-events-event-content {
flex: 1;
min-width: 0;
}
.upcoming-events-event-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 2px;
gap: var(--sf-space-sm);
}
.upcoming-events-event-title {
font-weight: 500;
font-size: 13px;
color: var(--text-normal);
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.upcoming-events-event-time {
font-size: 11px;
color: var(--text-muted);
font-weight: 400;
flex-shrink: 0;
}
.upcoming-events-event-details {
margin-bottom: 2px;
}
.upcoming-events-event-location {
font-size: 11px;
color: var(--text-muted);
display: flex;
align-items: center;
gap: 2px;
margin-bottom: 2px;
}
.upcoming-events-event-description {
font-size: 11px;
color: var(--text-faint);
line-height: 1.3;
margin-bottom: 2px;
}
.upcoming-events-event-category {
font-size: 10px;
color: var(--text-muted);
background-color: var(--background-modifier-border);
padding: 1px 4px;
border-radius: 2px;
display: inline-block;
text-transform: capitalize;
font-weight: 500;
}
.event-modal-datetime-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: var(--sf-space-md);
margin-bottom: var(--sf-space-md);
}
.event-modal-category-color-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: var(--sf-space-md);
margin-bottom: var(--sf-space-md);
}
.event-modal-buttons {
display: flex;
gap: var(--sf-space-sm);
justify-content: flex-end;
margin-top: var(--sf-space-lg);
padding-top: var(--sf-space-md);
border-top: 1px solid var(--background-modifier-border);
}
.event-modal-buttons button {
padding: var(--sf-space-sm) var(--sf-space-md);
border-radius: var(--radius-s);
border: 1px solid var(--background-modifier-border);
cursor: pointer;
transition: var(--sf-transition);
font-weight: 500;
}
.event-modal-buttons button.mod-cta {
background-color: var(--interactive-accent);
color: var(--text-on-accent);
border-color: var(--interactive-accent);
}
.event-modal-buttons button.mod-cta:hover {
background-color: var(--interactive-accent-hover);
}
.event-modal-buttons button:not(.mod-cta) {
background-color: var(--background-secondary);
color: var(--text-normal);
}
.event-modal-buttons button:not(.mod-cta):hover {
background-color: var(--background-modifier-hover);
}
.event-modal-buttons button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
@media (max-width: 768px) {
.event-modal-datetime-row,
.event-modal-category-color-row {
grid-template-columns: 1fr;
gap: var(--sf-space-sm);
}
.upcoming-events-container {
max-height: 200px;
}
.calendar-event-tab {
font-size: 8px;
padding: 1px 2px;
}
.upcoming-events-event-item {
padding: var(--sf-space-xs) var(--sf-space-sm);
}
.upcoming-events-event-title {
font-size: 12px;
}
.upcoming-events-event-time {
font-size: 10px;
}
}
.calendar-day {
position: relative;
}
.calendar-day-add-event {
position: absolute;
top: 2px;
right: 2px;
width: 16px;
height: 16px;
display: flex;
align-items: center;
justify-content: center;
background-color: var(--interactive-accent);
color: var(--text-on-accent);
border-radius: 50%;
font-size: 10px;
cursor: pointer;
opacity: 0;
transform: scale(0.8);
transition: all 0.2s ease;
z-index: 20;
box-shadow: var(--sf-shadow-sm);
}
.calendar-day:hover .calendar-day-add-event {
opacity: 1;
transform: scale(1);
}
.calendar-day-add-event:hover {
background-color: var(--interactive-accent-hover);
transform: scale(1.1);
box-shadow: var(--sf-shadow-md);
}
.calendar-day.empty:hover .calendar-day-add-event {
opacity: 0.7;
}
.calendar-day.has-reviews .calendar-day-add-event {
top: 28px;
}
.calendar-day.has-events .calendar-day-add-event {
top: 28px;
}
.calendar-day.has-reviews.has-events .calendar-day-add-event {
top: 44px;
}
.event-modal {
max-width: 600px;
max-height: 90vh;
overflow-y: auto;
}
.event-modal .modal-content {
border-radius: 12px;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
}
.event-modal-header {
display: flex;
align-items: center;
gap: var(--sf-space-md);
padding: var(--sf-space-lg) var(--sf-space-lg) var(--sf-space-md) var(--sf-space-lg);
background: linear-gradient(135deg, var(--interactive-accent) 0%, var(--interactive-accent-hover) 100%);
color: var(--text-on-accent);
border-radius: 12px 12px 0 0;
}
.event-modal-header-icon {
width: 40px;
height: 40px;
display: flex;
align-items: center;
justify-content: center;
background-color: rgba(255, 255, 255, 0.2);
border-radius: 50%;
font-size: 20px;
}
.event-modal-header-text {
flex: 1;
}
.event-modal-title {
margin: 0 0 var(--sf-space-xs) 0;
font-size: 24px;
font-weight: 600;
color: var(--text-on-accent);
}
.event-modal-subtitle {
margin: 0;
font-size: 14px;
opacity: 0.9;
color: var(--text-on-accent);
}
.event-modal-form {
padding: var(--sf-space-lg);
}
.event-modal-section {
margin-bottom: var(--sf-space-lg);
}
.event-modal-label {
display: block;
font-weight: 600;
font-size: 14px;
color: var(--text-normal);
margin-bottom: var(--sf-space-sm);
}
.event-modal-label.required::after {
content: " *";
color: var(--text-error);
}
.event-modal-input,
.event-modal-textarea,
.event-modal-select {
width: 100%;
padding: var(--sf-space-sm) var(--sf-space-md);
border: 2px solid var(--background-modifier-border);
border-radius: 8px;
font-size: 14px;
background-color: var(--background-primary);
color: var(--text-normal);
transition: all 0.2s ease;
box-sizing: border-box;
}
.event-modal-input:focus,
.event-modal-textarea:focus,
.event-modal-select:focus {
outline: none;
border-color: var(--interactive-accent);
box-shadow: 0 0 0 3px rgba(var(--interactive-accent-rgb), 0.1);
}
.event-modal-input:hover,
.event-modal-textarea:hover,
.event-modal-select:hover {
border-color: var(--background-modifier-border-hover);
}
.event-modal-textarea {
resize: vertical;
min-height: 80px;
font-family: inherit;
}
.event-modal-datetime-section {
background-color: var(--background-secondary);
padding: var(--sf-space-md);
border-radius: 8px;
border: 1px solid var(--background-modifier-border);
}
.event-modal-datetime-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: var(--sf-space-md);
margin-bottom: var(--sf-space-md);
}
.event-modal-input-group {
position: relative;
display: flex;
align-items: center;
}
.event-modal-input-icon {
position: absolute;
left: var(--sf-space-sm);
color: var(--text-muted);
font-size: 16px;
z-index: 1;
pointer-events: none;
}
.event-modal-input-group .event-modal-input {
padding-left: 36px;
}
.event-modal-all-day-container {
display: flex;
align-items: center;
gap: var(--sf-space-sm);
}
.event-modal-checkbox {
width: 20px;
height: 20px;
accent-color: var(--interactive-accent);
cursor: pointer;
}
.event-modal-checkbox-label {
font-size: 14px;
color: var(--text-normal);
cursor: pointer;
user-select: none;
}
.event-modal-category-color-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: var(--sf-space-md);
}
.event-modal-category-container,
.event-modal-color-container {
display: flex;
flex-direction: column;
gap: var(--sf-space-sm);
}
.event-modal-color-label {
font-size: 12px;
color: var(--text-muted);
font-weight: 500;
}
.event-modal-color-picker {
display: flex;
align-items: center;
gap: var(--sf-space-sm);
}
.event-modal-color-input {
width: 50px;
height: 36px;
border: 2px solid var(--background-modifier-border);
border-radius: 6px;
cursor: pointer;
padding: 2px;
}
.event-modal-color-preview {
width: 36px;
height: 36px;
border-radius: 6px;
border: 2px solid var(--background-modifier-border);
cursor: pointer;
transition: transform 0.2s ease;
}
.event-modal-color-preview:hover {
transform: scale(1.05);
}
.event-modal-recurrence-container {
margin-bottom: var(--sf-space-md);
}
.event-modal-recurrence-end-container {
background-color: var(--background-secondary);
padding: var(--sf-space-md);
border-radius: 8px;
border: 1px solid var(--background-modifier-border);
margin-top: var(--sf-space-md);
}
.event-modal-actions {
display: flex;
gap: var(--sf-space-sm);
justify-content: flex-end;
padding: var(--sf-space-lg);
border-top: 1px solid var(--background-modifier-border);
background-color: var(--background-secondary);
margin: 0 -var(--sf-space-lg) -var(--sf-space-lg);
border-radius: 0 0 12px 12px;
}
.event-modal-btn {
padding: var(--sf-space-sm) var(--sf-space-lg);
border-radius: 8px;
border: none;
font-size: 14px;
font-weight: 600;
cursor: pointer;
transition: all 0.2s ease;
min-width: 120px;
}
.event-modal-btn-primary {
background-color: var(--interactive-accent);
color: var(--text-on-accent);
}
.event-modal-btn-primary:hover:not(:disabled) {
background-color: var(--interactive-accent-hover);
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(var(--interactive-accent-rgb), 0.3);
}
.event-modal-btn-secondary {
background-color: var(--background-modifier-border);
color: var(--text-normal);
}
.event-modal-btn-secondary:hover {
background-color: var(--background-modifier-border-hover);
transform: translateY(-1px);
}
.event-modal-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
transform: none;
}
@media (max-width: 768px) {
.event-modal {
max-width: 95vw;
margin: var(--sf-space-md);
}
.event-modal-datetime-grid,
.event-modal-category-color-grid {
grid-template-columns: 1fr;
gap: var(--sf-space-sm);
}
.event-modal-header {
padding: var(--sf-space-md);
}
.event-modal-form {
padding: var(--sf-space-md);
}
.event-modal-actions {
padding: var(--sf-space-md);
flex-direction: column;
}
.event-modal-btn {
width: 100%;
}
}
.event-modal {
animation: modalSlideIn 0.3s ease-out;
}
@keyframes modalSlideIn {
from {
opacity: 0;
transform: translateY(-20px) scale(0.95);
}
to {
opacity: 1;
transform: translateY(0) scale(1);
}
}
.event-modal-input:focus,
.event-modal-textarea:focus,
.event-modal-select:focus {
transform: translateY(-1px);
}
.event-modal-input.error,
.event-modal-textarea.error,
.event-modal-select.error {
border-color: var(--text-error);
box-shadow: 0 0 0 3px rgba(var(--text-error-rgb), 0.1);
}
.event-modal-btn.loading {
position: relative;
color: transparent;
}
.event-modal-btn.loading::after {
content: "";
position: absolute;
width: 16px;
height: 16px;
top: 50%;
left: 50%;
margin-left: -8px;
margin-top: -8px;
border: 2px solid var(--text-on-accent);
border-radius: 50%;
border-top-color: transparent;
animation: spin 1s ease-in-out infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
.event-modal {
max-width: 500px;
max-height: 85vh;
}
.event-modal .modal-content {
border-radius: 8px;
}
.event-modal-header {
display: flex;
align-items: center;
gap: var(--sf-space-sm);
padding: var(--sf-space-md) var(--sf-space-lg);
background: var(--interactive-accent);
color: var(--text-on-accent);
border-radius: 8px 8px 0 0;
}
.event-modal-header-icon {
width: 24px;
height: 24px;
display: flex;
align-items: center;
justify-content: center;
background-color: rgba(255, 255, 255, 0.2);
border-radius: 50%;
font-size: 14px;
}
.event-modal-header-text {
flex: 1;
}
.event-modal-title {
margin: 0;
font-size: 18px;
font-weight: 600;
color: var(--text-on-accent);
}
.event-modal-form {
padding: var(--sf-space-md);
}
.event-modal-row {
display: flex;
gap: var(--sf-space-sm);
margin-bottom: var(--sf-space-sm);
}
.event-modal-row.event-modal-full-width {
display: block;
}
.event-modal-field-group {
flex: 1;
display: flex;
flex-direction: column;
gap: 2px;
}
.event-modal-label {
font-size: 12px;
font-weight: 600;
color: var(--text-normal);
margin: 0;
}
.event-modal-label.required::after {
content: " *";
color: var(--text-error);
}
.event-modal-input,
.event-modal-textarea,
.event-modal-select {
width: 100%;
padding: 6px 8px;
border: 1px solid var(--background-modifier-border);
border-radius: 4px;
font-size: 13px;
background-color: var(--background-primary);
color: var(--text-normal);
transition: border-color 0.2s ease;
box-sizing: border-box;
}
.event-modal-input:focus,
.event-modal-textarea:focus,
.event-modal-select:focus {
outline: none;
border-color: var(--interactive-accent);
}
.event-modal-textarea {
resize: vertical;
min-height: 40px;
font-family: inherit;
}
.event-modal-checkbox {
width: 16px;
height: 16px;
accent-color: var(--interactive-accent);
cursor: pointer;
margin-right: 6px;
}
.event-modal-checkbox-label {
font-size: 13px;
color: var(--text-normal);
cursor: pointer;
user-select: none;
display: flex;
align-items: center;
}
.event-modal-color-picker-compact {
display: flex;
align-items: center;
gap: 6px;
}
.event-modal-color-input {
width: 32px;
height: 28px;
border: 1px solid var(--background-modifier-border);
border-radius: 4px;
cursor: pointer;
padding: 1px;
}
.event-modal-recurrence-end-row {
display: none;
}
.event-modal-recurrence-end-row.visible {
display: flex;
}
.event-modal-actions {
display: flex;
gap: var(--sf-space-sm);
justify-content: flex-end;
padding: var(--sf-space-md) var(--sf-space-lg);
border-top: 1px solid var(--background-modifier-border);
background-color: var(--background-secondary);
margin: 0 -var(--sf-space-md) -var(--sf-space-md);
border-radius: 0 0 8px 8px;
}
.event-modal-btn {
padding: 6px 16px;
border-radius: 4px;
border: none;
font-size: 13px;
font-weight: 500;
cursor: pointer;
transition: all 0.2s ease;
min-width: 80px;
}
.event-modal-btn-primary {
background-color: var(--interactive-accent);
color: var(--text-on-accent);
}
.event-modal-btn-primary:hover:not(:disabled) {
background-color: var(--interactive-accent-hover);
}
.event-modal-btn-secondary {
background-color: var(--background-modifier-border);
color: var(--text-normal);
}
.event-modal-btn-secondary:hover {
background-color: var(--background-modifier-border-hover);
}
.event-modal-btn-danger {
background-color: var(--color-red, #dc3545);
color: white;
}
.event-modal-btn-danger:hover {
background-color: var(--color-red-hover, #c82333);
transform: translateY(-1px);
}
.event-modal-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
@media (max-width: 600px) {
.event-modal {
max-width: 95vw;
margin: var(--sf-space-sm);
}
.event-modal-row {
flex-direction: column;
gap: var(--sf-space-xs);
}
.event-modal-header {
padding: var(--sf-space-sm) var(--sf-space-md);
}
.event-modal-form {
padding: var(--sf-space-sm);
}
.event-modal-actions {
padding: var(--sf-space-sm) var(--sf-space-md);
flex-direction: row;
}
.event-modal-btn {
flex: 1;
min-width: auto;
}
}
.calendar-event-tooltip {
position: fixed;
z-index: 1000;
background: var(--background-primary);
border: 1px solid var(--background-modifier-border);
border-radius: 8px;
padding: 12px;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.15);
max-width: 280px;
font-size: 13px;
line-height: 1.4;
pointer-events: none;
opacity: 0;
transform: translateY(-4px);
transition: all 0.2s ease;
}
.calendar-event-tooltip .tooltip-title {
font-weight: 600;
font-size: 14px;
color: var(--text-normal);
margin-bottom: 6px;
padding-bottom: 6px;
border-bottom: 1px solid var(--background-modifier-border);
}
.calendar-event-tooltip .tooltip-date,
.calendar-event-tooltip .tooltip-time,
.calendar-event-tooltip .tooltip-description,
.calendar-event-tooltip .tooltip-location,
.calendar-event-tooltip .tooltip-category {
margin: 3px 0;
color: var(--text-muted);
font-size: 12px;
display: flex;
align-items: center;
gap: 4px;
}
.calendar-event-tooltip .tooltip-description {
max-height: 60px;
overflow: hidden;
text-overflow: ellipsis;
display: -webkit-box;
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
}
.event-modal-datetime-section,
.event-modal-datetime-grid,
.event-modal-input-group,
.event-modal-input-icon,
.event-modal-all-day-container,
.event-modal-category-color-grid,
.event-modal-category-container,
.event-modal-color-container,
.event-modal-color-label,
.event-modal-recurrence-container,
.event-modal-recurrence-end-container {
display: none;
}
/* styles/mcq.css */
.mcq-header-container {

File diff suppressed because it is too large Load diff

View file

@ -1,8 +1,11 @@
import { Notice, setIcon } from "obsidian";
import SpaceforgePlugin from "../main";
import { ReviewSchedule } from "../models/review-schedule";
import { CalendarEvent } from "../models/calendar-event";
import { DateUtils } from "../utils/dates";
import { EstimationUtils } from "../utils/estimation";
import { UpcomingEvents } from "./upcoming-events";
import { EventModal } from "./event-modal";
/**
* Interface for grouped reviews by date
@ -13,6 +16,14 @@ interface DateReviews {
totalTime: number;
}
/**
* Interface for grouped events by date
*/
interface DateEvents {
timestamp: number;
events: CalendarEvent[];
}
/**
* Calendar view component for review schedule
*/
@ -37,10 +48,21 @@ export class CalendarView {
*/
reviewsByDate: Map<string, DateReviews> = new Map();
/**
* Events grouped by date
*/
eventsByDate: Map<string, DateEvents> = new Map();
// Persistent UI elements
private calendarHeaderEl: HTMLElement | null = null;
private monthTitleEl: HTMLElement | null = null;
private calendarGridEl: HTMLElement | null = null;
private upcomingEventsEl: HTMLElement | null = null;
private upcomingEventsComponent: UpcomingEvents | null = null;
// Tooltip elements
private tooltipEl: HTMLElement | null = null;
private tooltipTimeout: NodeJS.Timeout | null = null;
/**
* Initialize calendar view
@ -63,10 +85,16 @@ export class CalendarView {
this.updateCalendarHeader(); // Updates month title
await this.loadReviewsData(); // Preloads review data for the current view
await this.loadEventsData(); // Preloads event data for the current view
if (this.calendarGridEl) {
this.renderCalendarGridContent(this.calendarGridEl); // Renders/updates the grid
}
// Render upcoming events
if (this.upcomingEventsComponent) {
await this.upcomingEventsComponent.render();
}
}
private ensureCalendarBaseStructure(): void {
@ -103,12 +131,25 @@ export class CalendarView {
this.currentDate = new Date();
this.render();
});
const addEventBtn = this.calendarHeaderEl.createDiv("calendar-add-event-btn");
setIcon(addEventBtn, "plus");
addEventBtn.addEventListener("click", () => {
this.openCreateEventModal();
});
}
if (!this.calendarGridEl || !calendarContainer.contains(this.calendarGridEl)) {
this.calendarGridEl?.remove();
this.calendarGridEl = calendarContainer.createDiv("calendar-grid");
}
// Create or update upcoming events container
if (!this.upcomingEventsEl || !calendarContainer.contains(this.upcomingEventsEl)) {
this.upcomingEventsEl?.remove();
this.upcomingEventsEl = calendarContainer.createDiv("upcoming-events-wrapper");
this.upcomingEventsComponent = new UpcomingEvents(this.upcomingEventsEl, this.plugin);
}
}
/**
@ -151,6 +192,43 @@ export class CalendarView {
}
}
}
/**
* Load and organize event data by date
*/
async loadEventsData(): Promise<void> {
this.eventsByDate = new Map();
if (!this.plugin.settings.enableCalendarEvents || !this.plugin.calendarEventService) {
return;
}
const { year, month } = this.getCalendarData();
const startDate = new Date(year, month, 1);
const endDate = new Date(year, month + 1, 0); // Last day of month
const eventsInRange = this.plugin.calendarEventService.getEventsInRange(
startDate.getTime(),
endDate.getTime()
);
for (const event of eventsInRange) {
const eventDayStart = DateUtils.startOfUTCDay(new Date(event.date));
const dateKey = eventDayStart.toString();
if (!this.eventsByDate.has(dateKey)) {
this.eventsByDate.set(dateKey, {
timestamp: eventDayStart,
events: []
});
}
const dateEvents = this.eventsByDate.get(dateKey);
if (dateEvents) {
dateEvents.events.push(event);
}
}
}
/**
* Render or update the calendar grid content
@ -207,6 +285,9 @@ export class CalendarView {
}
const dateReviews = this.reviewsByDate.get(dateKey);
const dateEvents = this.eventsByDate.get(dateKey);
// Render reviews
if (dateReviews && dateReviews.notes.length > 0) {
dayCell.addClass("has-reviews");
@ -237,6 +318,59 @@ export class CalendarView {
else if (dateReviews.notes.length > 5) dayCell.addClass("medium-load");
else dayCell.addClass("light-load");
}
// Render events as small tabs
if (dateEvents && dateEvents.events.length > 0) {
dayCell.addClass("has-events");
const eventsContainer = dayCell.createDiv("calendar-events-container");
// Show up to 3 event tabs
const eventsToShow = dateEvents.events.slice(0, 3);
eventsToShow.forEach(event => {
const eventTab = eventsContainer.createDiv("calendar-event-tab");
eventTab.setText(event.title.substring(0, 8) + (event.title.length > 8 ? "..." : ""));
// Set color based on event category or custom color
const eventColor = this.plugin.calendarEventService?.getEventColor(event) || '#95A5A6';
eventTab.style.backgroundColor = eventColor;
// Add hover handler for tooltip
eventTab.addEventListener("mouseenter", (e) => {
this.showEventTooltip(eventTab, event);
});
eventTab.addEventListener("mouseleave", (e) => {
this.hideEventTooltip();
});
// Add click handler for event
eventTab.addEventListener("click", (e) => {
e.stopPropagation(); // Prevent day cell click
this.showEventDetails(event);
});
// Add double-click handler for editing
eventTab.addEventListener("dblclick", (e) => {
e.stopPropagation(); // Prevent day cell click
this.openEditEventModal(event);
});
});
// Show "more" indicator if there are more events
if (dateEvents.events.length > 3) {
const moreIndicator = eventsContainer.createDiv("calendar-events-more");
moreIndicator.setText(`+${dateEvents.events.length - 3}`);
}
}
// Add hover plus button for creating events
const addEventBtn = dayCell.createDiv("calendar-day-add-event");
setIcon(addEventBtn, "plus");
addEventBtn.addEventListener("click", (e) => {
e.stopPropagation(); // Prevent day cell click
this.openCreateEventModalForDate(currentDateObj);
});
dayOfMonth++;
} else {
dayCell.addClass("empty");
@ -278,4 +412,233 @@ export class CalendarView {
today.getDate() === day
);
}
/**
* Show event details modal
*
* @param event Event to show details for
*/
showEventDetails(event: CalendarEvent): void {
// For now, just show a notice with event details
// TODO: Create a proper modal for event details
const eventDate = new Date(event.date);
const dateStr = eventDate.toLocaleDateString(undefined, {
weekday: 'long',
year: 'numeric',
month: 'long',
day: 'numeric'
});
let details = `📅 ${event.title}\n📆 ${dateStr}`;
if (event.time) {
details += `\n🕐 ${event.time}`;
}
if (event.description) {
details += `\n📝 ${event.description}`;
}
if (event.location) {
details += `\n📍 ${event.location}`;
}
details += `\n🏷 ${event.category}`;
new Notice(details, 8000);
}
/**
* Open create event modal
*/
openCreateEventModal(): void {
const modal = new EventModal(
this.plugin.app,
this.plugin,
null,
async (savedEvent) => {
// Refresh the calendar view after saving
await this.render();
}
);
modal.open();
}
/**
* Open create event modal for a specific date
*
* @param date Date to pre-fill in the modal
*/
openCreateEventModalForDate(date: Date): void {
// Create a default event with the selected date
const defaultEvent = {
title: "",
date: date.getTime(),
isAllDay: true,
category: this.plugin.settings.defaultEventCategory as any || 'personal',
recurrence: 'none' as any
};
const modal = new EventModal(
this.plugin.app,
this.plugin,
null,
async (savedEvent) => {
// Refresh the calendar view after saving
await this.render();
}
);
modal.open();
// Pre-fill the date in the modal after it opens
setTimeout(() => {
const dateInput = modal.contentEl.querySelector('input[type="date"]') as HTMLInputElement;
if (dateInput) {
dateInput.value = date.toISOString().split('T')[0];
}
}, 100);
}
/**
* Open edit event modal
*
* @param event Event to edit
*/
openEditEventModal(event: CalendarEvent): void {
const modal = new EventModal(
this.plugin.app,
this.plugin,
event,
async (savedEvent) => {
// Refresh the calendar view after saving
await this.render();
}
);
modal.open();
}
/**
* Clean up resources when the calendar view is destroyed
*/
destroy(): void {
this.hideEventTooltip();
// Clear other references
this.calendarHeaderEl = null;
this.monthTitleEl = null;
this.calendarGridEl = null;
this.upcomingEventsEl = null;
this.upcomingEventsComponent = null;
}
/**
* Show event tooltip
*
* @param targetEl Element to show tooltip for
* @param event Event to show details for
*/
showEventTooltip(targetEl: HTMLElement, event: CalendarEvent): void {
// Clear any existing timeout
if (this.tooltipTimeout) {
clearTimeout(this.tooltipTimeout);
}
// Small delay before showing tooltip to avoid flickering
this.tooltipTimeout = setTimeout(() => {
this.createEventTooltip(targetEl, event);
}, 300);
}
/**
* Hide event tooltip
*/
hideEventTooltip(): void {
// Clear any pending tooltip
if (this.tooltipTimeout) {
clearTimeout(this.tooltipTimeout);
this.tooltipTimeout = null;
}
// Remove existing tooltip
if (this.tooltipEl) {
this.tooltipEl.remove();
this.tooltipEl = null;
}
}
/**
* Create and show the tooltip element
*
* @param targetEl Element to position tooltip relative to
* @param event Event to show details for
*/
private createEventTooltip(targetEl: HTMLElement, event: CalendarEvent): void {
// Remove existing tooltip if any
this.hideEventTooltip();
// Create tooltip element
this.tooltipEl = document.createElement("div");
this.tooltipEl.className = "calendar-event-tooltip";
// Format event details
const eventDate = new Date(event.date);
const dateStr = eventDate.toLocaleDateString(undefined, {
weekday: 'short',
month: 'short',
day: 'numeric'
});
let tooltipContent = `<div class="tooltip-title">${event.title}</div>`;
tooltipContent += `<div class="tooltip-date">📅 ${dateStr}</div>`;
if (event.time) {
tooltipContent += `<div class="tooltip-time">🕐 ${event.time}</div>`;
}
if (event.description) {
tooltipContent += `<div class="tooltip-description">📝 ${event.description}</div>`;
}
if (event.location) {
tooltipContent += `<div class="tooltip-location">📍 ${event.location}</div>`;
}
tooltipContent += `<div class="tooltip-category">🏷️ ${event.category}</div>`;
this.tooltipEl.innerHTML = tooltipContent;
// Position tooltip
const rect = targetEl.getBoundingClientRect();
const tooltipRect = this.tooltipEl.getBoundingClientRect();
// Add to DOM temporarily to get dimensions
document.body.appendChild(this.tooltipEl);
// Calculate position
let left = rect.left + (rect.width / 2) - (this.tooltipEl.offsetWidth / 2);
let top = rect.bottom + 8;
// Adjust if tooltip goes off screen
if (left < 8) left = 8;
if (left + this.tooltipEl.offsetWidth > window.innerWidth - 8) {
left = window.innerWidth - this.tooltipEl.offsetWidth - 8;
}
if (top + this.tooltipEl.offsetHeight > window.innerHeight - 8) {
top = rect.top - this.tooltipEl.offsetHeight - 8;
}
this.tooltipEl.style.left = `${left}px`;
this.tooltipEl.style.top = `${top}px`;
// Add fade-in animation
this.tooltipEl.style.opacity = '0';
this.tooltipEl.style.transform = 'translateY(-4px)';
requestAnimationFrame(() => {
if (this.tooltipEl) {
this.tooltipEl.style.opacity = '1';
this.tooltipEl.style.transform = 'translateY(0)';
}
});
}
}

436
ui/event-modal.ts Normal file
View file

@ -0,0 +1,436 @@
import { App, Modal, Setting, Notice, setIcon } from "obsidian";
import SpaceforgePlugin from "../main";
import { CalendarEvent, EventCategory, EventRecurrence, EVENT_CATEGORY_COLORS } from "../models/calendar-event";
/**
* Modal for creating or editing calendar events
*/
export class EventModal extends Modal {
plugin: SpaceforgePlugin;
event: CalendarEvent | null;
onSave: (event: CalendarEvent) => void;
// Form elements
titleInput: HTMLInputElement;
descriptionInput: HTMLTextAreaElement;
dateInput: HTMLInputElement;
timeInput: HTMLInputElement;
locationInput: HTMLInputElement;
categorySelect: HTMLSelectElement;
colorInput: HTMLInputElement;
isAllDayToggle: HTMLInputElement;
recurrenceSelect: HTMLSelectElement;
recurrenceEndDateInput: HTMLInputElement;
constructor(app: App, plugin: SpaceforgePlugin, event: CalendarEvent | null = null, onSave: (event: CalendarEvent) => void) {
super(app);
this.plugin = plugin;
this.event = event;
this.onSave = onSave;
}
onOpen() {
const { contentEl } = this;
contentEl.empty();
contentEl.addClass("event-modal");
// Compact header
const header = contentEl.createDiv("event-modal-header");
const headerIcon = header.createDiv("event-modal-header-icon");
setIcon(headerIcon, this.event ? "edit" : "calendar-plus");
const headerText = header.createDiv("event-modal-header-text");
headerText.createEl("h2", {
text: this.event ? "Edit Event" : "New Event",
cls: "event-modal-title"
});
// Compact form container
const formContainer = contentEl.createDiv("event-modal-form");
// Title row
const titleRow = formContainer.createDiv("event-modal-row");
const titleGroup = titleRow.createDiv("event-modal-field-group");
titleGroup.createEl("label", { text: "Title", cls: "event-modal-label required" });
this.titleInput = titleGroup.createEl("input", {
type: "text",
cls: "event-modal-input",
placeholder: "Event title..."
});
if (this.event?.title) {
this.titleInput.value = this.event.title;
}
this.titleInput.addEventListener("input", () => this.validateForm());
// Date & Time row
const dateTimeRow = formContainer.createDiv("event-modal-row");
const dateGroup = dateTimeRow.createDiv("event-modal-field-group");
dateGroup.createEl("label", { text: "Date", cls: "event-modal-label" });
this.dateInput = dateGroup.createEl("input", {
type: "date",
cls: "event-modal-input"
});
if (this.event?.date) {
const date = new Date(this.event.date);
this.dateInput.value = date.toISOString().split('T')[0];
} else {
this.dateInput.value = new Date().toISOString().split('T')[0];
}
this.dateInput.addEventListener("change", () => this.validateForm());
const timeGroup = dateTimeRow.createDiv("event-modal-field-group");
timeGroup.createEl("label", { text: "Time", cls: "event-modal-label" });
this.timeInput = timeGroup.createEl("input", {
type: "time",
cls: "event-modal-input"
});
if (this.event?.time) {
this.timeInput.value = this.event.time;
}
this.timeInput.addEventListener("change", () => this.updateAllDayToggle());
// All-day toggle row
const allDayRow = formContainer.createDiv("event-modal-row");
const allDayGroup = allDayRow.createDiv("event-modal-field-group");
this.isAllDayToggle = allDayGroup.createEl("input", {
type: "checkbox",
cls: "event-modal-checkbox"
}) as HTMLInputElement;
this.isAllDayToggle.checked = this.event?.isAllDay ?? false;
this.isAllDayToggle.addEventListener("change", () => this.toggleTimeInput());
const allDayLabel = allDayGroup.createEl("label", {
text: "All-day event",
cls: "event-modal-checkbox-label"
});
// Location row
const locationRow = formContainer.createDiv("event-modal-row");
const locationGroup = locationRow.createDiv("event-modal-field-group");
locationGroup.createEl("label", { text: "Location", cls: "event-modal-label" });
this.locationInput = locationGroup.createEl("input", {
type: "text",
cls: "event-modal-input",
placeholder: "Location (optional)..."
});
if (this.event?.location) {
this.locationInput.value = this.event.location;
}
// Category & Color row
const categoryColorRow = formContainer.createDiv("event-modal-row");
const categoryGroup = categoryColorRow.createDiv("event-modal-field-group");
categoryGroup.createEl("label", { text: "Category", cls: "event-modal-label" });
this.categorySelect = categoryGroup.createEl("select", {
cls: "event-modal-select"
});
Object.values(EventCategory).forEach(category => {
const option = this.categorySelect.createEl("option", {
value: category,
text: category.charAt(0).toUpperCase() + category.slice(1)
});
this.categorySelect.appendChild(option);
});
if (this.event?.category) {
this.categorySelect.value = this.event.category;
} else {
this.categorySelect.value = this.plugin.settings.defaultEventCategory || 'personal';
}
this.categorySelect.addEventListener("change", () => this.updateColorFromCategory());
const colorGroup = categoryColorRow.createDiv("event-modal-field-group");
colorGroup.createEl("label", { text: "Color", cls: "event-modal-label" });
const colorPickerContainer = colorGroup.createDiv("event-modal-color-picker-compact");
this.colorInput = colorPickerContainer.createEl("input", {
type: "color",
cls: "event-modal-color-input"
}) as HTMLInputElement;
if (this.event?.color) {
this.colorInput.value = this.event.color;
} else {
this.updateColorFromCategory();
}
// Description row (full width)
const descriptionRow = formContainer.createDiv("event-modal-row event-modal-full-width");
const descriptionGroup = descriptionRow.createDiv("event-modal-field-group");
descriptionGroup.createEl("label", { text: "Description", cls: "event-modal-label" });
this.descriptionInput = descriptionGroup.createEl("textarea", {
cls: "event-modal-textarea",
placeholder: "Event details (optional)..."
});
this.descriptionInput.rows = 2;
if (this.event?.description) {
this.descriptionInput.value = this.event.description;
}
// Recurrence row
const recurrenceRow = formContainer.createDiv("event-modal-row");
const recurrenceGroup = recurrenceRow.createDiv("event-modal-field-group");
recurrenceGroup.createEl("label", { text: "Recurrence", cls: "event-modal-label" });
this.recurrenceSelect = recurrenceGroup.createEl("select", {
cls: "event-modal-select"
});
Object.values(EventRecurrence).forEach(recurrence => {
const option = this.recurrenceSelect.createEl("option", {
value: recurrence,
text: this.getRecurrenceDisplayText(recurrence)
});
this.recurrenceSelect.appendChild(option);
});
if (this.event?.recurrence) {
this.recurrenceSelect.value = this.event.recurrence;
} else {
this.recurrenceSelect.value = EventRecurrence.None;
}
this.recurrenceSelect.addEventListener("change", () => this.toggleRecurrenceEndDate());
// Recurrence end date row (conditional)
const recurrenceEndRow = formContainer.createDiv("event-modal-row event-modal-recurrence-end-row");
const recurrenceEndGroup = recurrenceEndRow.createDiv("event-modal-field-group");
recurrenceEndGroup.createEl("label", { text: "End Date", cls: "event-modal-label" });
this.recurrenceEndDateInput = recurrenceEndGroup.createEl("input", {
type: "date",
cls: "event-modal-input"
});
if (this.event?.recurrenceEndDate) {
const date = new Date(this.event.recurrenceEndDate);
this.recurrenceEndDateInput.value = date.toISOString().split('T')[0];
}
// Enhanced buttons section
const buttonContainer = contentEl.createDiv("event-modal-actions");
const cancelButton = buttonContainer.createEl("button", {
text: "Cancel",
cls: "event-modal-btn event-modal-btn-secondary"
});
cancelButton.addEventListener("click", () => this.close());
// Add delete button only for existing events
if (this.event) {
const deleteButton = buttonContainer.createEl("button", {
text: "Delete",
cls: "event-modal-btn event-modal-btn-danger"
});
deleteButton.addEventListener("click", () => this.deleteEvent());
}
const saveButton = buttonContainer.createEl("button", {
text: this.event ? "Update Event" : "Create Event",
cls: "event-modal-btn event-modal-btn-primary"
});
saveButton.addEventListener("click", () => this.saveEvent());
// Initial setup
this.validateForm();
this.updateAllDayToggle();
this.toggleTimeInput();
this.toggleRecurrenceEndDate();
this.updateColorPreview();
}
onClose() {
const { contentEl } = this;
contentEl.empty();
}
/**
* Validate form and enable/disable save button
*/
private validateForm(): void {
const saveButton = this.contentEl.querySelector(".event-modal-btn-primary") as HTMLButtonElement;
const isValid = this.titleInput.value.trim() !== "" && this.dateInput.value !== "";
if (saveButton) {
saveButton.disabled = !isValid;
}
}
/**
* Update color based on selected category
*/
private updateColorFromCategory(): void {
const category = this.categorySelect.value as EventCategory;
const categoryColor = EVENT_CATEGORY_COLORS[category];
this.colorInput.value = categoryColor;
this.updateColorPreview();
}
/**
* Update color preview
*/
private updateColorPreview(): void {
const colorPreview = this.contentEl.querySelector(".event-modal-color-preview") as HTMLElement;
if (colorPreview) {
colorPreview.style.backgroundColor = this.colorInput.value;
}
}
/**
* Toggle time input based on all-day setting
*/
private toggleTimeInput(): void {
const isAllDay = this.isAllDayToggle.checked;
const timeContainer = this.timeInput.closest(".event-modal-input-group") as HTMLElement;
if (timeContainer) {
timeContainer.style.display = isAllDay ? "none" : "block";
}
if (isAllDay) {
this.timeInput.value = "";
}
}
/**
* Update all-day toggle based on time input
*/
private updateAllDayToggle(): void {
const hasTime = this.timeInput.value !== "";
if (hasTime && this.isAllDayToggle.checked) {
this.isAllDayToggle.checked = false;
this.toggleTimeInput();
}
}
/**
* Toggle recurrence end date based on recurrence setting
*/
private toggleRecurrenceEndDate(): void {
const recurrence = this.recurrenceSelect.value as EventRecurrence;
const endDateContainer = this.recurrenceEndDateInput.closest(".event-modal-recurrence-end-container") as HTMLElement;
if (endDateContainer) {
endDateContainer.style.display = recurrence === EventRecurrence.None ? "none" : "block";
}
if (recurrence === EventRecurrence.None) {
this.recurrenceEndDateInput.value = "";
}
}
/**
* Get display text for recurrence option
*/
private getRecurrenceDisplayText(recurrence: EventRecurrence): string {
switch (recurrence) {
case EventRecurrence.None: return "None";
case EventRecurrence.Daily: return "Daily";
case EventRecurrence.Weekly: return "Weekly";
case EventRecurrence.Monthly: return "Monthly";
case EventRecurrence.Yearly: return "Yearly";
default: return recurrence;
}
}
/**
* Delete the event
*/
private async deleteEvent(): Promise<void> {
if (!this.event) return;
try {
const deleted = this.plugin.calendarEventService?.deleteEvent(this.event.id);
if (deleted) {
new Notice("Event deleted successfully");
await this.plugin.savePluginData();
// Trigger calendar refresh through the onSave callback
this.onSave(this.event);
this.close();
} else {
new Notice("Failed to delete event");
}
} catch (error) {
console.error("Error deleting event:", error);
new Notice("Error deleting event");
}
}
/**
* Save the event
*/
private async saveEvent(): Promise<void> {
try {
const title = this.titleInput.value.trim();
const description = this.descriptionInput.value.trim();
const date = new Date(this.dateInput.value);
const time = this.timeInput.value;
const location = this.locationInput.value.trim();
const category = this.categorySelect.value as EventCategory;
const color = this.colorInput.value;
const isAllDay = this.isAllDayToggle.checked;
const recurrence = this.recurrenceSelect.value as EventRecurrence;
const recurrenceEndDateText = this.recurrenceEndDateInput.value;
const recurrenceEndDate = recurrenceEndDateText ? new Date(recurrenceEndDateText).getTime() : undefined;
if (!title || !this.dateInput.value) {
new Notice("Please fill in all required fields");
return;
}
let eventData: Omit<CalendarEvent, 'id' | 'createdAt' | 'updatedAt'>;
if (this.event) {
// Update existing event
eventData = {
...this.event,
title,
description: description || undefined,
date: date.getTime(),
time: time || undefined,
location: location || undefined,
category,
color: color !== EVENT_CATEGORY_COLORS[category] ? color : undefined,
isAllDay,
recurrence,
recurrenceEndDate
};
const updatedEvent = this.plugin.calendarEventService?.updateEvent(this.event.id, eventData);
if (updatedEvent) {
this.onSave(updatedEvent);
new Notice("Event updated successfully");
}
} else {
// Create new event
eventData = {
title,
description: description || undefined,
date: date.getTime(),
time: time || undefined,
location: location || undefined,
category,
color: color !== EVENT_CATEGORY_COLORS[category] ? color : undefined,
isAllDay,
recurrence,
recurrenceEndDate
};
const newEvent = this.plugin.calendarEventService?.createEvent(eventData);
if (newEvent) {
this.onSave(newEvent);
new Notice("Event created successfully");
}
}
// Save plugin data
await this.plugin.savePluginData();
this.close();
} catch (error) {
console.error("Error saving event:", error);
new Notice("Error saving event");
}
}
}

View file

@ -106,6 +106,7 @@ export class SpaceforgeSettingTab extends PluginSettingTab {
pomodoroUserOverrideHours: this.plugin.pluginState.pomodoroUserOverrideHours ?? 0,
pomodoroUserOverrideMinutes: this.plugin.pluginState.pomodoroUserOverrideMinutes ?? 0,
pomodoroUserAddToEstimation: this.plugin.pluginState.pomodoroUserAddToEstimation ?? false,
calendarEvents: this.plugin.pluginState.calendarEvents ?? {},
};
const dataToExport: SpaceforgePluginData = {

283
ui/upcoming-events.ts Normal file
View file

@ -0,0 +1,283 @@
import { Notice, setIcon } from "obsidian";
import SpaceforgePlugin from "../main";
import { UpcomingEvent } from "../models/calendar-event";
import { DateUtils } from "../utils/dates";
/**
* Upcoming events component for calendar view
*/
export class UpcomingEvents {
/**
* Reference to the main plugin
*/
plugin: SpaceforgePlugin;
/**
* Container element for upcoming events
*/
containerEl: HTMLElement;
/**
* Upcoming events data
*/
upcomingEvents: UpcomingEvent[] = [];
/**
* Initialize upcoming events component
*
* @param containerEl Container element
* @param plugin Reference to the main plugin
*/
constructor(containerEl: HTMLElement, plugin: SpaceforgePlugin) {
this.containerEl = containerEl;
this.plugin = plugin;
}
/**
* Render upcoming events list
*/
async render(): Promise<void> {
if (!this.plugin.settings.enableCalendarEvents || !this.plugin.settings.showUpcomingEvents) {
this.containerEl.empty();
return;
}
if (!this.plugin.calendarEventService) {
this.containerEl.empty();
return;
}
// Load upcoming events data
await this.loadUpcomingEvents();
// Clear container
this.containerEl.empty();
if (this.upcomingEvents.length === 0) {
this.renderEmptyState();
return;
}
this.renderEventsList();
}
/**
* Load upcoming events data
*/
async loadUpcomingEvents(): Promise<void> {
this.upcomingEvents = this.plugin.calendarEventService.getUpcomingEvents(
this.plugin.settings.upcomingEventsDays || 7
);
}
/**
* Render empty state when no upcoming events
*/
private renderEmptyState(): void {
const emptyState = this.containerEl.createDiv("upcoming-events-empty");
const emptyIcon = emptyState.createDiv("upcoming-events-empty-icon");
setIcon(emptyIcon, "calendar");
const emptyText = emptyState.createDiv("upcoming-events-empty-text");
emptyText.setText("No upcoming events");
const emptySubtext = emptyState.createDiv("upcoming-events-empty-subtext");
emptySubtext.setText("Events will appear here once you create them");
}
/**
* Render the events list
*/
private renderEventsList(): void {
// Create header
const header = this.containerEl.createDiv("upcoming-events-header");
const headerTitle = header.createDiv("upcoming-events-title");
headerTitle.setText("Upcoming Events");
const headerSubtitle = header.createDiv("upcoming-events-subtitle");
const daysText = this.plugin.settings.upcomingEventsDays || 7;
headerSubtitle.setText(`Next ${daysText} days`);
// Create events container
const eventsContainer = this.containerEl.createDiv("upcoming-events-container");
// Group events by day
const eventsByDay = this.groupEventsByDay();
// Render each day's events
eventsByDay.forEach((dayEvents, dayKey) => {
this.renderDaySection(eventsContainer, dayKey, dayEvents);
});
}
/**
* Group upcoming events by day
*/
private groupEventsByDay(): Map<string, UpcomingEvent[]> {
const eventsByDay = new Map<string, UpcomingEvent[]>();
this.upcomingEvents.forEach(upcomingEvent => {
let dayKey: string;
if (upcomingEvent.isToday) {
dayKey = "Today";
} else if (upcomingEvent.isTomorrow) {
dayKey = "Tomorrow";
} else {
const eventDate = new Date(upcomingEvent.event.date);
dayKey = eventDate.toLocaleDateString(undefined, {
weekday: 'short',
month: 'short',
day: 'numeric'
});
}
if (!eventsByDay.has(dayKey)) {
eventsByDay.set(dayKey, []);
}
eventsByDay.get(dayKey)!.push(upcomingEvent);
});
return eventsByDay;
}
/**
* Render a day section with its events
*/
private renderDaySection(container: HTMLElement, dayKey: string, dayEvents: UpcomingEvent[]): void {
const daySection = container.createDiv("upcoming-events-day-section");
// Day header
const dayHeader = daySection.createDiv("upcoming-events-day-header");
const dayTitle = dayHeader.createDiv("upcoming-events-day-title");
dayTitle.setText(dayKey);
if (dayKey === "Today") {
daySection.addClass("today");
} else if (dayKey === "Tomorrow") {
daySection.addClass("tomorrow");
}
// Events for this day
const dayEventsContainer = daySection.createDiv("upcoming-events-day-events");
dayEvents.forEach(upcomingEvent => {
this.renderEventItem(dayEventsContainer, upcomingEvent);
});
}
/**
* Render a single event item
*/
private renderEventItem(container: HTMLElement, upcomingEvent: UpcomingEvent): void {
const event = upcomingEvent.event;
const eventItem = container.createDiv("upcoming-events-event-item");
// Event color indicator
const colorIndicator = eventItem.createDiv("upcoming-events-event-color");
const eventColor = this.plugin.calendarEventService?.getEventColor(event) || '#95A5A6';
colorIndicator.style.backgroundColor = eventColor;
// Event content
const eventContent = eventItem.createDiv("upcoming-events-event-content");
// Event title and time
const eventHeader = eventContent.createDiv("upcoming-events-event-header");
const eventTitle = eventHeader.createDiv("upcoming-events-event-title");
eventTitle.setText(event.title);
if (event.time) {
const eventTime = eventHeader.createDiv("upcoming-events-event-time");
eventTime.setText(event.time);
} else if (event.isAllDay) {
const eventTime = eventHeader.createDiv("upcoming-events-event-time");
eventTime.setText("All day");
}
// Event details
if (event.description || event.location) {
const eventDetails = eventContent.createDiv("upcoming-events-event-details");
if (event.location) {
const eventLocation = eventDetails.createDiv("upcoming-events-event-location");
setIcon(eventLocation, "map-pin");
eventLocation.appendText(" " + event.location);
}
if (event.description) {
const eventDescription = eventDetails.createDiv("upcoming-events-event-description");
eventDescription.setText(event.description.substring(0, 100) + (event.description.length > 100 ? "..." : ""));
}
}
// Event category
const eventCategory = eventContent.createDiv("upcoming-events-event-category");
eventCategory.setText(event.category);
// Click handler to show event details
eventItem.addEventListener("click", () => {
this.showEventDetails(event);
});
// Add hover effect
eventItem.addEventListener("mouseenter", () => {
eventItem.addClass("hover");
});
eventItem.addEventListener("mouseleave", () => {
eventItem.removeClass("hover");
});
}
/**
* Show event details modal
*
* @param event Event to show details for
*/
private showEventDetails(event: any): void {
const eventDate = new Date(event.date);
const dateStr = eventDate.toLocaleDateString(undefined, {
weekday: 'long',
year: 'numeric',
month: 'long',
day: 'numeric'
});
let details = `📅 ${event.title}\n📆 ${dateStr}`;
if (event.time) {
details += `\n🕐 ${event.time}`;
}
if (event.description) {
details += `\n📝 ${event.description}`;
}
if (event.location) {
details += `\n📍 ${event.location}`;
}
details += `\n🏷 ${event.category}`;
new Notice(details, 8000);
}
/**
* Refresh the upcoming events display
*/
async refresh(): Promise<void> {
await this.render();
}
/**
* Clean up the component
*/
destroy(): void {
this.containerEl.empty();
}
}