timely-calendar 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 6828a89cb80defec08b9eadaa1e03cf98e45e90d01efe6770423be86f14d8692
4
+ data.tar.gz: c0857481b8a83267cd5289111e333a07d62c5cebf59a7d509e4a0ddceac80346
5
+ SHA512:
6
+ metadata.gz: c62100cb41044387038866e90e8061061be47c1c3e79c77771d60880133ea63312f325cc66084dddb905594f6951535b9cef98383e10386882836a63ff68a76e
7
+ data.tar.gz: ce4f9e135b9c64afe3c6a195a8da0f2f3dd688a523051e31dd42af0764181f17710504d805ead890271b44c9660227cab6fe1986c98535fabcf0d4d879fcfc40
data/.gitignore ADDED
@@ -0,0 +1,39 @@
1
+ # Timely data
2
+ ~/.timely/
3
+ *.db
4
+ *.db-shm
5
+ *.db-wal
6
+
7
+ # Secrets and credentials
8
+ .env
9
+ .env.*
10
+ *.pem
11
+ *.key
12
+ credentials.json
13
+ token.json
14
+ *_secret*
15
+ *_token*
16
+
17
+ # Ruby
18
+ *.gem
19
+ *.rbc
20
+ /pkg/
21
+ /tmp/
22
+
23
+ # Editor
24
+ *.swp
25
+ *.swo
26
+ *~
27
+ .vscode/
28
+ .idea/
29
+
30
+ # OS
31
+ .DS_Store
32
+ Thumbs.db
33
+
34
+ # Claude
35
+ CLAUDE.md
36
+ .claude/
37
+
38
+ # Logs
39
+ *.log
data/CLAUDE.md ADDED
@@ -0,0 +1,95 @@
1
+ # CLAUDE.md - Development Notes for Timely
2
+
3
+ ## Project Vision
4
+
5
+ **Timely** - Terminal calendar companion to Heathrow.
6
+
7
+ A TUI calendar application built on rcurses, following the same patterns as Heathrow. View, navigate, and manage calendars with month, week, day, and year views.
8
+
9
+ ## Architecture
10
+
11
+ Built on rcurses (Ruby TUI library). Same pane layout as Heathrow and RTFM.
12
+
13
+ ### File Structure
14
+ ```
15
+ timely/
16
+ bin/timely # Entry point
17
+ lib/timely.rb # Module loader
18
+ lib/timely/
19
+ version.rb # Version constant
20
+ database.rb # SQLite database (events, calendars, settings)
21
+ config.rb # YAML config manager (~/.timely/config.yml)
22
+ event.rb # Event model
23
+ astronomy.rb # Moon phase calculations
24
+ ui/
25
+ panes.rb # Pane layout (top, left, right, bottom)
26
+ application.rb # Main app class, input loop, rendering
27
+ views/
28
+ month.rb # Month calendar grid
29
+ year.rb # 12 mini-months
30
+ week.rb # 7-column hourly grid
31
+ day.rb # 30-min time slots
32
+ sources/ # Calendar source plugins (future)
33
+ sync/ # Sync engines (future)
34
+ docs/ # Documentation
35
+ ```
36
+
37
+ ### Key Bindings
38
+ - h/l or LEFT/RIGHT: navigate days/months
39
+ - j/k or DOWN/UP: navigate weeks/hours
40
+ - 1-6: switch views (year, quarter, month, week, workweek, day)
41
+ - T: go to today
42
+ - g: go to specific date
43
+ - n: new event (placeholder)
44
+ - w: cycle pane width
45
+ - B: toggle border
46
+ - q: quit
47
+
48
+ ## Critical rcurses Notes
49
+
50
+ ### Key Input
51
+ rcurses returns special keys as STRINGS:
52
+ - "UP", "DOWN", "LEFT", "RIGHT"
53
+ - "ENTER", "ESC", "SPACE"
54
+ - "PgUP", "PgDOWN", "HOME", "END"
55
+ - "BACK" (backspace)
56
+
57
+ ### Pane API
58
+ - `Rcurses::Pane.new(x, y, w, h, fg, bg)` - create pane
59
+ - `pane.text = string` then `pane.refresh` - set and display content
60
+ - `pane.ask(prompt, default)` - get user input
61
+ - `pane.border = true/false`
62
+ - `pane.ix = 0` - scroll position
63
+
64
+ ### String Extensions
65
+ - `"text".fg(color)` - foreground color (256-color int)
66
+ - `"text".bg(color)` - background color
67
+ - `"text".b` - bold
68
+ - `"text".u` - underline
69
+ - `"text".r` - reverse video
70
+
71
+ ### Initialization
72
+ - Call `Rcurses.init!` before anything
73
+ - Terminal size: `IO.console.winsize` returns `[height, width]`
74
+ - Flush stdin: `$stdin.getc while $stdin.wait_readable(0)`
75
+
76
+ ### Rules
77
+ - No raw ANSI codes; use rcurses methods only
78
+ - Use `require 'rcurses'` (the gem)
79
+ - All dates use Ruby's Date class
80
+ - All times stored as Unix timestamps in DB
81
+
82
+ ## Current Status
83
+
84
+ **Phase 0:** Foundation (complete)
85
+ - Core architecture, database, config
86
+ - Month, week, day, year views
87
+ - Moon phase display
88
+ - Navigation and view switching
89
+
90
+ **Future Phases:**
91
+ 1. CalDAV sync (Google Calendar, iCloud, etc.)
92
+ 2. Event creation and editing
93
+ 3. Recurring events
94
+ 4. Weather integration
95
+ 5. Notifications and reminders
data/README.md ADDED
@@ -0,0 +1,188 @@
1
+ # Timely
2
+
3
+ <img src="img/timely.svg" align="right" width="150">
4
+
5
+ **Your terminal calendar. Where time meets the stars.**
6
+
7
+ ![Ruby](https://img.shields.io/badge/language-Ruby-red) [![Gem Version](https://badge.fury.io/rb/timely-calendar.svg)](https://badge.fury.io/rb/timely-calendar) ![Unlicense](https://img.shields.io/badge/license-Unlicense-green) [![Heathrow](https://img.shields.io/badge/companion-Heathrow-blue)](https://github.com/isene/heathrow) ![Stay Amazing](https://img.shields.io/badge/Stay-Amazing-important)
8
+
9
+ A unified TUI calendar that brings Google Calendar, Outlook/365, and local events into one terminal interface. Built on [rcurses](https://github.com/isene/rcurses), companion to [Heathrow](https://github.com/isene/heathrow).
10
+
11
+ ## Why Timely?
12
+
13
+ Google Calendar in a browser tab. Outlook in another. Work meetings in Teams. Personal events on your phone. Sound familiar?
14
+
15
+ Timely puts everything in one terminal view with moon phases, planet visibility, weather, and full keyboard control.
16
+
17
+ ## Features
18
+
19
+ **Calendar Sources:**
20
+ - Google Calendar (OAuth2, full read/write/RSVP)
21
+ - Microsoft Outlook/365 (device code auth, Graph API)
22
+ - ICS file import with RRULE recurring event expansion
23
+ - Local events with create/edit/delete
24
+
25
+ **Three-Pane Layout:**
26
+ - **Top:** Horizontal month strip with week numbers and event markers
27
+ - **Middle:** Selected week with half-hour time slots and all-day events
28
+ - **Bottom:** Event details with organizer, attendees, description
29
+
30
+ **Astronomy:**
31
+ - Moon phases with emoji symbols and illumination percentage
32
+ - Visible planet indicators (Mercury through Saturn) via [ruby-ephemeris](https://github.com/isene/ephemeris)
33
+ - Sunrise/sunset times
34
+ - Solstices, equinoxes, meteor shower peak dates
35
+
36
+ **Weather:**
37
+ - 7-day forecast from met.no (free, no API key)
38
+ - Temperature and conditions shown above each day column
39
+
40
+ **Integration:**
41
+ - Bidirectional with [Heathrow](https://github.com/isene/heathrow) (Z key sends calendar invite to Timely, r replies via Heathrow)
42
+ - ICS auto-import from `~/.timely/incoming/`
43
+ - Desktop notifications via notify-send (libnotify)
44
+ - Cross-source deduplication (no duplicates from ICS + Google)
45
+
46
+ **UI:**
47
+ - Visual 256-color picker for all colors
48
+ - Calendar manager (toggle, recolor, remove calendars)
49
+ - Preferences popup for colors, work hours, defaults
50
+ - Scrollable event detail popup
51
+ - Weekend colors (Saturday orange, Sunday red)
52
+
53
+ ## Installation
54
+
55
+ ```bash
56
+ gem install timely-calendar
57
+ ```
58
+
59
+ ### Requirements
60
+
61
+ - Ruby >= 2.7
62
+ - [rcurses](https://github.com/isene/rcurses) gem (>= 5.0)
63
+ - sqlite3 gem (>= 1.4)
64
+ - Optional: [ruby-ephemeris](https://github.com/isene/ephemeris) for planet data
65
+ - Optional: notify-send for desktop notifications
66
+
67
+ ## Quick Start
68
+
69
+ ```bash
70
+ timely
71
+ ```
72
+
73
+ ### Connect Google Calendar
74
+
75
+ 1. Press `G`, enter your Google email
76
+ 2. Credentials: place your OAuth2 JSON + refresh token in the configured `safe_dir`
77
+ (default: `~/.config/timely/credentials/`)
78
+ 3. Requires both `https://mail.google.com/` and `https://www.googleapis.com/auth/calendar` scopes
79
+
80
+ ### Connect Outlook/365
81
+
82
+ 1. Register an app in [Azure Portal](https://portal.azure.com/) > App Registrations
83
+ 2. Add `Calendars.ReadWrite` and `offline_access` (delegated) permissions
84
+ 3. Enable "Allow public client flows" in Authentication
85
+ 4. Press `O` in Timely, enter the client ID and tenant ID
86
+ 5. Follow the device code flow (visit URL, enter code)
87
+
88
+ ## Key Bindings
89
+
90
+ ### Navigation
91
+ | Key | Action |
92
+ |-----|--------|
93
+ | `d` / `RIGHT` | Next day |
94
+ | `D` / `LEFT` | Previous day |
95
+ | `w` / `W` | Next / previous week |
96
+ | `m` / `M` | Next / previous month |
97
+ | `y` / `Y` | Next / previous year |
98
+ | `UP` / `DOWN` | Select time slot (scrolls) |
99
+ | `PgUp` / `PgDn` | Jump 10 time slots |
100
+ | `HOME` | Top (all-day area) |
101
+ | `END` | Bottom (23:30) |
102
+ | `e` / `E` | Jump to next / previous event |
103
+ | `t` | Go to today |
104
+ | `g` | Go to date (yyyy-mm-dd, Mar, 21, 2026) |
105
+
106
+ ### Events
107
+ | Key | Action |
108
+ |-----|--------|
109
+ | `n` | New event |
110
+ | `ENTER` | Edit event |
111
+ | `x` / `DEL` | Delete event |
112
+ | `v` | View event details (scrollable popup) |
113
+ | `a` | Accept invite (pushes RSVP to Google/Outlook) |
114
+ | `Ctrl-Y` | Copy event to clipboard |
115
+ | `r` | Reply via Heathrow |
116
+
117
+ ### Sources & Settings
118
+ | Key | Action |
119
+ |-----|--------|
120
+ | `i` | Import ICS file |
121
+ | `G` | Setup Google Calendar |
122
+ | `O` | Setup Outlook/365 |
123
+ | `S` | Sync now (background) |
124
+ | `C` | Calendar manager |
125
+ | `P` | Preferences |
126
+ | `?` | Help |
127
+ | `q` | Quit |
128
+
129
+ ## Configuration
130
+
131
+ Config file: `~/.timely/config.yml`
132
+
133
+ ```yaml
134
+ location:
135
+ lat: 59.9139
136
+ lon: 10.7522
137
+ timezone_offset: 1
138
+ work_hours:
139
+ start: 8
140
+ end: 17
141
+ week_starts_on: monday
142
+ default_view: month
143
+ default_calendar: 1
144
+
145
+ google:
146
+ safe_dir: ~/.config/timely/credentials
147
+ sync_interval: 300
148
+
149
+ outlook:
150
+ client_id: ''
151
+ tenant_id: common
152
+
153
+ notifications:
154
+ enabled: true
155
+ default_alarm: 15
156
+
157
+ colors:
158
+ selected_bg_a: 235
159
+ selected_bg_b: 234
160
+ alt_bg_a: 233
161
+ alt_bg_b: 0
162
+ current_month_bg: 233
163
+ saturday: 208
164
+ sunday: 167
165
+ today_fg: 232
166
+ today_bg: 246
167
+ slot_selected_bg: 237
168
+ info_bg: 235
169
+ status_bg: 235
170
+ ```
171
+
172
+ ## Heathrow Integration
173
+
174
+ Timely is the calendar companion to [Heathrow](https://github.com/isene/heathrow), the unified messaging TUI.
175
+
176
+ **From Heathrow:** Press `Z` on a calendar invite email to open it in Timely. The ICS data is auto-imported and Timely jumps to the event date.
177
+
178
+ **From Timely:** Press `r` on an event to compose a reply via Heathrow.
179
+
180
+ **Auto-import:** Drop `.ics` files in `~/.timely/incoming/` and they're imported on next Timely startup.
181
+
182
+ ## License
183
+
184
+ Released into the Public Domain ([Unlicense](LICENSE)).
185
+
186
+ ## Author
187
+
188
+ [Geir Isene](https://isene.com) with [Claude Code](https://claude.ai/claude-code)
data/bin/timely ADDED
@@ -0,0 +1,41 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ # Timely - Terminal Calendar
5
+ # A TUI calendar application built on rcurses
6
+ # Author: Geir Isene with Claude Code
7
+ # License: Public Domain
8
+
9
+ CRASH_LOG = '/tmp/timely-crash.log'
10
+
11
+ require_relative '../lib/timely'
12
+
13
+ begin
14
+ app = Timely::Application.new
15
+ app.run
16
+ rescue Interrupt
17
+ # Restore terminal
18
+ system('stty sane 2>/dev/null')
19
+ print "\e[?25h\e[0m\e[2J\e[H"
20
+ $stdout.flush
21
+ exit 0
22
+ rescue => e
23
+ # Log full crash details
24
+ crash = "#{Time.now.strftime('%Y-%m-%d %H:%M:%S')} CRASH in #{e.backtrace&.first}\n"
25
+ crash << " #{e.class}: #{e.message}\n"
26
+ crash << " Backtrace:\n"
27
+ e.backtrace&.first(20)&.each { |l| crash << " #{l}\n" }
28
+ crash << "\n"
29
+ File.write(CRASH_LOG, crash, mode: 'a')
30
+
31
+ # Restore terminal
32
+ system('stty sane 2>/dev/null')
33
+ print "\e[?25h\e[0m\e[2J\e[H"
34
+ $stdout.flush
35
+
36
+ loc = e.backtrace&.first&.sub(/^.*lib\/timely\//, '')
37
+ $stderr.puts "Timely crashed: #{e.class}: #{e.message}"
38
+ $stderr.puts " at #{loc}"
39
+ $stderr.puts " Full log: #{CRASH_LOG}"
40
+ exit 1
41
+ end
data/img/timely.svg ADDED
@@ -0,0 +1,111 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" width="256" height="256">
2
+ <defs>
3
+ <linearGradient id="bg" x1="0%" y1="0%" x2="100%" y2="100%">
4
+ <stop offset="0%" style="stop-color:#0a0a1a"/>
5
+ <stop offset="100%" style="stop-color:#1a1a3e"/>
6
+ </linearGradient>
7
+ <linearGradient id="face-glow" x1="0%" y1="0%" x2="0%" y2="100%">
8
+ <stop offset="0%" style="stop-color:#3a7bd5"/>
9
+ <stop offset="100%" style="stop-color:#1a4a8a"/>
10
+ </linearGradient>
11
+ <linearGradient id="hand-glow" x1="0%" y1="0%" x2="100%" y2="100%">
12
+ <stop offset="0%" style="stop-color:#e8d9a0"/>
13
+ <stop offset="100%" style="stop-color:#c08040"/>
14
+ </linearGradient>
15
+ <radialGradient id="moon-glow" cx="50%" cy="50%" r="50%">
16
+ <stop offset="0%" style="stop-color:#ffffcc;stop-opacity:0.3"/>
17
+ <stop offset="100%" style="stop-color:#ffffcc;stop-opacity:0"/>
18
+ </radialGradient>
19
+ </defs>
20
+
21
+ <style>
22
+ @keyframes tick { 0% { transform: rotate(0deg); } 100% { transform: rotate(360deg); } }
23
+ @keyframes pulse { 0%,100% { opacity: 0.4; } 50% { opacity: 0.9; } }
24
+ @keyframes moon-orbit { 0% { transform: rotate(0deg); } 100% { transform: rotate(360deg); } }
25
+ @keyframes twinkle1 { 0%,100% { opacity: 0.2; } 50% { opacity: 1; } }
26
+ @keyframes twinkle2 { 0%,100% { opacity: 0.8; } 50% { opacity: 0.1; } }
27
+ @keyframes twinkle3 { 0%,100% { opacity: 0.5; } 50% { opacity: 1; } }
28
+ .minute-hand { animation: tick 60s linear infinite; transform-origin: 128px 128px; }
29
+ .hour-hand { animation: tick 720s linear infinite; transform-origin: 128px 128px; }
30
+ .outer-glow { animation: pulse 4s ease-in-out infinite; }
31
+ .moon-orbit { animation: moon-orbit 30s linear infinite; transform-origin: 128px 128px; }
32
+ .star1 { animation: twinkle1 3s ease-in-out infinite; }
33
+ .star2 { animation: twinkle2 4s ease-in-out infinite; }
34
+ .star3 { animation: twinkle3 2.5s ease-in-out infinite; }
35
+ .star4 { animation: twinkle1 3.5s ease-in-out 1s infinite; }
36
+ .star5 { animation: twinkle2 2s ease-in-out 0.5s infinite; }
37
+ </style>
38
+
39
+ <!-- Background -->
40
+ <rect width="256" height="256" rx="32" fill="url(#bg)"/>
41
+
42
+ <!-- Stars -->
43
+ <circle cx="30" cy="35" r="1.5" fill="#fff" class="star1"/>
44
+ <circle cx="220" cy="25" r="1" fill="#fff" class="star2"/>
45
+ <circle cx="45" cy="210" r="1.2" fill="#fff" class="star3"/>
46
+ <circle cx="200" cy="220" r="1" fill="#fff" class="star4"/>
47
+ <circle cx="180" cy="45" r="1.3" fill="#fff" class="star5"/>
48
+ <circle cx="60" cy="70" r="0.8" fill="#fff" class="star2"/>
49
+ <circle cx="210" cy="180" r="0.8" fill="#fff" class="star1"/>
50
+ <circle cx="35" cy="150" r="1" fill="#fff" class="star3"/>
51
+
52
+ <!-- Outer glow ring -->
53
+ <circle cx="128" cy="128" r="95" fill="none" stroke="#3a7bd5" stroke-width="1" opacity="0.3" class="outer-glow"/>
54
+ <circle cx="128" cy="128" r="100" fill="none" stroke="#3a7bd5" stroke-width="0.5" opacity="0.15" class="outer-glow"/>
55
+
56
+ <!-- Clock face -->
57
+ <circle cx="128" cy="128" r="85" fill="url(#face-glow)" opacity="0.15"/>
58
+ <circle cx="128" cy="128" r="85" fill="none" stroke="#4a8ad5" stroke-width="2" opacity="0.7"/>
59
+
60
+ <!-- Hour markers -->
61
+ <g opacity="0.6">
62
+ <line x1="128" y1="48" x2="128" y2="56" stroke="#e8d9a0" stroke-width="2"/>
63
+ <line x1="128" y1="200" x2="128" y2="208" stroke="#e8d9a0" stroke-width="2"/>
64
+ <line x1="48" y1="128" x2="56" y2="128" stroke="#e8d9a0" stroke-width="2"/>
65
+ <line x1="200" y1="128" x2="208" y2="128" stroke="#e8d9a0" stroke-width="2"/>
66
+ <!-- Diagonal markers -->
67
+ <line x1="71.4" y1="71.4" x2="77" y2="77" stroke="#e8d9a0" stroke-width="1.5"/>
68
+ <line x1="184.6" y1="71.4" x2="179" y2="77" stroke="#e8d9a0" stroke-width="1.5"/>
69
+ <line x1="71.4" y1="184.6" x2="77" y2="179" stroke="#e8d9a0" stroke-width="1.5"/>
70
+ <line x1="184.6" y1="184.6" x2="179" y2="179" stroke="#e8d9a0" stroke-width="1.5"/>
71
+ </g>
72
+
73
+ <!-- Calendar grid (small, bottom-right of clock face) -->
74
+ <g transform="translate(145, 145)" opacity="0.5">
75
+ <rect x="0" y="0" width="28" height="24" rx="3" fill="none" stroke="#e8d9a0" stroke-width="0.8"/>
76
+ <line x1="0" y1="6" x2="28" y2="6" stroke="#e8d9a0" stroke-width="0.5"/>
77
+ <line x1="7" y1="6" x2="7" y2="24" stroke="#e8d9a0" stroke-width="0.3"/>
78
+ <line x1="14" y1="6" x2="14" y2="24" stroke="#e8d9a0" stroke-width="0.3"/>
79
+ <line x1="21" y1="6" x2="21" y2="24" stroke="#e8d9a0" stroke-width="0.3"/>
80
+ <line x1="0" y1="12" x2="28" y2="12" stroke="#e8d9a0" stroke-width="0.3"/>
81
+ <line x1="0" y1="18" x2="28" y2="18" stroke="#e8d9a0" stroke-width="0.3"/>
82
+ <!-- "Today" highlight -->
83
+ <rect x="14.5" y="12.5" width="6" height="5" rx="1" fill="#e8d9a0" opacity="0.4"/>
84
+ </g>
85
+
86
+ <!-- Moon in orbit -->
87
+ <g class="moon-orbit">
88
+ <circle cx="128" cy="30" r="10" fill="#e8d9a0" opacity="0.8"/>
89
+ <circle cx="123" cy="28" r="8" fill="url(#bg)"/>
90
+ <circle cx="128" cy="30" r="14" fill="url(#moon-glow)"/>
91
+ </g>
92
+
93
+ <!-- Hour hand -->
94
+ <g class="hour-hand">
95
+ <line x1="128" y1="128" x2="128" y2="78" stroke="url(#hand-glow)" stroke-width="4" stroke-linecap="round"/>
96
+ </g>
97
+
98
+ <!-- Minute hand -->
99
+ <g class="minute-hand">
100
+ <line x1="128" y1="128" x2="128" y2="58" stroke="#e8d9a0" stroke-width="2" stroke-linecap="round"/>
101
+ </g>
102
+
103
+ <!-- Center dot -->
104
+ <circle cx="128" cy="128" r="4" fill="#e8d9a0"/>
105
+ <circle cx="128" cy="128" r="2" fill="#fff"/>
106
+
107
+ <!-- Planet dots along the outer ring -->
108
+ <circle cx="128" cy="22" r="3" fill="#BC2732" opacity="0.7" class="star1"/> <!-- Mars -->
109
+ <circle cx="220" cy="100" r="3.5" fill="#C08040" opacity="0.7" class="star3"/> <!-- Jupiter -->
110
+ <circle cx="50" cy="170" r="2.5" fill="#E6B07C" opacity="0.7" class="star2"/> <!-- Venus -->
111
+ </svg>
@@ -0,0 +1,180 @@
1
+ module Timely
2
+ module Astronomy
3
+ PHASE_NAMES = [
4
+ 'New Moon', 'Waxing Crescent', 'First Quarter', 'Waxing Gibbous',
5
+ 'Full Moon', 'Waning Gibbous', 'Last Quarter', 'Waning Crescent'
6
+ ].freeze
7
+
8
+ PHASE_SYMBOLS = [
9
+ "\u{1F311}", "\u{1F312}", "\u{1F313}", "\u{1F314}",
10
+ "\u{1F315}", "\u{1F316}", "\u{1F317}", "\u{1F318}"
11
+ ].freeze
12
+
13
+ # Calculate moon phase for a given Date.
14
+ # Returns a hash with :illumination (0.0-1.0), :phase_name, :symbol, :phase_index
15
+ def self.moon_phase(date)
16
+ # Julian date calculation
17
+ y = date.year
18
+ m = date.month
19
+ d = date.day
20
+
21
+ jd = 367 * y - (7 * (y + ((m + 9) / 12))) / 4 + (275 * m) / 9 + d + 1721013.5
22
+
23
+ # Days since known new moon (Jan 6, 2000 18:14 UTC)
24
+ # Reference epoch: JD 2451550.1 (known new moon)
25
+ days_since = jd - 2451550.1
26
+ synodic_month = 29.530588853
27
+
28
+ # Normalize to 0.0 - 1.0 within synodic month
29
+ phase = (days_since / synodic_month) % 1.0
30
+ phase = phase + 1.0 if phase < 0
31
+
32
+ # Map phase position to illumination (0 = new, 0.5 = full)
33
+ # Illumination follows a cosine curve
34
+ illumination = (1.0 - Math.cos(phase * 2 * Math::PI)) / 2.0
35
+
36
+ # Determine phase index (0-7)
37
+ phase_index = (phase * 8).floor % 8
38
+
39
+ {
40
+ illumination: illumination.round(4),
41
+ phase: phase.round(4),
42
+ phase_name: PHASE_NAMES[phase_index],
43
+ symbol: PHASE_SYMBOLS[phase_index],
44
+ phase_index: phase_index
45
+ }
46
+ end
47
+
48
+ # Return a short symbol for display in calendar cells
49
+ def self.moon_symbol(date)
50
+ moon_phase(date)[:symbol]
51
+ end
52
+
53
+ # Check if this is a notable phase (new, first quarter, full, last quarter).
54
+ # Only returns true on the day closest to the exact phase transition.
55
+ def self.notable_phase?(date)
56
+ today_phase = moon_phase(date)
57
+ index = today_phase[:phase_index]
58
+ return false unless [0, 2, 4, 6].include?(index)
59
+
60
+ # Check if yesterday had a different phase index
61
+ yesterday = moon_phase(date - 1)
62
+ yesterday[:phase_index] != index
63
+ end
64
+
65
+ # Find all notable moon phase dates within a month.
66
+ # Returns array of { date:, phase_name:, symbol: }
67
+ def self.notable_phases_in_month(year, month)
68
+ last_day = Date.new(year, month, -1).day
69
+ result = []
70
+ (1..last_day).each do |d|
71
+ date = Date.new(year, month, d)
72
+ if notable_phase?(date)
73
+ p = moon_phase(date)
74
+ result << { date: date, day: d, phase_name: p[:phase_name], symbol: p[:symbol] }
75
+ end
76
+ end
77
+ result
78
+ end
79
+
80
+ # Sunrise/sunset for a given date and location.
81
+ # Returns { rise: "HH:MM", set: "HH:MM" }
82
+ def self.sun_times(date, lat = 59.9139, lon = 10.7522, tz = 1)
83
+ load_ephemeris
84
+ return nil unless defined?(Ephemeris)
85
+ eph = Ephemeris.new(date.strftime('%Y-%m-%d'), lat, lon, tz)
86
+ rise, _, sett = eph.rts(eph.sun[0], eph.sun[1])
87
+ { rise: rise.is_a?(String) ? rise[0..4] : rise.to_s,
88
+ set: sett.is_a?(String) ? sett[0..4] : sett.to_s }
89
+ rescue => e
90
+ nil
91
+ end
92
+
93
+ def self.load_ephemeris
94
+ return if defined?(Ephemeris)
95
+ begin
96
+ require 'ephemeris'
97
+ rescue LoadError
98
+ path = File.expand_path('~/Main/G/GIT-isene/ephemeris/lib/ephemeris.rb')
99
+ require path if File.exist?(path)
100
+ end
101
+ end
102
+
103
+ PLANET_SYMBOLS = {
104
+ 'mercury' => "\u263F", 'venus' => "\u2640", 'mars' => "\u2642",
105
+ 'jupiter' => "\u2643", 'saturn' => "\u2644"
106
+ }.freeze
107
+
108
+ # RGB colors matching astropanel
109
+ BODY_COLORS = {
110
+ 'sun' => 'FFD700',
111
+ 'moon' => '888888',
112
+ 'mercury' => '8F6E54',
113
+ 'venus' => 'E6B07C',
114
+ 'mars' => 'BC2732',
115
+ 'jupiter' => 'C08040',
116
+ 'saturn' => 'E8D9A0'
117
+ }.freeze
118
+
119
+ # Returns array of planet names visible at night for the given date/location.
120
+ # A planet is "visible" if altitude > 5 degrees at any hour between 20:00-04:00.
121
+ def self.visible_planets(date, lat = 59.9139, lon = 10.7522, tz = 1)
122
+ load_ephemeris
123
+ return [] unless defined?(Ephemeris)
124
+
125
+ date_str = date.strftime('%Y-%m-%d')
126
+ eph = Ephemeris.new(date_str, lat, lon, tz)
127
+ visible = []
128
+
129
+ %w[mercury venus mars jupiter saturn].each do |planet|
130
+ # Check altitude at evening/night hours
131
+ [20.0, 21.0, 22.0, 23.0, 0.0, 1.0, 2.0, 3.0, 4.0].any? do |h|
132
+ alt, _ = eph.body_alt_az(planet, h)
133
+ if alt > 5
134
+ body = eph.send(planet)
135
+ visible << {
136
+ name: planet.capitalize,
137
+ symbol: PLANET_SYMBOLS[planet],
138
+ rise: body[5].is_a?(String) ? body[5][0..4] : body[5].to_s,
139
+ set: body[7].is_a?(String) ? body[7][0..4] : body[7].to_s
140
+ }
141
+ true
142
+ end
143
+ end
144
+ end
145
+ visible
146
+ rescue => e
147
+ []
148
+ end
149
+
150
+ # Notable astronomical events for a date (simple rule-based).
151
+ # Returns array of event description strings.
152
+ def self.astro_events(date, lat = 59.9139, lon = 10.7522, tz = 1)
153
+ events = []
154
+
155
+ # Check for notable moon phase
156
+ phase = moon_phase(date)
157
+ if notable_phase?(date)
158
+ events << "#{phase[:symbol]} #{phase[:phase_name]}"
159
+ end
160
+
161
+ # Check for solstices and equinoxes
162
+ m, d = date.month, date.day
163
+ events << "\u2600 Summer Solstice" if m == 6 && d == 21
164
+ events << "\u2744 Winter Solstice" if m == 12 && d == 21
165
+ events << "\u2600 Vernal Equinox" if m == 3 && d == 20
166
+ events << "\u2600 Autumnal Equinox" if m == 9 && d == 22
167
+
168
+ # Major meteor showers (peak dates)
169
+ events << "\u2604 Quadrantids peak" if m == 1 && d == 3
170
+ events << "\u2604 Lyrids peak" if m == 4 && d == 22
171
+ events << "\u2604 Eta Aquariids peak" if m == 5 && d == 6
172
+ events << "\u2604 Perseids peak" if m == 8 && d == 12
173
+ events << "\u2604 Orionids peak" if m == 10 && d == 21
174
+ events << "\u2604 Leonids peak" if m == 11 && d == 17
175
+ events << "\u2604 Geminids peak" if m == 12 && d == 14
176
+
177
+ events
178
+ end
179
+ end
180
+ end