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 +7 -0
- data/.gitignore +39 -0
- data/CLAUDE.md +95 -0
- data/README.md +188 -0
- data/bin/timely +41 -0
- data/img/timely.svg +111 -0
- data/lib/timely/astronomy.rb +180 -0
- data/lib/timely/config.rb +93 -0
- data/lib/timely/database.rb +378 -0
- data/lib/timely/event.rb +76 -0
- data/lib/timely/notifications.rb +83 -0
- data/lib/timely/sources/google.rb +276 -0
- data/lib/timely/sources/ics_file.rb +247 -0
- data/lib/timely/sources/outlook.rb +286 -0
- data/lib/timely/sync/poller.rb +119 -0
- data/lib/timely/ui/application.rb +1960 -0
- data/lib/timely/ui/panes.rb +46 -0
- data/lib/timely/ui/views/month.rb +118 -0
- data/lib/timely/version.rb +3 -0
- data/lib/timely/weather.rb +111 -0
- data/lib/timely.rb +45 -0
- data/timely.gemspec +33 -0
- metadata +98 -0
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
|
+
 [](https://badge.fury.io/rb/timely-calendar)  [](https://github.com/isene/heathrow) 
|
|
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
|