Project IT Calendar 1.0.0
Advanced Calendar Applet for Cinnamon Desktop Environment
Loading...
Searching...
No Matches
CalendarLogic.ts
Go to the documentation of this file.
1/**
2 * @file CalendarLogic.ts
3 * @brief Business logic for holiday and date calculations
4 *
5 * @details Pure logic component for date mathematics and holiday detection with regional rules.
6 *
7 * @author Arnold Schiller <calendar@projektit.de>
8 * @date 2023-2026
9 * @copyright GPL-3.0-or-later
10 */
11/*
12 * Project IT Calendar - Business Logic Core (Holiday & Date Calculations)
13 * =======================================================================
14 *
15 * This module handles all date-related business logic, primarily focusing on:
16 * 1. Holiday calculation and regional holiday rules
17 * 2. Date mathematics and calendar algorithms
18 * 3. Pure business logic without UI or I/O dependencies
19 *
20 * IMPORTANT ARCHITECTURAL CONTEXT:
21 * ---------------------------------
22 * CalendarLogic is intentionally designed as a PURE LOGIC component:
23 * - NO UI dependencies (doesn't import St, Clutter, etc.)
24 * - NO I/O operations (except initial holiday data loading)
25 * - NO side effects (deterministic calculations only)
26 *
27 * This makes it:
28 * - Easily testable (all methods are deterministic)
29 * - Reusable across different UI implementations
30 * - Independent of Cinnamon/GJS environment
31 *
32 * ------------------------------------------------------------------
33 * DATA SOURCES AND REGIONALIZATION:
34 * ------------------------------------------------------------------
35 * Holiday data is stored in JSON files in the holidays/ directory:
36 *
37 * Structure:
38 * holidays/
39 * de.json - Base German holidays (national)
40 * de-BY.json - Bavaria-specific holidays (regional)
41 * de-BE.json - Berlin-specific holidays
42 * en.json - English/International holidays
43 * (etc.)
44 *
45 * Each JSON file follows this structure:
46 * {
47 * "regions": {
48 * "de": [ ...holiday rules... ],
49 * "de-BY": [ ...regional rules... ]
50 * }
51 * }
52 *
53 * Holiday rules use a compact format:
54 * {
55 * "k": "f", // Type: 'f'=fixed, 'e'=easter-based, 'r'=relative
56 * "m": 1, // Month (1-12) for fixed dates
57 * "d": 1, // Day for fixed dates
58 * "o": -2, // Offset from Easter (for easter-based)
59 * "n": "New Year", // Holiday name
60 * "p": true, // Public holiday (true) or observance (false)
61 * "c": "year<=1994" // Optional condition for historical changes
62 * }
63 *
64 * ------------------------------------------------------------------
65 * HOW REGIONS WORK:
66 * ------------------------------------------------------------------
67 * The system supports hierarchical regional holiday resolution:
68 *
69 * Example: User in Munich (Bavaria, Germany)
70 * 1. First checks "de-BY" (Bavaria-specific holidays)
71 * 2. Then checks "de" (German national holidays)
72 * 3. Combines both lists, removing duplicates
73 *
74 * This allows:
75 * - Regional specificity (Bavaria has different holidays than Berlin)
76 * - Fallback to national holidays
77 * - No code changes needed for new regions (just add JSON files)
78 *
79 * ------------------------------------------------------------------
80 * USAGE BY OTHER COMPONENTS:
81 * ------------------------------------------------------------------
82 * CalendarLogic is used by:
83 *
84 * 1. applet.ts (Header Display):
85 * - setHeaderDate() calls getHolidaysForDate() to show holiday names
86 * - Displays holidays in the popup header
87 *
88 * 2. CalendarView.ts (Month Grid):
89 * - renderMonthView() calls getHolidaysForDate() for each cell
90 * - Highlights holiday cells with special CSS classes
91 * - Shows holiday tooltips on hover
92 *
93 * 3. CalendarDay.ts (Type Definitions):
94 * - Provides TypeScript interfaces used here
95 * - Ensures type safety across date calculations
96 *
97 * ------------------------------------------------------------------
98 * DESIGN DECISIONS:
99 * ------------------------------------------------------------------
100 * 1. HYBRID MODULE SYSTEM:
101 * Uses 'export' for AMD/UMD compatibility and 'global' assignment for
102 * monolithic bundling (outFile). This satisfies both Cinnamon's
103 * internal requireModule and tsc's 'None' module setting.
104 *
105 * 2. GJS COMPATIBILITY:
106 * Uses native GLib for file operations instead of Node.js 'fs',
107 * ensuring it runs inside the Cinnamon/SpiderMonkey environment.
108 *
109 * 3. TYPE SAFETY:
110 * Imports types from CalendarDay.ts. Note that in 'None' mode,
111 * these imports are purely for the compiler and emit no JS code.
112 *
113 * ------------------------------------------------------------------
114 * @author Arnold Schiller <calendar@projektit.de>
115 * @link https://github.com/ArnoldSchiller/calendar
116 * @link https://projektit.de/kalender
117 * @license GPL-3.0-or-later
118 */
119
120/* ================================================================
121 * TYPE IMPORTS (TypeScript Only - Stripped at Runtime)
122 * ================================================================
123 *
124 * These imports are ONLY for TypeScript type checking and development.
125 * In production (GJS runtime), they don't exist - hence the 'None' module mode.
126 *
127 * CalendarDay.ts defines interfaces for type-safe date handling.
128 */
129
130import { CalendarDay, DayType } from './CalendarDay';
131
132/* ================================================================
133 * GJS / CINNAMON IMPORTS
134 * ================================================================
135 *
136 * GLib is used for file operations and system locale detection.
137 * This is the ONLY external dependency, making CalendarLogic mostly
138 * environment-agnostic.
139 */
140
141const GLib = imports.gi.GLib;
142
143/* ================================================================
144 * HOLIDAY INTERFACE DEFINITION
145 * ================================================================
146 *
147 * Defines the structure of holiday rules in JSON files.
148 *
149 * WHY THIS COMPACT FORMAT?
150 * - Minimizes file size (important for applet performance)
151 * - Easy to parse and process
152 * - Human-readable for manual editing
153 */
154
155export interface Holiday {
156 /**
157 * Rule type key:
158 * - 'f': Fixed date (e.g., Christmas on Dec 25)
159 * - 'e': Easter-based (e.g., Good Friday = Easter - 2)
160 * - 'r': Relative (e.g., "first Monday in September")
161 */
162 k: string;
163
164 /**
165 * Day of month (1-31) for fixed dates.
166 * Optional for non-fixed holidays.
167 */
168 d?: number;
169
170 /**
171 * Month of year (1-12) for fixed dates.
172 * Optional for non-fixed holidays.
173 */
174 m?: number;
175
176 /**
177 * Offset in days from Easter (for easter-based holidays).
178 * Example: -2 = Good Friday, +49 = Pentecost Monday
179 */
180 o?: number;
181
182 /**
183 * Holiday name in local language.
184 * Displayed in UI headers and tooltips.
185 */
186 n: string;
187
188 /**
189 * Public holiday flag:
190 * - true: Official public holiday (banks/schools closed)
191 * - false: Observance or traditional holiday (normal work day)
192 */
193 p: boolean;
194
195 /**
196 * Optional condition for historical rule changes.
197 * Format: "year<=1994" or "year>=2000"
198 * Used for holidays that changed over time.
199 */
200 c?: string;
201}
202
203/* ================================================================
204 * CALENDAR LOGIC CLASS
205 * ================================================================
206 *
207 * Main business logic class for all date and holiday calculations.
208 *
209 * PERFORMANCE CHARACTERISTICS:
210 * - Holiday data loaded ONCE at initialization
211 * - All calculations are O(1) or O(n) where n is small (< 100 rules)
212 * - No network calls or blocking I/O after initialization
213 */
214
215/**
216 * @class CalendarLogic
217 * @brief Main calendarlogic class
218 *
219 * @details For detailed documentation see the main class documentation.
220 */
221/**
222 * @class CalendarLogic
223 * @brief Main calendarlogic class
224 *
225 * @details For detailed documentation see the main class documentation.
226 */
227export class CalendarLogic {
228 /**
229 * In-memory cache of holiday rules by region.
230 * Structure: { "de": [Holiday...], "de-BY": [Holiday...], ... }
231 *
232 * WHY CACHE IN MEMORY?
233 * - JSON files are small (< 10KB each)
234 * - Eliminates disk I/O during calendar navigation
235 * - Fast random access for date lookups
236 */
237 private holidayData: { [region: string]: Holiday[] } = {};
238
239 /**
240 * Filesystem path to the applet directory.
241 * Used to locate holiday JSON files in holidays/ subdirectory.
242 */
243 private appletDir: string;
244
245 /* ============================================================
246 * CONSTRUCTOR
247 * ============================================================
248 *
249 * Initializes the logic component and loads holiday data.
250 *
251 * @param appletDir - Absolute path to applet directory (from metadata.path)
252 */
253
254 constructor(appletDir: string) {
255 this.appletDir = appletDir;
256 this.loadHolidays();
257 }
258
259 /* ============================================================
260 * HOLIDAY DATA LOADING
261 * ============================================================
262 *
263 * Loads holiday definitions based on system locale.
264 *
265 * LOCALE RESOLUTION LOGIC:
266 * 1. Get system locale (e.g., "de_DE.UTF-8")
267 * 2. Extract language code (e.g., "de")
268 * 3. Look for holidays/{lang}.json
269 * 4. Fallback: No holidays if file not found
270 *
271 * WHY AUTO-DETECT LOCALE?
272 * - Users don't need to configure their region
273 * - Works out-of-the-box for common locales
274 * - Can be extended with manual region selection
275 */
276
277 private loadHolidays() {
278 try {
279 // Get system locale - GLib provides reliable locale detection
280 let locale = GLib.get_language_names()[0] || "en";
281
282 // Extract base language code (e.g., "de_DE" → "de")
283 let lang = locale.split('_')[0].split('.')[0].toLowerCase();
284
285 // Construct path to holiday definitions file
286 let filePath = `${this.appletDir}/holidays/${lang}.json`;
287
288 // Check if holiday file exists for this language
289 if (GLib.file_test(filePath, GLib.FileTest.EXISTS)) {
290 // Read file contents (synchronous - only done once at startup)
291 let [success, content] = GLib.file_get_contents(filePath);
292
293 if (success) {
294 // Parse JSON and extract regions data
295 let json = JSON.parse(content.toString());
296 this.holidayData = json.regions || {};
297 }
298 }
299 } catch (e) {
300 // Log error but don't crash - applet works without holidays
301 if (typeof global !== 'undefined') {
302 global.log(`[CalendarLogic] Error loading holidays: ${e}`);
303 }
304 }
305 }
306
307 /* ============================================================
308 * PUBLIC API: GET HOLIDAYS FOR DATE
309 * ============================================================
310 *
311 * Primary public method used by UI components.
312 * Returns all holidays for a specific date and region.
313 *
314 * REGIONAL RESOLUTION LOGIC:
315 * 1. Get region-specific rules (e.g., "de-BY" for Bavaria)
316 * 2. Get base language rules (e.g., "de" for Germany)
317 * 3. Combine both lists, removing duplicates
318 *
319 * @param date - JavaScript Date object to check
320 * @param region - Regional code (e.g., "de-BY"). Defaults to "de"
321 * @returns Array of unique holiday names (empty if no holidays)
322 *
323 * USAGE EXAMPLES:
324 * - getHolidaysForDate(new Date(2026-01-01), "de") → ["New Year"]
325 * - getHolidaysForDate(new Date(2026-10-03), "de") → ["German Unity Day"]
326 * - getHolidaysForDate(new Date(2026-12-25), "de") → ["Christmas Day"]
327 */
328
329 public getHolidaysForDate(date: Date, region: string = "de"): string[] {
330 let dayHolidays: string[] = [];
331
332 // Get regional rules (e.g., Bavaria-specific)
333 let rules = this.holidayData[region] || [];
334
335 // Get base language rules (e.g., German national)
336 let baseRegion = region.split('-')[0];
337 let baseRules = this.holidayData[baseRegion] || [];
338
339 // Combine regional + base rules (regional takes precedence)
340 let allRules = rules.concat(baseRules);
341
342 // Check each rule against the target date
343 for (let rule of allRules) {
344 if (this.isHolidayMatch(rule, date)) {
345 dayHolidays.push(rule.n);
346 }
347 }
348
349 /**
350 * REMOVE DUPLICATES:
351 * Using a Set ensures that if a holiday (like "New Year") is defined in
352 * both the base and regional rules, it only appears once in the UI.
353 *
354 * WHY USE SET INSTEAD OF ARRAY CHECKS?
355 * - Simpler code
356 * - Better performance for small arrays
357 * - Maintains insertion order
358 */
359 return [...new Set(dayHolidays)];
360 }
361
362 /* ============================================================
363 * HOLIDAY RULE MATCHING ENGINE
364 * ============================================================
365 *
366 * Core matching logic for different holiday types.
367 *
368 * SUPPORTED HOLIDAY TYPES:
369 * 1. Fixed dates (k='f'): Christmas, New Year, etc.
370 * 2. Easter-based (k='e'): Good Friday, Pentecost, etc.
371 * 3. Relative dates (k='r'): Not implemented in current version
372 *
373 * @param rule - Holiday rule definition from JSON
374 * @param date - Date to check against
375 * @returns true if date matches the holiday rule
376 */
377
378 private isHolidayMatch(rule: Holiday, date: Date): boolean {
379 const year = date.getFullYear();
380 const month = date.getMonth() + 1; // Convert JS 0-index to 1-index
381 const day = date.getDate();
382
383 // Check year-based conditions first (e.g., historical changes)
384 if (rule.c && !this.checkCondition(rule.c, year)) {
385 return false;
386 }
387
388 // Fixed date holiday (e.g., Christmas on Dec 25)
389 if (rule.k === 'f') {
390 return rule.m === month && rule.d === day;
391 }
392
393 // Easter-based holiday (e.g., Good Friday = Easter - 2)
394 if (rule.k === 'e') {
395 let easter = this.getEaster(year);
396 let target = new Date(easter);
397 target.setDate(easter.getDate() + (rule.o || 0));
398 return target.getMonth() + 1 === month && target.getDate() === day;
399 }
400
401 // Relative date holiday (not yet implemented)
402 if (rule.k === 'r') {
403 // Future enhancement: "first Monday in September" etc.
404 return false;
405 }
406
407 return false;
408 }
409
410 /* ============================================================
411 * CONDITION PARSER
412 * ============================================================
413 *
414 * Parses simple year-based conditions for historical accuracy.
415 *
416 * SUPPORTED FORMATS:
417 * - "year<=1994" (holiday existed until 1994)
418 * - "year>=2000" (holiday started in 2000)
419 * - "year==1990" (holiday only in 1990)
420 *
421 * WHY NEED CONDITIONS?
422 * - Holidays change over time (political/religious)
423 * - German reunification (1990) changed many holidays
424 * - Some holidays were added/removed by law
425 *
426 * @param cond - Condition string from JSON
427 * @param year - Year to check
428 * @returns true if condition is satisfied
429 */
430
431 private checkCondition(cond: string, year: number): boolean {
432 const match = cond.match(/year([<>=!]+)(\d+)/);
433 if (!match || !match[1] || !match[2]) return true;
434
435 const operator = match[1];
436 const val = parseInt(match[2]);
437
438 switch (operator) {
439 case "<=": return year <= val;
440 case ">=": return year >= val;
441 case "==": return year === val;
442 case "<": return year < val;
443 case ">": return year > val;
444 default: return true;
445 }
446 }
447
448 /* ============================================================
449 * EASTER CALCULATION ALGORITHM
450 * ============================================================
451 *
452 * Calculates Easter Sunday date using the Meeus/Jones/Butcher algorithm.
453 * This is a refinement of Gauss's original algorithm.
454 *
455 * WHY IS EASTER COMPLICATED?
456 * - Based on lunar calendar (first Sunday after first full moon after vernal equinox)
457 * - Different churches use different calculations
458 * - This algorithm works for Gregorian calendar years 1583-4099
459 *
460 * ALGORITHM SOURCE:
461 * Jean Meeus, "Astronomical Algorithms", 2nd edition, 1998
462 *
463 * @param year - Gregorian calendar year
464 * @returns Date object for Easter Sunday
465 */
466
467 private getEaster(year: number): Date {
468 // Step 1: Golden number - position in 19-year Metonic cycle
469 let a = year % 19;
470
471 // Step 2: Century number
472 let b = Math.floor(year / 100);
473
474 // Step 3: Years within century
475 let c = year % 100;
476
477 // Step 4: Leap year corrections
478 let d = Math.floor(b / 4);
479 let e = b % 4;
480
481 // Step 5: Correction for 30-year cycle
482 let f = Math.floor((b + 8) / 25);
483
484 // Step 6: Moon orbit correction
485 let g = Math.floor((b - f + 1) / 3);
486
487 // Step 7: Epact (age of moon on Jan 1)
488 let h = (19 * a + b - d - g + 15) % 30;
489
490 // Step 8: Weekday corrections
491 let i = Math.floor(c / 4);
492 let k = c % 4;
493
494 // Step 9: Correction for 7-day week
495 let l = (32 + 2 * e + 2 * i - h - k) % 7;
496
497 // Step 10: Final calculation for full moon
498 let m = Math.floor((a + 11 * h + 22 * l) / 451);
499
500 // Step 11: Month and day calculation
501 let n = h + l - 7 * m + 114;
502 let month = Math.floor(n / 31);
503 let day = (n % 31) + 1;
504
505 // Return Easter Sunday date (month is 1-indexed in algorithm)
506 return new Date(year, month - 1, day);
507 }
508}
509
510/* ================================================================
511 * HYBRID EXPORT SYSTEM
512 * ================================================================
513 *
514 * CRITICAL DUAL EXPORT PATTERN:
515 *
516 * Cinnamon applets require BOTH export styles:
517 *
518 * 1. CommonJS/ES6 Export (Development):
519 * - Used by TypeScript compiler
520 * - Used by module bundlers
521 * - Enables IDE auto-completion
522 *
523 * 2. Global Export (Production):
524 * - Cinnamon uses requireModule() which expects global assignment
525 * - No module system at runtime in GJS
526 * - Other files access via global.CalendarLogic
527 *
528 * WARNING: Removing either export will break the applet!
529 */
530
531/* ----------------------------------------------------------------
532 * CommonJS/ES6 Export (Development & TypeScript)
533 * ----------------------------------------------------------------
534 */
535if (typeof exports !== 'undefined') {
536 exports.CalendarLogic = CalendarLogic;
537}
538
539/* ----------------------------------------------------------------
540 * Global Export (Cinnamon Runtime)
541 * ----------------------------------------------------------------
542 */
543(global as any).CalendarLogic = CalendarLogic;
544
545/* ================================================================
546 * TODOs AND FUTURE ENHANCEMENTS
547 * ================================================================
548 *
549 * TODO: Add support for relative date holidays (k='r'):
550 * - "first Monday in September" (Labor Day in US)
551 * - "last Monday in May" (Memorial Day in US)
552 *
553 * TODO: Add lunar calendar calculations for:
554 * - Islamic holidays (Hijri calendar)
555 * - Hebrew holidays (Jewish calendar)
556 * - Chinese holidays (Lunisolar calendar)
557 *
558 * TODO: Implement holiday caching by year for performance:
559 * - Pre-calculate all holidays for current year
560 * - Cache results for frequently accessed dates
561 *
562 * TODO: Add support for user-defined holiday regions:
563 * - Allow users to select region in settings
564 * - Support custom holiday JSON files
565 *
566 * TODO: Extend condition system with logical operators:
567 * - Support AND/OR in condition strings
568 * - Add support for day-of-week conditions
569 */