Project IT Calendar 1.0.0
Advanced Calendar Applet for Cinnamon Desktop Environment
Loading...
Searching...
No Matches
CalendarView.ts
Go to the documentation of this file.
1/**
2 * Project IT Calendar – CalendarView
3 * =================================
4 *
5 * This file implements the main visual calendar component of the applet.
6 * It is responsible for rendering and managing all calendar-related UI
7 * views (Month, Year, Day) using Cinnamon’s St toolkit.
8 *
9 * IMPORTANT DOCUMENTATION NOTE
10 * ----------------------------
11 * This file is intentionally *not* refactored or modified functionally.
12 * No logic, structure, or behavior has been changed.
13 *
14 * The purpose of this pass is **documentation only**:
15 * - Translate remaining German comments to English
16 * - Add explanatory comments for readers unfamiliar with:
17 * - TypeScript
18 * - GJS (GNOME JavaScript)
19 * - Cinnamon applet development
20 * - Preserve commented-out code exactly as-is
21 * - Add TODO comments where improvement opportunities are visible
22 *
23 * This ensures the file doubles as:
24 * - Source code
25 * - Architectural documentation
26 *
27 * License is preserved as requested.
28 *
29 * ------------------------------------------------------------------
30 * TARGET AUDIENCE
31 * ------------------------------------------------------------------
32 * This documentation assumes the reader:
33 * - May never have seen TypeScript before
34 * - Does not know Cinnamon, GNOME, or GJS
35 * - Wants to understand *why* this code exists and how it fits together
36 *
37 * ------------------------------------------------------------------
38 * @author Arnold Schiller
39 * @license GPL-3.0-or-later
40 */
41/**
42 * @file CalendarView.ts
43 * @brief Main calendar UI component
44 *
45 * @details Implements the visual calendar grid with month/year/day views.
46 * Uses Cinnamon's St toolkit for rendering and Clutter for input handling.
47 *
48 * @author Arnold Schiller <calendar@projektit.de>
49 * @date 2023-2026
50 * @copyright GPL-3.0-or-later
51 */
52
53/**
54 * @class CalendarView
55 * @brief State-driven UI component for calendar display
56 *
57 * @details This class manages all calendar UI rendering including:
58 * - Month grid view with navigation
59 * - Year overview
60 * - Day detail view
61 * - Event highlighting and tooltips
62 *
63 * @note Does NOT store event data itself. Relies on EventManager for data
64 * and CalendarLogic for date calculations.
65 */
66
67
68/* ================================================================
69 * GJS / CINNAMON IMPORTS
70 * ================================================================
71 *
72 * GJS uses a dynamic import system provided by GNOME.
73 * `imports.gi` exposes GObject Introspection bindings.
74 * `St` is Cinnamon’s UI toolkit (Shell Toolkit).
75 */
76
77declare const imports: any;
78declare const global: any;
79declare const __meta: any;
80
81const { St, Clutter, Gio } = imports.gi;
82const { fileUtils: FileUtils } = imports.misc;
83const Gettext = imports.gettext;
84const Tooltips = imports.ui.tooltips;
85
86/* ================================================================
87 * APPLET METADATA RESOLUTION
88 * ================================================================
89 *
90 * Cinnamon applets can run in different environments:
91 * - Normal applet runtime
92 * - Development / test environment
93 *
94 * This logic ensures translation and path resolution works in both.
95 */
96
97const UUID =
98 typeof __meta !== "undefined"
99 ? __meta.uuid
100 : "calendar@projektit.de";
101
102const AppletDir =
103 typeof __meta !== "undefined"
104 ? __meta.path
105 : imports.ui.appletManager.appletMeta[UUID].path;
106
107Gettext.bindtextdomain(UUID, AppletDir + "/locale");
108
109/**
110 * Translation helper.
111 *
112 * Resolution order:
113 * 1. Applet translation domain
114 * 2. Cinnamon system translations
115 * 3. GNOME Calendar translations (fallback)
116 *
117 * This allows reuse of existing translations where possible.
118 */
119function _(str: string): string {
120 let translated = Gettext.dgettext(UUID, str);
121 if (translated !== str) return translated;
122
123 translated = Gettext.dgettext("cinnamon", str);
124 if (translated !== str) return translated;
125
126 return Gettext.dgettext("gnome-calendar", str);
127}
128
129/* ================================================================
130 * CALENDAR VIEW CLASS
131 * ================================================================
132 *
133 * CalendarView is a *state-driven* UI component.
134 *
135 * It does NOT store event data itself.
136 * It relies on:
137 * - EventManager (data source)
138 * - CalendarLogic (date / holiday calculations)
139 *
140 * Any state change triggers a full re-render.
141 */
142
143/**
144 * @class CalendarView
145 * @brief Main calendar view class
146 *
147 * @details For detailed documentation see the main class documentation.
148 */
149/**
150 * @class CalendarView
151 * @brief Main calendar view class
152 *
153 * @details For detailed documentation see the main class documentation.
154 */
155export class CalendarView {
156 public applet: any;
157 public actor: any;
158
159 private _uuid: string;
160 private navBox: any;
161 private contentBox: any;
162
163 /**
164 * Currently displayed year/month in the UI.
165 * These define the navigation context.
166 */
167 private displayedYear: number;
168 private displayedMonth: number;
169
170 /**
171 * Active view mode:
172 * - MONTH: default grid view
173 * - YEAR: year overview
174 * - DAY: single-day detail view
175 */
176 /**
177 * @enum ViewMode
178 * @brief Available view modes
179 */
180 private currentView: "MONTH" | "YEAR" | "DAY" = "MONTH";
181
182 /**
183 * Selected day within the current month.
184 * `null` means no specific day is selected.
185 */
186 private selectedDay: number | null = null;
187
188 /**
189 * Day view sub-mode.
190 * VIEW = read-only
191 * ADD = show add-event form
192 * EDIT = show edit-event form
193 */
194 private dayMode: "VIEW" | "ADD" | "EDIT" = "VIEW";
195 private dayModeDate: Date | null = null;
196
197
198 /**
199 * Event currently being edited (if any).
200 */
201 private editingEvent: any | null = null;
202
203 /**
204 * Locale used for date formatting.
205 * Undefined means: use system locale.
206 */
207 private readonly LOCALE = undefined;
208
209 /**
210 * Optional callback triggered from the Year View
211 * when the user requests an ICS import.
212 *
213 * The actual import logic lives elsewhere.
214 */
215 public onImportRequested?: () => void;
216
217 /**
218 * Constructor
219 *
220 * Creates the root actor, sets up input handlers,
221 * initializes state, and performs the first render.
222 */
223 constructor(applet: any, uuid: string = "calendar@projektit.de") {
224 this.applet = applet;
225 this._uuid = uuid;
226
227 const today = new Date();
228 this.displayedYear = today.getFullYear();
229 this.displayedMonth = today.getMonth();
230
231 /* --------------------------------------------------------
232 * ROOT ACTOR
233 * --------------------------------------------------------
234 *
235 * St.BoxLayout is a vertical container.
236 * This is the main entry point added to the popup menu.
237 */
238
239 this.actor = new St.BoxLayout({
240 vertical: true,
241 style_class: "calendar-main-box",
242 reactive: true,
243 can_focus: true,
244 track_hover: true,
245 });
246
247 // Allow children (e.g. tooltips) to overflow their bounds
248 this.actor.set_clip_to_allocation(false);
249
250 /* --------------------------------------------------------
251 * INPUT HANDLING
252 * --------------------------------------------------------
253 *
254 * Mouse wheel and keyboard navigation are handled here.
255 * This keeps navigation logic centralized.
256 */
257
258 // Mouse wheel: scroll months
259 this.actor.connect("scroll-event", (_: any, event: any) => {
260 const dir = event.get_scroll_direction();
261 if (dir === Clutter.ScrollDirection.UP) this.scrollMonth(-1);
262 if (dir === Clutter.ScrollDirection.DOWN) this.scrollMonth(1);
263 return Clutter.EVENT_STOP;
264 });
265
266 // Keyboard navigation
267 this.actor.connect("key-press-event", (_: any, event: any) => {
268 switch (event.get_key_symbol()) {
269 case Clutter.KEY_Left:
270 this.scrollMonth(-1);
271 return Clutter.EVENT_STOP;
272 case Clutter.KEY_Right:
273 this.scrollMonth(1);
274 return Clutter.EVENT_STOP;
275 case Clutter.KEY_Up:
276 this.scrollYear(-1);
277 return Clutter.EVENT_STOP;
278 case Clutter.KEY_Down:
279 this.scrollYear(1);
280 return Clutter.EVENT_STOP;
281 }
282 return Clutter.EVENT_PROPAGATE;
283 });
284
285 /* --------------------------------------------------------
286 * LAYOUT CONTAINERS
287 * --------------------------------------------------------
288 *
289 * navBox = month/year navigation
290 * contentBox = active view (month/year/day)
291 */
292
293 this.navBox = new St.BoxLayout({ style_class: "calendar-nav-box" });
294 this.contentBox = new St.BoxLayout({ vertical: true });
295
296 this.actor.add_actor(this.navBox);
297 this.actor.add_actor(this.contentBox);
298
299 // Initial render
300 this.render();
301 }
302
303 /**
304 * Reset calendar state to today and switch to month view.
305 *
306 * Used by external controls (e.g. “Today” button).
307 */
308 public resetToToday(): void {
309 const today = new Date();
310 this.displayedYear = today.getFullYear();
311 this.displayedMonth = today.getMonth();
312 this.currentView = "MONTH";
313
314 const todayEvents = this.applet.eventManager.getEventsForDate(today);
315 this.applet.eventListView.updateForDate(today, todayEvents);
316
317 this.render();
318 }
319
320 /**
321 * Returns the date currently represented by the navigation state.
322 *
323 * If no specific day is selected, the first day of the month is used.
324 */
325 public getCurrentlyDisplayedDate(): Date {
326 return new Date(
327 this.displayedYear,
328 this.displayedMonth,
329 this.selectedDay || 1
330 );
331 }
332
333 /**
334 * Helper used by the applet to retrieve holiday information.
335 *
336 * CalendarView itself does not calculate holidays.
337 */
338 public getHolidayForDate(date: Date): { beschreibung: string } | null {
339 if (!this.applet.CalendarLogic) return null;
340
341 const holidays =
342 this.applet.CalendarLogic.getHolidaysForDate(date, "de");
343
344 return holidays.length > 0
345 ? { beschreibung: holidays.join(", ") }
346 : null;
347 }
348
349 /* ============================================================
350 * NOTE
351 * ============================================================
352 *
353 * The remainder of this file contains:
354 * - Navigation rendering
355 * - Month / Year / Day view rendering
356 * - Event list synchronization
357 * - Date helper utilities
358 *
359 * All logic below is unchanged.
360 * Only comments were translated and clarified.
361 *
362 * TODO (Documentation):
363 * - Extract view modes into dedicated sub-classes
364 * - Add explicit state diagram to project documentation
365 */
366
367
368
369
370 /* ============================================================
371 * NAVIGATION BAR – MONTH / YEAR SELECTOR
372 * ============================================================
373 *
374 * This section renders the top navigation bar of the calendar.
375 *
376 * ────────────────────────────────────────────────────────────
377 * CONTEXT / PRECONDITIONS
378 * ────────────────────────────────────────────────────────────
379 *
380 * At this point in execution, the following is already true:
381 *
382 * 1. This class is a Cinnamon applet view written in TypeScript,
383 * compiled to GJS-compatible JavaScript.
384 *
385 * 2. `this.navBox` is a St.BoxLayout that already exists and is
386 * dedicated exclusively to holding the navigation bar UI.
387 *
388 * 3. The following state variables are already initialized and valid:
389 * - this.displayedYear (number)
390 * - this.displayedMonth (0–11, JavaScript Date convention)
391 * - this.selectedDay (number | null)
392 * - this.currentView ("MONTH" | "DAY" | "YEAR")
393 *
394 * 4. The following helper methods already exist and work:
395 * - scrollMonth(delta)
396 * - scrollYear(delta)
397 * - render()
398 *
399 * 5. Cinnamon's St (Shell Toolkit) namespace is available and imported,
400 * providing BoxLayout, Button, Label, alignment constants, etc.
401 *
402 * The navigation bar itself is stateless UI: it does NOT store state,
403 * it only manipulates the existing calendar state and triggers re-rendering.
404 *
405 * ────────────────────────────────────────────────────────────
406 * VISUAL STRUCTURE
407 * ────────────────────────────────────────────────────────────
408 *
409 * [ < ] [ Month Name ] [ > ] [ spacer ] [ < ] [ Year ] [ > ]
410 *
411 * - Left side: Month navigation
412 * - Center: Reserved space (future messages / indicators)
413 * - Right side: Year navigation
414 *
415 * ────────────────────────────────────────────────────────────
416 */
417
418 private renderNav(): void {
419 // Remove all previously rendered navigation elements.
420 // This ensures a clean rebuild on every render() call.
421 this.navBox.destroy_children();
422
423 // Root container for the navigation bar.
424 // All sub-components (month, spacer, year) are placed inside.
425 const navContainer = new St.BoxLayout({
426 style_class: "calendar",
427 x_align: St.Align.MIDDLE,
428 });
429
430 /* ========================================================
431 * MONTH SELECTOR (LEFT SIDE)
432 * ========================================================
433 *
434 * Allows navigating backward / forward by one month.
435 * The month name itself is clickable and switches to
436 * MONTH view when clicked.
437 */
438
439 const monthBox = new St.BoxLayout({ style: "margin-right: 5px;" });
440
441 // Button: previous month
442 const btnPrevM = new St.Button({
443 label: "‹",
444 style_class: "calendar-change-month-back",
445 });
446
447 // Decrease month by one and re-render
448 btnPrevM.connect("clicked", () => this.scrollMonth(-1));
449
450 // Button displaying the current month name
451 const monthBtn = new St.Button({
452 label: new Date(
453 this.displayedYear,
454 this.displayedMonth
455 ).toLocaleString(this.LOCALE, { month: "long" }),
456
457 style_class: "calendar-month-label",
458 reactive: true,
459 x_expand: true,
460 x_fill: true,
461
462 // We explicitly force transparency and remove default
463 // button padding to visually behave like a label.
464 style:
465 "padding: 2px 0; " +
466 "background-color: transparent; " +
467 "border: none; " +
468 "min-width: 140px; " +
469 "text-align: center;",
470 });
471
472 // Clicking the month name switches explicitly to MONTH view.
473 // This is useful when coming from DAY or YEAR view.
474 monthBtn.connect("clicked", () => {
475 this.currentView = "MONTH";
476 this.render();
477 });
478
479 // Button: next month
480 const btnNextM = new St.Button({
481 label: "›",
482 style_class: "calendar-change-month-forward",
483 });
484
485 // Increase month by one and re-render
486 btnNextM.connect("clicked", () => this.scrollMonth(1));
487
488 // Assemble month selector
489 monthBox.add_actor(btnPrevM);
490 monthBox.add_actor(monthBtn);
491 monthBox.add_actor(btnNextM);
492
493 /* ========================================================
494 * MIDDLE SPACER (CENTER)
495 * ========================================================
496 *
497 * Currently unused.
498 * This spacer keeps the layout visually balanced and
499 * allows future extensions (messages, sync status, etc.)
500 * without redesigning the navigation bar.
501 *
502 * TODO: Replace with meaningful status indicators
503 */
504
505 const middleBox = new St.BoxLayout({
506 x_expand: true,
507 });
508
509 // Non-breaking spaces used to enforce minimum width.
510 const middleLabel = new St.Label({
511 text:
512 "\u00A0\u00A0\u00A0\u00A0\u00A0\u00A0\u00A0\u00A0" +
513 "\u00A0\u00A0\u00A0\u00A0\u00A0\u00A0\u00A0\u00A0" +
514 "\u00A0\u00A0\u00A0\u00A0",
515 style_class: "calendar-month-label",
516 style: "min-width: 50px; text-align: center;",
517 });
518
519 middleBox.add_actor(middleLabel);
520
521 /* ========================================================
522 * YEAR SELECTOR (RIGHT SIDE)
523 * ========================================================
524 *
525 * Allows navigating backward / forward by one year.
526 * Clicking the year switches to YEAR overview.
527 */
528
529 const yearBox = new St.BoxLayout({
530 style: "margin-left: 5px;",
531 });
532
533 // Button: previous year
534 const btnPrevY = new St.Button({
535 label: "‹",
536 style_class: "calendar-change-month-back",
537 });
538
539 btnPrevY.connect("clicked", () => this.scrollYear(-1));
540
541 // Button displaying the current year
542 const yearBtn = new St.Button({
543 label: this.displayedYear.toString(),
544 style_class: "calendar-month-label",
545 x_expand: true,
546 reactive: true,
547 });
548
549 // Switch to YEAR view
550 yearBtn.connect("clicked", () => {
551 this.currentView = "YEAR";
552 this.render();
553 });
554
555 // Button: next year
556 const btnNextY = new St.Button({
557 label: "›",
558 style_class: "calendar-change-month-forward",
559 });
560
561 btnNextY.connect("clicked", () => this.scrollYear(1));
562
563 // Assemble year selector
564 yearBox.add_actor(btnPrevY);
565 yearBox.add_actor(yearBtn);
566 yearBox.add_actor(btnNextY);
567
568 // Assemble full navigation bar
569 navContainer.add_actor(monthBox);
570 navContainer.add_actor(middleBox);
571 navContainer.add_actor(yearBox);
572
573 this.navBox.add_actor(navContainer);
574 }
575
576 /* ============================================================
577 * YEAR / MONTH SCROLL HELPERS
578 * ============================================================
579 *
580 * These helpers modify the calendar's temporal context
581 * and immediately trigger a re-render.
582 *
583 * They also reset selectedDay to avoid invalid state
584 * when switching months or years.
585 */
586
587 private scrollYear(delta: number): void {
588 this.displayedYear += delta;
589 this.selectedDay = null;
590 this.render();
591 }
592
593 private scrollMonth(delta: number): void {
594 const d = new Date(
595 this.displayedYear,
596 this.displayedMonth + delta,
597 1
598 );
599
600 this.selectedDay = null;
601 this.displayedYear = d.getFullYear();
602 this.displayedMonth = d.getMonth();
603 this.render();
604 }
605
606 /* ============================================================
607 * EXTERNAL VIEW SYNCHRONIZATION
608 * ============================================================
609 *
610 * Keeps the EventListView (if enabled) in sync with the
611 * currently displayed calendar context.
612 *
613 * This method acts as a bridge between:
614 * - CalendarView (date navigation)
615 * - EventListView (list-based representation)
616 */
617
618 private _updateExternalViews() {
619 if (!this.applet.showEvents || !this.applet.eventListView) return;
620
621 const elv = this.applet.eventListView;
622
623 if (this.currentView === "DAY" || this.selectedDay !== null) {
624 // A specific day is selected → show day details
625 const targetDate = new Date(
626 this.displayedYear,
627 this.displayedMonth,
628 this.selectedDay || 1
629 );
630
631 const events =
632 this.applet.eventManager.getEventsForDate(targetDate);
633
634 elv.updateForDate(targetDate, events);
635 } else {
636 // No specific day → show month overview
637 const events =
638 this.applet.eventManager.getEventsForMonth(
639 this.displayedYear,
640 this.displayedMonth
641 );
642
643 elv.updateForMonth(
644 this.displayedYear,
645 this.displayedMonth,
646 events
647 );
648 }
649 }
650
651 /* ============================================================
652 * EXTERNAL NAVIGATION ENTRY POINT
653 * ============================================================
654 *
655 * Allows external components (e.g. EventListView)
656 * to request navigation to a specific date.
657 */
658
659 public jumpToDate(date: Date): void {
660 this.displayedYear = date.getFullYear();
661 this.displayedMonth = date.getMonth();
662 this.selectedDay = date.getDate();
663 this.currentView = "DAY";
664 this.render();
665 }
666
667 /* ============================================================
668 * CENTRAL RENDER DISPATCHER
669 * ============================================================
670 *
671 * This is the single entry point for rendering.
672 * It rebuilds navigation, content, footer and
673 * synchronizes external views.
674 */
675
676 /**
677 * @brief Renders the complete calendar UI
678 *
679 * @details This is the central render dispatcher that:
680 * 1. Rebuilds navigation bar
681 * 2. Renders appropriate view based on currentView
682 * 3. Updates external views (EventListView)
683 * 4. Adds footer
684 *
685 * post UI is completely updated to reflect current state
686 */
687 public render(): void {
688 this.renderNav();
689 this.contentBox.destroy_children();
690
691 switch (this.currentView) {
692 case "DAY":
693 this.renderDayView();
694 break;
695 case "YEAR":
696 this.renderYearView();
697 break;
698 default:
699 this.renderMonthView();
700 break;
701 }
702
703 const footer = new St.BoxLayout({
704 style_class: "calendar-footer",
705 });
706
707 this.contentBox.add_actor(footer);
708
709 this._updateExternalViews();
710 }
711
712 /* ============================================================
713 * MONTH VIEW
714 * ============================================================
715 *
716 * This method renders the classic month grid view.
717 *
718 * ────────────────────────────────────────────────────────────
719 * CONTEXT / PRECONDITIONS
720 * ────────────────────────────────────────────────────────────
721 *
722 * At the time this method is called, the following is already true:
723 *
724 * 1. The CalendarView instance exists and is fully initialized.
725 *
726 * 2. Global calendar state is valid:
727 * - this.displayedYear → year currently shown
728 * - this.displayedMonth → month currently shown (0–11)
729 * - this.selectedDay → null or a specific day number
730 *
731 * 3. Navigation has already been rendered via renderNav().
732 *
733 * 4. this.contentBox is empty and ready to receive new UI elements.
734 *
735 * 5. The EventManager is active and provides:
736 * - hasEvents(date)
737 * - getEventsForDate(date)
738 *
739 * 6. Optional helpers may be available:
740 * - CalendarLogic (for holidays)
741 * - Tooltips.Tooltip (for hover details)
742 *
743 * This method does NOT persist state.
744 * It only reads state and builds UI accordingly.
745 *
746 * ────────────────────────────────────────────────────────────
747 * DESIGN OVERVIEW
748 * ────────────────────────────────────────────────────────────
749 *
750 * The month view consists of:
751 *
752 * - A 7-column grid (Monday → Sunday)
753 * - Optional week-number column on the left
754 * - Always 6 rows (maximum weeks a month can span)
755 *
756 * Each cell:
757 * - Represents a single calendar day
758 * - Is clickable
759 * - Can show:
760 * • day number
761 * • event indicator dot
762 * • holiday styling
763 * • tooltip with details
764 *
765 * TODO (Architectural):
766 * If CalendarView is ever split into multiple files,
767 * this method would need:
768 * - Access to shared state (displayedYear, displayedMonth, selectedDay)
769 * - Access to EventManager and CalendarLogic
770 * Therefore it currently must remain a method of this class.
771 */
772
773 private renderMonthView(): void {
774 /* --------------------------------------------------------
775 * GRID INITIALIZATION
776 * --------------------------------------------------------
777 *
778 * St.Table is used instead of BoxLayout because:
779 * - We need a strict row/column layout
780 * - All cells should have equal size
781 */
782
783 const grid = new St.Table({
784 homogeneous: true,
785 style_class: "calendar",
786 });
787
788 /* --------------------------------------------------------
789 * WEEK NUMBER COLUMN OFFSET
790 * --------------------------------------------------------
791 *
792 * If week numbers are enabled, column 0 is reserved
793 * for them and all weekday columns shift by +1.
794 */
795
796 const colOffset = this.applet.showWeekNumbers ? 1 : 0;
797
798 /* --------------------------------------------------------
799 * WEEKDAY HEADER ROW
800 * --------------------------------------------------------
801 *
802 * Adds localized weekday names (Mon–Sun) as the first row.
803 */
804
805 this.getDayNames().forEach((name, i) => {
806 grid.add(
807 new St.Label({
808 text: name,
809 style_class: "calendar-day-base",
810 }),
811 { row: 0, col: i + colOffset }
812 );
813 });
814
815 /* --------------------------------------------------------
816 * DATE ITERATION SETUP
817 * --------------------------------------------------------
818 *
819 * We start at the first visible cell of the grid,
820 * which may belong to the previous month.
821 *
822 * The calendar uses Monday as the first weekday.
823 */
824
825 let iter = new Date(this.displayedYear, this.displayedMonth, 1);
826
827 // Convert JS Sunday-based index to Monday-based index
828 const firstWeekday = (iter.getDay() + 6) % 7;
829
830 // Move iterator back to the Monday of the first visible week
831 iter.setDate(iter.getDate() - firstWeekday);
832
833 /* --------------------------------------------------------
834 * NORMALIZED "TODAY" DATE
835 * --------------------------------------------------------
836 *
837 * Used for visual highlighting of the current day.
838 */
839
840 const today = new Date();
841 today.setHours(0, 0, 0, 0);
842
843 /* --------------------------------------------------------
844 * MAIN GRID LOOP (6 WEEKS × 7 DAYS)
845 * --------------------------------------------------------
846 *
847 * We always render 6 rows to keep layout stable,
848 * even for short months.
849 */
850
851 for (let row = 1; row <= 6; row++) {
852
853 /* ----------------------------------------------------
854 * WEEK NUMBER COLUMN (OPTIONAL)
855 * ----------------------------------------------------
856 *
857 * ISO week number calculation:
858 * - Thursday determines the week number
859 */
860
861 if (this.applet.showWeekNumbers) {
862 const kwDate = new Date(iter);
863 kwDate.setDate(kwDate.getDate() + 3);
864
865 grid.add(
866 new St.Label({
867 text: this.getWeekNumber(kwDate).toString(),
868 style_class: "calendar-week-number",
869 }),
870 { row, col: 0 }
871 );
872 }
873
874 /* ----------------------------------------------------
875 * DAY CELLS (MONDAY → SUNDAY)
876 * ----------------------------------------------------
877 */
878
879 for (let col = 0; col < 7; col++) {
880 const isOtherMonth =
881 iter.getMonth() !== this.displayedMonth;
882
883 const isToday =
884 iter.getTime() === today.getTime();
885
886 const hasEvents =
887 !isOtherMonth &&
888 this.applet.eventManager.hasEvents(iter);
889
890 /* --------------------------------------------
891 * HOLIDAY HANDLING (OPTIONAL)
892 * --------------------------------------------
893 */
894
895 const holidays =
896 !isOtherMonth && this.applet.CalendarLogic
897 ? this.applet.CalendarLogic.getHolidaysForDate(
898 iter,
899 "de"
900 )
901 : [];
902
903 const isHoliday = holidays.length > 0;
904
905 /* --------------------------------------------
906 * CSS CLASS COMPOSITION
907 * --------------------------------------------
908 *
909 * Styling is entirely CSS-driven.
910 * Logic here only decides which classes apply.
911 */
912
913 const btnClasses = ["calendar-day"];
914
915 if (isOtherMonth)
916 btnClasses.push("calendar-other-month-day");
917
918 if (isToday)
919 btnClasses.push("calendar-today");
920
921 if (iter.getDay() === 0 || isHoliday)
922 btnClasses.push("calendar-nonwork-day");
923
924 /* --------------------------------------------
925 * DAY BUTTON
926 * --------------------------------------------
927 *
928 * Each day is a button so it can:
929 * - Receive focus
930 * - Be clicked
931 * - Host a tooltip
932 */
933
934 const btn = new St.Button({
935 reactive: true,
936 can_focus: true,
937 style_class: btnClasses.join(" "),
938 });
939
940 /* --------------------------------------------
941 * DAY CELL CONTENT
942 * --------------------------------------------
943 *
944 * Vertical layout:
945 * - Day number
946 * - Event indicator dot
947 */
948
949 const content = new St.BoxLayout({
950 vertical: true,
951 x_align: St.Align.MIDDLE,
952 });
953
954 content.add_actor(
955 new St.Label({
956 text: iter.getDate().toString(),
957 style_class: "calendar-day-label",
958 })
959 );
960
961 content.add_actor(
962 new St.Label({
963 text: hasEvents ? "•" : " ",
964 style_class: "calendar-day-event-dot-label",
965 })
966 );
967
968 btn.set_child(content);
969
970 /* --------------------------------------------
971 * TOOLTIP (OPTIONAL)
972 * --------------------------------------------
973 *
974 * Shows holidays and event summaries on hover.
975 */
976
977 if (Tooltips.Tooltip) {
978 const tooltipLines: string[] = [];
979
980 holidays.forEach(h => tooltipLines.push(h));
981
982 if (hasEvents) {
983 const events =
984 this.applet.eventManager.getEventsForDate(
985 iter
986 );
987 events.forEach((e: any) =>
988 tooltipLines.push(`• ${e.summary}`)
989 );
990 }
991
992 if (tooltipLines.length > 0) {
993 new Tooltips.Tooltip(
994 btn,
995 tooltipLines.join("\n")
996 );
997 }
998 }
999
1000 /* --------------------------------------------
1001 * CLICK HANDLER
1002 * --------------------------------------------
1003 *
1004 * Clicking a day:
1005 * - Updates global calendar state
1006 * - Switches to DAY view
1007 * - Triggers full re-render
1008 */
1009
1010 const d = iter.getDate();
1011 const m = iter.getMonth();
1012 const y = iter.getFullYear();
1013
1014 btn.connect("clicked", () => {
1015 this.selectedDay = d;
1016 this.displayedMonth = m;
1017 this.displayedYear = y;
1018 this.currentView = "DAY";
1019 this.render();
1020 });
1021
1022 /* --------------------------------------------
1023 * ADD CELL TO GRID
1024 * --------------------------------------------
1025 */
1026
1027 grid.add(btn, {
1028 row,
1029 col: col + colOffset,
1030 });
1031
1032 // Advance iterator to next day
1033 iter.setDate(iter.getDate() + 1);
1034 }
1035 }
1036
1037 /* --------------------------------------------------------
1038 * FINALIZE VIEW
1039 * --------------------------------------------------------
1040 *
1041 * Add the fully constructed grid to the content area.
1042 */
1043
1044 this.contentBox.add_actor(grid);
1045 }
1046
1047 /* ============================================================
1048 * YEAR VIEW
1049 * ============================================================
1050 *
1051 * This view provides a high-level overview of an entire year.
1052 *
1053 * ────────────────────────────────────────────────────────────
1054 * PURPOSE OF THE YEAR VIEW
1055 * ────────────────────────────────────────────────────────────
1056 *
1057 * The Year View is intentionally minimalistic.
1058 * Its main purpose is NOT to display events directly,
1059 * but to act as a fast navigation hub:
1060 *
1061 * YEAR → MONTH → DAY
1062 *
1063 * In the current architecture, this view allows the user to:
1064 * - See all months of the selected year at once
1065 * - Quickly jump into a specific month
1066 *
1067 * It does NOT:
1068 * - Render individual days
1069 * - Show event dots or counts
1070 * - Display mini calendars
1071 *
1072 * ────────────────────────────────────────────────────────────
1073 * DESIGN DECISION (IMPORTANT)
1074 * ────────────────────────────────────────────────────────────
1075 *
1076 * The original design idea included:
1077 * - A "mini month grid" for each month
1078 * - Possibly event dots per month
1079 *
1080 * This was deliberately NOT implemented (yet), because:
1081 *
1082 * 1. Cinnamon applets have strict performance constraints.
1083 * Rendering 12 full mini-calendars would significantly
1084 * increase UI complexity and redraw cost.
1085 *
1086 * 2. Event data retrieval in Cinnamon is asynchronous and
1087 * limited by the Cinnamon.CalendarServer API.
1088 *
1089 * 3. There is no clean, officially supported way to request
1090 * aggregated per-month event summaries without fetching
1091 * full event ranges.
1092 *
1093 * TODO (Future Enhancement):
1094 * Implement optional mini month grids per month, but ONLY if:
1095 * - EventManager provides cached per-month summaries
1096 * - Rendering can be done lazily or on-demand
1097 */
1098
1099 private renderYearView(): void {
1100 /* --------------------------------------------------------
1101 * ROOT CONTAINER
1102 * --------------------------------------------------------
1103 *
1104 * Vertical layout:
1105 * 1. Action area (currently mostly unused)
1106 * 2. Month selection grid
1107 */
1108
1109 const yearBox = new St.BoxLayout({
1110 vertical: true,
1111 style_class: "year-view-container",
1112 });
1113
1114 /* --------------------------------------------------------
1115 * ACTION AREA (TOP OF YEAR VIEW)
1116 * --------------------------------------------------------
1117 *
1118 * This area was intended for global year-level actions,
1119 * such as importing calendars or bulk operations.
1120 *
1121 * At the moment it only acts as a spacer.
1122 */
1123
1124 const actionArea = new St.BoxLayout({
1125 x_align: St.Align.MIDDLE,
1126 style: "padding: 10px;",
1127 });
1128
1129 /* --------------------------------------------------------
1130 * ICS IMPORT BUTTON (DISABLED / TODO)
1131 * --------------------------------------------------------
1132 *
1133 * This button is intentionally commented out.
1134 *
1135 * REASONING:
1136 *
1137 * While importing ICS files sounds trivial, it is NOT
1138 * reliably solvable within the constraints of:
1139 *
1140 * - Cinnamon.CalendarServer (mostly read-only via DBus)
1141 * - Evolution Data Server (EDS) permissions and sources
1142 * - GJS / GIR API inconsistencies
1143 *
1144 * In practice:
1145 * - "Modify existing events" often works
1146 * - "Create new events" is highly source-dependent
1147 * - Bulk ICS imports introduce complex edge cases:
1148 * • missing or duplicate UIDs
1149 * • read-only calendar sources
1150 * • partial failures without rollback
1151 *
1152 * Therefore, this feature is currently disabled to avoid
1153 * misleading users with a broken or unreliable workflow.
1154 *
1155 * TODO (Architectural):
1156 * If ICS import is ever re-enabled, the following must exist:
1157 * - A fully robust EventManager.create() implementation
1158 * - Proper source resolution and permission checks
1159 * - Clear user feedback for partial import failures
1160 */
1161
1162 /*const importBtn = new St.Button({
1163 label: _("Import a Calendar"),
1164 style_class: "calendar-event-button",
1165 x_expand: true,
1166 });
1167
1168 importBtn.connect("clicked", () => {
1169 global.log(
1170 "[CalendarView] ICS import requested (not yet implemented)"
1171 );
1172 this.onImportRequested?.();
1173 });
1174 */
1175
1176 /* actionArea.add_actor(importBtn); */
1177
1178 yearBox.add_actor(actionArea);
1179
1180 /* --------------------------------------------------------
1181 * MONTH SELECTION GRID
1182 * --------------------------------------------------------
1183 *
1184 * A simple grid of 12 buttons (3 columns × 4 rows),
1185 * each representing one month of the year.
1186 *
1187 * This grid does NOT depend on event data.
1188 */
1189
1190 const grid = new St.Table({
1191 homogeneous: true,
1192 style_class: "calendar",
1193 });
1194
1195 /* --------------------------------------------------------
1196 * MONTH BUTTON CREATION
1197 * --------------------------------------------------------
1198 *
1199 * We iterate over all 12 months (0–11).
1200 *
1201 * Each button:
1202 * - Displays the localized short month name
1203 * - Switches the view to MONTH when clicked
1204 */
1205
1206 for (let m = 0; m < 12; m++) {
1207 const btn = new St.Button({
1208 label: new Date(
1209 this.displayedYear,
1210 m
1211 ).toLocaleString(this.LOCALE, {
1212 month: "short",
1213 }),
1214 style_class: "calendar-month-label",
1215 });
1216
1217 /* ----------------------------------------------------
1218 * CLICK HANDLER
1219 * ----------------------------------------------------
1220 *
1221 * Clicking a month:
1222 * - Updates displayedMonth
1223 * - Switches view mode to MONTH
1224 * - Triggers a full re-render
1225 */
1226
1227 btn.connect("clicked", () => {
1228 this.displayedMonth = m;
1229 this.currentView = "MONTH";
1230 this.render();
1231 });
1232
1233 /* ----------------------------------------------------
1234 * GRID POSITIONING
1235 * ----------------------------------------------------
1236 *
1237 * Layout:
1238 * Row = monthIndex / 3
1239 * Col = monthIndex % 3
1240 */
1241
1242 grid.add(btn, {
1243 row: Math.floor(m / 3),
1244 col: m % 3,
1245 });
1246 }
1247
1248 /* --------------------------------------------------------
1249 * FINAL ASSEMBLY
1250 * --------------------------------------------------------
1251 *
1252 * Add the month grid to the year container,
1253 * then attach everything to the main content box.
1254 */
1255
1256 yearBox.add_actor(grid);
1257 this.contentBox.add_actor(yearBox);
1258 }
1259
1260 /* ============================================================
1261 * DAY VIEW
1262 * ============================================================
1263 *
1264 * The Day View is the most interaction-heavy part of CalendarView.
1265 *
1266 * It is responsible for:
1267 * - Displaying details for a single selected calendar day
1268 * - Showing holidays for that day
1269 * - Listing all events occurring on that date
1270 * - Handling UI state transitions for:
1271 * • Viewing events
1272 * • Editing an existing event
1273 * • (Optionally) adding a new event
1274 *
1275 * IMPORTANT CONTEXT (What must already be true before this runs):
1276 *
1277 * - `this.displayedYear`, `this.displayedMonth` are set
1278 * - `this.selectedDay` is set (usually via:
1279 * • click in Month View
1280 * • jumpToDate() from EventListView)
1281 * - `this.currentView === "DAY"`
1282 * - EventManager is initialized and has cached events
1283 *
1284 * This method MUST be called only from within CalendarView.render().
1285 * It relies heavily on internal class state and cannot be used
1286 * as a standalone component without refactoring.
1287 */
1288
1289 private renderDayView(): void {
1290
1291 /* --------------------------------------------------------
1292 * ROOT CONTAINER FOR DAY VIEW
1293 * --------------------------------------------------------
1294 *
1295 * This vertical box contains:
1296 * 1. Date header (weekday + date)
1297 * 2. Holiday rows (if any)
1298 * 3. Event list OR "No events"
1299 * 4. Add/Edit form (optional, depending on mode)
1300 * 5. Action bar
1301 * 6. Navigation back to Month View
1302 */
1303
1304 const box = new St.BoxLayout({
1305 vertical: true,
1306 style_class: "calendar-events-main-box",
1307 });
1308
1309 /* --------------------------------------------------------
1310 * RESOLVE SELECTED DATE
1311 * --------------------------------------------------------
1312 *
1313 * selectedDay should normally be set.
1314 * Fallback to day "1" is a defensive safeguard and should
1315 * normally never be hit during regular operation.
1316 */
1317
1318 const selectedDate = new Date(
1319 this.displayedYear,
1320 this.displayedMonth,
1321 this.selectedDay || 1
1322 );
1323
1324 /* --------------------------------------------------------
1325 * DAY MODE GUARD (VERY IMPORTANT)
1326 * --------------------------------------------------------
1327 *
1328 * dayMode controls the internal UI sub-state:
1329 * - "VIEW" → show events only
1330 * - "ADD" → show create form
1331 * - "EDIT" → show edit form for a specific event
1332 *
1333 * This guard ensures:
1334 * - ADD / EDIT modes are ONLY valid for the exact date
1335 * they were initiated on.
1336 *
1337 * Example problem without this guard:
1338 * - User clicks "Edit" on Jan 5
1339 * - Then navigates to Jan 6
1340 * - Edit form would still be shown for the wrong day
1341 *
1342 * Therefore:
1343 * If the date changes → reset to VIEW mode.
1344 */
1345
1346 if (
1347 this.dayMode !== "VIEW" &&
1348 (
1349 !this.dayModeDate ||
1350 this.dayModeDate.toDateString() !== selectedDate.toDateString()
1351 )
1352 ) {
1353 this.dayMode = "VIEW";
1354 this.editingEvent = null;
1355 this.dayModeDate = null;
1356 }
1357
1358 /* --------------------------------------------------------
1359 * DATE HEADER
1360 * --------------------------------------------------------
1361 *
1362 * Displays the full localized date:
1363 * e.g. "Tuesday, 05 January 2026"
1364 */
1365
1366 box.add_actor(
1367 new St.Label({
1368 text: selectedDate.toLocaleString(this.LOCALE, {
1369 weekday: "long",
1370 day: "2-digit",
1371 month: "long",
1372 year: "numeric",
1373 }),
1374 style_class: "day-details-title",
1375 })
1376 );
1377
1378 /* --------------------------------------------------------
1379 * HOLIDAYS SECTION (OPTIONAL)
1380 * --------------------------------------------------------
1381 *
1382 * Holidays are resolved via CalendarLogic (if present).
1383 *
1384 * This is completely independent from events.
1385 * Holidays are rendered as visual info rows only.
1386 */
1387
1388 if (this.applet.CalendarLogic) {
1389 const holidays =
1390 this.applet.CalendarLogic.getHolidaysForDate(
1391 selectedDate,
1392 "de"
1393 );
1394
1395 holidays.forEach(h => {
1396 const row = new St.BoxLayout({
1397 style_class: "calendar-event-button",
1398 style:
1399 "background-color: rgba(255,0,0,0.1);",
1400 });
1401 row.add_actor(
1402 new St.Label({
1403 text: h,
1404 style_class:
1405 "calendar-event-summary",
1406 })
1407 );
1408 box.add_actor(row);
1409 });
1410 }
1411
1412 /* --------------------------------------------------------
1413 * FETCH EVENTS FOR SELECTED DATE
1414 * --------------------------------------------------------
1415 *
1416 * EventManager is responsible for all date filtering.
1417 * CalendarView never interprets start/end times itself.
1418 */
1419
1420 const events =
1421 this.applet.eventManager.getEventsForDate(
1422 selectedDate
1423 );
1424
1425 /* --------------------------------------------------------
1426 * EVENT LIST RENDERING
1427 * --------------------------------------------------------
1428 *
1429 * Each event is rendered as:
1430 * - Summary label
1431 * - Edit button
1432 *
1433 * No delete button exists here by design.
1434 * (Deletion semantics are non-trivial with EDS.)
1435 */
1436
1437 if (events.length > 0) {
1438 events.forEach((ev: any) => {
1439 const row = new St.BoxLayout({
1440 style_class: "calendar-event-button",
1441 });
1442
1443 /* Event summary */
1444 row.add_actor(
1445 new St.Label({
1446 text: ev.summary,
1447 style_class: "calendar-event-summary",
1448 })
1449 );
1450
1451 /* Edit button */
1452 const editBtn = new St.Button({
1453 label: _("Edit"),
1454 style_class: "calendar-event-edit-button",
1455 });
1456
1457 editBtn.connect("clicked", () => {
1458 this.dayMode = "EDIT";
1459 this.editingEvent = ev;
1460 this.dayModeDate = selectedDate;
1461 this.render();
1462 });
1463
1464 row.add_actor(editBtn);
1465 box.add_actor(row);
1466 });
1467 }
1468
1469 /* --------------------------------------------------------
1470 * NO EVENTS PLACEHOLDER
1471 * --------------------------------------------------------
1472 *
1473 * Only shown in VIEW mode.
1474 * Not shown when ADD or EDIT form is active.
1475 */
1476
1477 if (events.length === 0 && this.dayMode === "VIEW") {
1478 box.add_actor(
1479 new St.Label({
1480 text: _("No events"),
1481 style_class: "calendar-events-no-events-label",
1482 })
1483 );
1484 }
1485
1486 /* --------------------------------------------------------
1487 * ADD / EDIT FORM INJECTION
1488 * --------------------------------------------------------
1489 *
1490 * createTerminForm() dynamically builds a form UI.
1491 *
1492 * IMPORTANT ARCHITECTURAL NOTE:
1493 *
1494 * This tightly couples:
1495 * - Day View
1496 * - Event creation/editing UI
1497 *
1498 * TODO (Refactor Idea):
1499 * - Extract createTerminForm into a separate component
1500 * - Or move all form logic into EventListView or a Dialog
1501 *
1502 * WHY THIS IS CURRENTLY PROBLEMATIC:
1503 *
1504 * - Cinnamon CalendarServer + EDS via GJS/GIR is unreliable
1505 * for CREATE operations (especially description fields).
1506 * - Description handling is partially broken / inconsistent.
1507 * - Because of this, ADD functionality is currently limited.
1508 */
1509
1510 if (this.dayMode === "ADD") {
1511 box.add_actor(
1512 this.createTerminForm(selectedDate)
1513 );
1514 }
1515 else if (this.dayMode === "EDIT" && this.editingEvent) {
1516 box.add_actor(
1517 this.createTerminForm(selectedDate, this.editingEvent)
1518 );
1519 }
1520
1521 /* --------------------------------------------------------
1522 * NAVIGATION BACK TO MONTH VIEW
1523 * --------------------------------------------------------
1524 *
1525 * Always resets Day View state.
1526 */
1527
1528 const backBtn = new St.Button({
1529 label: _("Month view"),
1530 style_class: "nav-button",
1531 style: "margin-top: 15px;",
1532 });
1533
1534 backBtn.connect("clicked", () => {
1535 this.currentView = "MONTH";
1536 this.dayMode = "VIEW";
1537 this.editingEvent = null;
1538 this.dayModeDate = null;
1539 this.render();
1540 });
1541
1542 /* --------------------------------------------------------
1543 * ACTION BAR (CURRENTLY MOSTLY UNUSED)
1544 * --------------------------------------------------------
1545 *
1546 * Originally planned to host the "Add event" button.
1547 *
1548 * WHY IT IS COMMENTED OUT:
1549 *
1550 * - Event creation via GJS / GIR / EDS is currently unstable
1551 * - Description fields are buggy or missing
1552 * - Creating events may silently fail depending on source
1553 *
1554 * TODO:
1555 * Re-enable ONLY when:
1556 * - EventManager.create() is fully reliable
1557 * - Description roundtrips correctly via CalendarServer
1558 */
1559
1560 const actionBar = new St.BoxLayout({
1561 style_class: "calendar-day-actions",
1562 x_align: St.Align.END
1563 });
1564
1565 /*
1566 if (this.dayMode === "VIEW") {
1567 const addBtn = new St.Button({
1568 label: _("Add event"),
1569 style_class: "calendar-event-button"
1570 });
1571
1572 addBtn.connect("clicked", () => {
1573 this.dayMode = "ADD";
1574 this.editingEvent = null;
1575 this.dayModeDate = selectedDate;
1576 this.render();
1577 });
1578
1579 actionBar.add_actor(addBtn);
1580 }
1581 */
1582
1583 /* --------------------------------------------------------
1584 * FINAL ASSEMBLY
1585 * --------------------------------------------------------
1586 */
1587
1588 box.add_actor(actionBar);
1589 box.add_actor(backBtn);
1590 this.contentBox.add_actor(box);
1591 }
1592
1593 /* ============================================================
1594 * CREATE / EDIT FORM
1595 * ============================================================
1596 *
1597 * This method builds the inline form used to:
1598 * - Create a new calendar event
1599 * - Edit an existing calendar event
1600 *
1601 * IMPORTANT CONTEXT (How we get here):
1602 *
1603 * - This form is ONLY rendered from renderDayView()
1604 * - It is injected into the Day View depending on `dayMode`
1605 * ("ADD" or "EDIT")
1606 * - The surrounding popup is NOT a dialog but part of the
1607 * Cinnamon applet popup UI
1608 *
1609 * WHY THIS IS INLINE (and not a dialog):
1610 *
1611 * - Cinnamon applets have limited dialog APIs
1612 * - Keeping everything inside the popup avoids focus issues
1613 * - Rendering inline allows full reuse of CalendarView state
1614 *
1615 * TODO (Architectural Improvement):
1616 * - Extract this form into a dedicated component or dialog
1617 * - Decouple UI logic from CalendarView
1618 * - Allow asynchronous validation / error feedback
1619 */
1620
1621 private createTerminForm(date: Date, editingEvent?: any): any {
1622
1623 /* --------------------------------------------------------
1624 * ROOT CONTAINER
1625 * --------------------------------------------------------
1626 *
1627 * Vertical layout that contains:
1628 * - All-day toggle
1629 * - Title input
1630 * - Time inputs
1631 * - (Optional) description input (currently disabled)
1632 * - Save button
1633 */
1634
1635 const box = new St.BoxLayout({
1636 vertical: true,
1637 style_class: "calendar-main-box",
1638 x_expand: true
1639 });
1640
1641 /* --------------------------------------------------------
1642 * EVENT ID HANDLING
1643 * --------------------------------------------------------
1644 *
1645 * - If editing an existing event:
1646 * → reuse its UUID
1647 * - If creating a new event:
1648 * → do NOT generate a UUID here
1649 *
1650 * WHY NOT GENERATE UUID HERE?
1651 *
1652 * - EDS (Evolution Data Server) is able to generate IDs
1653 * - Manual UUID handling is error-prone with EDS sources
1654 * - Let EventManager / backend decide when necessary
1655 */
1656
1657 const currentId = editingEvent ? editingEvent.id : undefined;
1658
1659 /* --------------------------------------------------------
1660 * ALL-DAY TOGGLE
1661 * --------------------------------------------------------
1662 *
1663 * This replaces a traditional checkbox.
1664 *
1665 * Reason:
1666 * - St does not have a native checkbox widget
1667 * - Button with toggle_mode is more consistent visually
1668 *
1669 * Behavior:
1670 * - When enabled:
1671 * → Time fields become visually disabled
1672 * → Time fields are non-interactive
1673 */
1674
1675 const isInitialFullDay = editingEvent ? editingEvent.isFullDay : false;
1676 const allDayCheckbox = new St.Button({
1677 label: isInitialFullDay ? "☑ " + _("All Day") : "☐ " + _("All Day"),
1678 style_class: "calendar-event-button",
1679 toggle_mode: true,
1680 checked: isInitialFullDay,
1681 x_align: St.Align.START
1682 });
1683
1684 /* --------------------------------------------------------
1685 * TITLE / SUMMARY ENTRY
1686 * --------------------------------------------------------
1687 *
1688 * This maps directly to the VEVENT SUMMARY field.
1689 * It is the ONLY required field for saving.
1690 */
1691
1692 const titleEntry = new St.Entry({
1693 hint_text: _("What? (Nice event)"),
1694 style_class: "calendar-event-summary",
1695 text: editingEvent ? editingEvent.summary : ""
1696 });
1697
1698 /* --------------------------------------------------------
1699 * TIME HANDLING
1700 * --------------------------------------------------------
1701 *
1702 * Time values are handled as strings (HH:MM) in the UI.
1703 * Conversion to Date objects happens only on Save.
1704 */
1705
1706 const formatTime = (d: Date) => {
1707 return `${String(d.getHours()).padStart(2, "0")}:${String(d.getMinutes()).padStart(2, "0")}`;
1708 };
1709
1710 const startTimeStr = editingEvent
1711 ? formatTime(editingEvent.start)
1712 : this._getCurrentTime();
1713
1714 const endTimeStr = editingEvent
1715 ? formatTime(editingEvent.end)
1716 : this._calculateDefaultEnd(startTimeStr);
1717
1718 const descriptionStr =
1719 (editingEvent && typeof editingEvent.description === 'string')
1720 ? editingEvent.description
1721 : "";
1722
1723 const timeBox = new St.BoxLayout({
1724 vertical: false,
1725 style: "margin: 5px 0;"
1726 });
1727
1728 const startEntry = new St.Entry({
1729 text: startTimeStr,
1730 style_class: "calendar-event-time-present",
1731 can_focus: true,
1732 reactive: true
1733 });
1734
1735 const endEntry = new St.Entry({
1736 text: endTimeStr,
1737 style_class: "calendar-event-time-present",
1738 can_focus: true,
1739 reactive: true
1740 });
1741
1742 /* --------------------------------------------------------
1743 * TIME FIELD VISIBILITY / ENABLE LOGIC
1744 * --------------------------------------------------------
1745 *
1746 * Centralized logic to enable / disable time fields
1747 * depending on the All-Day toggle.
1748 *
1749 * Visual feedback:
1750 * - Reduced opacity when disabled
1751 * - Input and focus disabled
1752 */
1753
1754 const updateVisibility = () => {
1755 const isFullDay = allDayCheckbox.checked;
1756
1757 allDayCheckbox.set_label(
1758 isFullDay ? "☑ " + _("All Day") : "☐ " + _("All Day")
1759 );
1760
1761 const opacity = isFullDay ? 128 : 255;
1762 startEntry.set_opacity(opacity);
1763 endEntry.set_opacity(opacity);
1764 startEntry.set_reactive(!isFullDay);
1765 endEntry.set_reactive(!isFullDay);
1766 startEntry.can_focus = !isFullDay;
1767 endEntry.can_focus = !isFullDay;
1768 };
1769
1770 allDayCheckbox.connect("clicked", () => {
1771 updateVisibility();
1772 });
1773
1774 timeBox.add_actor(new St.Label({
1775 text: _("From:"),
1776 style: "margin-right: 5px;"
1777 }));
1778 timeBox.add_actor(startEntry);
1779 timeBox.add_actor(new St.Label({
1780 text: _("To:"),
1781 style: "margin: 0 5px;"
1782 }));
1783 timeBox.add_actor(endEntry);
1784
1785 /* --------------------------------------------------------
1786 * DESCRIPTION FIELD (DISABLED / COMMENTED OUT)
1787 * --------------------------------------------------------
1788 *
1789 * WHY THIS IS DISABLED:
1790 *
1791 * - Cinnamon CalendarServer + GJS/GIR has known issues
1792 * with VEVENT DESCRIPTION handling
1793 * - set_description / set_description_list behave
1794 * inconsistently across environments
1795 * - Description data may be silently dropped or break
1796 * CREATE operations
1797 *
1798 * CURRENT DECISION:
1799 * - Keep the UI code commented for future reference
1800 * - Do NOT expose broken functionality to users
1801 *
1802 * TODO:
1803 * - Re-enable when EDS + CalendarServer reliably
1804 * supports description roundtrips
1805 */
1806
1807 /*
1808 const descEntry = new St.Entry({
1809 hint_text: _("Description"),
1810 style_class: "calendar-event-row-content",
1811 x_expand: true,
1812 text: descriptionStr
1813 });
1814 */
1815
1816 /*
1817 descEntry.clutter_text.single_line_mode = false;
1818 descEntry.clutter_text.line_wrap = true;
1819 descEntry.clutter_text.set_activatable(false);
1820 */
1821
1822 /* --------------------------------------------------------
1823 * SAVE BUTTON
1824 * --------------------------------------------------------
1825 *
1826 * On click:
1827 * - Validate title
1828 * - Build Date objects
1829 * - Delegate persistence to EventManager
1830 *
1831 * IMPORTANT UX NOTE:
1832 *
1833 * After Save:
1834 * - The form closes immediately
1835 * - The popup re-renders
1836 *
1837 * This feels abrupt for users.
1838 *
1839 * TODO (UX Improvement):
1840 * - Keep form open until backend confirms success
1841 * - Show error feedback on failure
1842 */
1843
1844 const buttonBox = new St.BoxLayout({
1845 style: "margin-top: 10px;"
1846 });
1847
1848 const saveBtn = new St.Button({
1849 label: editingEvent ? _("Update") : _("Save"),
1850 style_class: "calendar-event-button",
1851 x_expand: true
1852 });
1853
1854 saveBtn.connect("clicked", () => {
1855 const title = titleEntry.get_text().trim();
1856 if (!title) return;
1857
1858 const isFullDay = allDayCheckbox.checked;
1859 const start = this._buildDateTime(date, startEntry.get_text());
1860 const end = this._buildDateTime(date, endEntry.get_text());
1861
1862 this.applet.eventManager.addEvent({
1863 id: currentId,
1864 sourceUid: editingEvent ? editingEvent.sourceUid : undefined,
1865 summary: title,
1866 // Description intentionally disabled due to EDS limitations
1867 description: "",
1868 start: start,
1869 end: end,
1870 isFullDay: isFullDay,
1871 color: editingEvent ? editingEvent.color : "#3498db"
1872 });
1873
1874 /* Reset Day View state */
1875 this.dayMode = "VIEW";
1876 this.editingEvent = null;
1877 this.render();
1878 });
1879
1880 /* --------------------------------------------------------
1881 * FINAL ASSEMBLY
1882 * --------------------------------------------------------
1883 */
1884
1885 box.add_actor(allDayCheckbox);
1886 box.add_actor(titleEntry);
1887 box.add_actor(timeBox);
1888 /* box.add_actor(descEntry); */
1889 buttonBox.add_actor(saveBtn);
1890 box.add_actor(buttonBox);
1891
1892 /* Ensure correct initial state */
1893 updateVisibility();
1894
1895 return box;
1896 }
1897
1898 /* ============================================================
1899 * DATE HELPERS
1900 * ============================================================
1901 *
1902 * The following helper methods are pure utility functions
1903 * used throughout CalendarView.
1904 *
1905 * They are intentionally kept INSIDE the class instead of
1906 * being moved to a shared utils module.
1907 *
1908 * WHY?
1909 * - They depend on this.LOCALE
1910 * - They are tightly coupled to calendar rendering logic
1911 * - Keeping them here improves readability for new developers
1912 *
1913 * TODO (Refactoring Option):
1914 * - These helpers could be extracted into a separate
1915 * DateUtils module if CalendarView ever gets split
1916 * into multiple files.
1917 */
1918
1919 /**
1920 * Returns localized short weekday names.
1921 *
1922 * Example (de_DE):
1923 * ["Mo", "Di", "Mi", "Do", "Fr", "Sa", "So"]
1924 *
1925 * IMPORTANT:
1926 * - Uses Intl.DateTimeFormat for proper localization
1927 * - Avoids hardcoding weekday strings
1928 *
1929 * Implementation detail:
1930 * - We generate arbitrary dates (Jan 1–7, 2024)
1931 * - Only the weekday part is relevant
1932 */
1933 private getDayNames(): string[] {
1934 const formatter = new Intl.DateTimeFormat(this.LOCALE, {
1935 weekday: "short",
1936 });
1937
1938 return [1, 2, 3, 4, 5, 6, 7].map(d =>
1939 formatter.format(new Date(2024, 0, d))
1940 );
1941 }
1942
1943 /**
1944 * Calculates ISO-8601 week number for a given date.
1945 *
1946 * WHY THIS EXISTS:
1947 * - JavaScript does NOT provide a native week number API
1948 * - Cinnamon calendar optionally displays week numbers
1949 *
1950 * Implementation notes:
1951 * - Uses UTC to avoid timezone-related off-by-one errors
1952 * - Thursday-based week calculation per ISO standard
1953 *
1954 * Reference:
1955 * - ISO 8601 defines week 1 as the week containing Jan 4th
1956 */
1957 private getWeekNumber(date: Date): number {
1958 const d = new Date(
1959 Date.UTC(
1960 date.getFullYear(),
1961 date.getMonth(),
1962 date.getDate()
1963 )
1964 );
1965
1966 // Move to Thursday of the current week
1967 d.setUTCDate(
1968 d.getUTCDate() + 4 - (d.getUTCDay() || 7)
1969 );
1970
1971 const yearStart = new Date(
1972 Date.UTC(d.getUTCFullYear(), 0, 1)
1973 );
1974
1975 return Math.ceil(
1976 (
1977 (d.getTime() - yearStart.getTime()) /
1978 86400000 +
1979 1
1980 ) / 7
1981 );
1982 }
1983
1984 /* ============================================================
1985 * TIME / DATE HELPERS FOR DAY VIEW FORMS
1986 * ============================================================
1987 *
1988 * These helpers are used exclusively by:
1989 * - createTerminForm()
1990 * - Day View time handling
1991 *
1992 * They convert between:
1993 * - UI-friendly strings (HH:MM)
1994 * - JavaScript Date objects
1995 *
1996 * WHY THIS IS NECESSARY:
1997 * - St.Entry widgets only handle strings
1998 * - EventManager requires Date objects
1999 * - Keeping conversion logic centralized avoids bugs
2000 */
2001
2002 /**
2003 * Returns current local time formatted as HH:MM.
2004 *
2005 * Used when creating a NEW event to pre-fill
2006 * the start time field.
2007 */
2008 private _getCurrentTime(): string {
2009 const now = new Date();
2010 return `${String(now.getHours()).padStart(2, "0")}:${String(
2011 now.getMinutes()
2012 ).padStart(2, "0")}`;
2013 }
2014
2015 /**
2016 * Calculates a default end time (+1 hour)
2017 * based on a given start time string.
2018 *
2019 * Example:
2020 * startTime = "14:30" → endTime = "15:30"
2021 *
2022 * Used for:
2023 * - New events
2024 * - Improving UX by avoiding empty end fields
2025 */
2026 private _calculateDefaultEnd(startTime: string): string {
2027 const [h, m] = startTime.split(":").map(Number);
2028 const d = new Date();
2029 d.setHours(h + 1, m, 0, 0);
2030
2031 return `${String(d.getHours()).padStart(2, "0")}:${String(
2032 d.getMinutes()
2033 ).padStart(2, "0")}`;
2034 }
2035
2036 /**
2037 * Builds a Date object from:
2038 * - A base calendar date
2039 * - A time string (HH:MM)
2040 *
2041 * This is the final step before sending
2042 * data to EventManager / EDS.
2043 *
2044 * Example:
2045 * date = 2026-01-05
2046 * time = "09:15"
2047 * → Date(2026-01-05T09:15:00)
2048 */
2049 private _buildDateTime(date: Date, time: string): Date {
2050 const [h, m] = time.split(":").map(Number);
2051 const d = new Date(date);
2052 d.setHours(h, m, 0, 0);
2053 return d;
2054 }
2055
2056}
2057
2058/* ================================================================
2059 * HYBRID EXPORT STRATEGY (DEVELOPMENT vs PRODUCTION)
2060 * ================================================================
2061 *
2062 * This section is EXTREMELY IMPORTANT.
2063 *
2064 * DO NOT REMOVE unless you fully understand Cinnamon's
2065 * loading mechanisms.
2066 *
2067 * WHY THIS EXISTS:
2068 *
2069 * Cinnamon applets run in a hybrid environment:
2070 *
2071 * 1) DEVELOPMENT / BUILD TIME
2072 * - TypeScript
2073 * - Modular imports
2074 * - Bundlers / transpilers
2075 *
2076 * 2) PRODUCTION / RUNTIME
2077 * - GJS (GNOME JavaScript)
2078 * - No real module loader
2079 * - Global namespace access
2080 *
2081 * To support BOTH environments, we export the class in
2082 * two different ways.
2083 */
2084
2085/* ---------------------------------------------------------------
2086 * CommonJS-style export (build / tooling environments)
2087 * ---------------------------------------------------------------
2088 *
2089 * This allows:
2090 * - Unit testing
2091 * - TypeScript compilation
2092 * - IDE tooling
2093 */
2094
2095if (typeof exports !== "undefined") {
2096 exports.CalendarView = CalendarView;
2097}
2098
2099/* ---------------------------------------------------------------
2100 * Global export (Cinnamon runtime)
2101 * ---------------------------------------------------------------
2102 *
2103 * Cinnamon loads applets by evaluating a single JS file.
2104 * There is NO module system at runtime.
2105 *
2106 * Therefore:
2107 * - Classes MUST be attached to the global object
2108 * - Other files access them via global.CalendarView
2109 *
2110 * The `(global as any)` cast:
2111 * - Is required to silence TypeScript
2112 * - Reflects the dynamic nature of GJS
2113 *
2114 * WARNING TO FUTURE DEVELOPERS:
2115 *
2116 * Removing this WILL:
2117 * - Work in development
2118 * - FAIL in production
2119 * - Break runtime imports silently
2120 */
2121
2122(global as any).CalendarView = CalendarView;
2123