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.
@@ -0,0 +1,46 @@
1
+ module Timely
2
+ module UI
3
+ module Panes
4
+ def setup_display
5
+ require 'io/console'
6
+ if IO.console
7
+ @h, @w = IO.console.winsize
8
+ else
9
+ @h = ENV['LINES']&.to_i || 24
10
+ @w = ENV['COLUMNS']&.to_i || 80
11
+ end
12
+ end
13
+
14
+ def create_panes
15
+ @panes = {}
16
+
17
+ # Layout: info(1) + top(months, fixed 9) + mid(week, flexible) + bottom(details) + status(1)
18
+ # Top pane: 1 blank row + 8 month rows = 9
19
+ top_h = 9
20
+ bottom_h = (@h * 0.2).to_i
21
+ bottom_h = [bottom_h, 5].max
22
+ mid_h = @h - 2 - top_h - bottom_h # 2 = info + status
23
+ mid_h = [mid_h, 4].max
24
+
25
+ # Adjust if overflow
26
+ if 2 + top_h + mid_h + bottom_h > @h
27
+ bottom_h = @h - 2 - top_h - mid_h
28
+ bottom_h = [bottom_h, 3].max
29
+ end
30
+
31
+ info_bg = @config ? @config.get('colors.info_bg', 235) : 235
32
+ status_bg = @config ? @config.get('colors.status_bg', 235) : 235
33
+ @panes[:info] = Rcurses::Pane.new(1, 1, @w, 1, 255, info_bg)
34
+ @panes[:top] = Rcurses::Pane.new(1, 2, @w, top_h)
35
+ @panes[:mid] = Rcurses::Pane.new(1, 2 + top_h, @w, mid_h)
36
+ @panes[:bottom] = Rcurses::Pane.new(1, 2 + top_h + mid_h, @w, bottom_h)
37
+ @panes[:status] = Rcurses::Pane.new(1, @h, @w, 1, 252, status_bg)
38
+
39
+ @panes.each_value do |p|
40
+ p.border = false
41
+ p.scroll = false
42
+ end
43
+ end
44
+ end
45
+ end
46
+ end
@@ -0,0 +1,118 @@
1
+ module Timely
2
+ module UI
3
+ module Views
4
+ module Month
5
+ WEEKDAYS = %w[Mo Tu We Th Fr Sa Su].freeze
6
+ MINI_WIDTH = 25 # 3 extra for "WW " week number prefix
7
+
8
+ # Render a compact mini-month calendar.
9
+ # Returns an array of strings (one per line), about 8-9 lines tall.
10
+ # selected_day: day number to highlight with reverse video (or nil)
11
+ # today: Date.today for bold+underline marking
12
+ # events_by_date: hash of Date => [event_hashes] for coloring event days
13
+ # width: available width (typically 22 chars)
14
+ def self.render_mini_month(year, month, selected_day, today, events_by_date, width = MINI_WIDTH, today_bg: 246)
15
+ lines = []
16
+
17
+ # Title: centered month and year
18
+ title = Date.new(year, month, 1).strftime("%B %Y")
19
+ pad = [(width - title.length) / 2, 1].max
20
+ lines << (" " * pad + title).b
21
+
22
+ # Weekday headers (with space for week number column)
23
+ header = " " + WEEKDAYS.each_with_index.map { |d, i|
24
+ s = d.rjust(2)
25
+ case i
26
+ when 5 then s.fg(208) # Saturday
27
+ when 6 then s.fg(167) # Sunday
28
+ else s.fg(245)
29
+ end
30
+ }.join(" ")
31
+ lines << header
32
+
33
+ # Build calendar grid
34
+ first_day = Date.new(year, month, 1)
35
+ last_day = Date.new(year, month, -1)
36
+ start_offset = first_day.cwday - 1
37
+
38
+ week = []
39
+ start_offset.times { week << nil }
40
+
41
+ (1..last_day.day).each do |day|
42
+ week << day
43
+ if week.length == 7
44
+ lines << format_week(week, year, month, today, selected_day, events_by_date, today_bg)
45
+ week = []
46
+ end
47
+ end
48
+
49
+ # Final partial week
50
+ unless week.empty?
51
+ week << nil while week.length < 7
52
+ lines << format_week(week, year, month, today, selected_day, events_by_date, today_bg)
53
+ end
54
+
55
+ # Pad to consistent height (title + header + 6 week rows = 8 lines)
56
+ while lines.length < 8
57
+ lines << " " * width
58
+ end
59
+
60
+ lines
61
+ end
62
+
63
+ private
64
+
65
+ def self.format_week(week, year, month, today, selected_day, events_by_date, today_bg = 236)
66
+ # Find week number from first non-nil day in this row
67
+ first_day = week.compact.first
68
+ wn = first_day ? Date.new(year, month, first_day).cweek.to_s.rjust(2) : " "
69
+
70
+ cells = week.map do |day|
71
+ if day.nil?
72
+ " "
73
+ else
74
+ format_day(day, year, month, today, selected_day, events_by_date, today_bg)
75
+ end
76
+ end
77
+ wn.fg(238) + " " + cells.join(" ")
78
+ end
79
+
80
+ def self.format_day(day, year, month, today, selected_day, events_by_date, today_bg = 236)
81
+ date = Date.new(year, month, day)
82
+ events = events_by_date[date]
83
+
84
+ is_today = (date == today)
85
+ is_selected = (selected_day && day == selected_day && date.month == today.month && date.year == today.year) ||
86
+ (selected_day && day == selected_day)
87
+
88
+ # selected_day is only meaningful when this is the selected month
89
+ # The caller controls which month gets a non-nil selected_day
90
+
91
+ # Base fg color: event > sunday > saturday > default
92
+ base_color = if events && !events.empty?
93
+ events.first['calendar_color'] || 39
94
+ elsif date.sunday?
95
+ 167
96
+ elsif date.saturday?
97
+ 208
98
+ else
99
+ nil
100
+ end
101
+
102
+ d = day.to_s.rjust(2)
103
+ if is_selected && is_today
104
+ base_color ? d.b.u.fg(base_color).bg(today_bg) : d.b.u.bg(today_bg)
105
+ elsif is_selected
106
+ base_color ? d.b.u.fg(base_color) : d.b.u
107
+ elsif is_today
108
+ base_color ? d.fg(base_color).bg(today_bg) : d.bg(today_bg)
109
+ elsif base_color
110
+ d.fg(base_color)
111
+ else
112
+ d
113
+ end
114
+ end
115
+ end
116
+ end
117
+ end
118
+ end
@@ -0,0 +1,3 @@
1
+ module Timely
2
+ VERSION = '1.0.0'
3
+ end
@@ -0,0 +1,111 @@
1
+ require 'net/http'
2
+ require 'json'
3
+ require 'uri'
4
+
5
+ module Timely
6
+ module Weather
7
+ SYMBOLS = {
8
+ 0 => "\u2600", # Clear sky
9
+ 1 => "\u{1F324}", # Mostly clear
10
+ 2 => "\u26C5", # Partly cloudy
11
+ 3 => "\u2601", # Cloudy
12
+ 5 => "\u{1F326}", # Rain showers
13
+ 6 => "\u{1F327}", # Rain
14
+ 8 => "\u{1F328}", # Snow
15
+ 9 => "\u{1F329}", # Thunder
16
+ }.freeze
17
+
18
+ # Fetch weather forecast from met.no
19
+ # Returns hash of date_str => { temp_high:, temp_low:, symbol:, wind:, description: }
20
+ def self.fetch(lat, lon, db = nil)
21
+ # Check cache first (6 hour TTL)
22
+ if db
23
+ cached = db.execute(
24
+ "SELECT data, fetched_at FROM weather_cache WHERE date = 'forecast' LIMIT 1"
25
+ ).first
26
+ if cached && (Time.now.to_i - cached['fetched_at'].to_i) < 21600
27
+ return JSON.parse(cached['data']) rescue {}
28
+ end
29
+ end
30
+
31
+ uri = URI("https://api.met.no/weatherapi/locationforecast/2.0/complete?lat=#{lat}&lon=#{lon}")
32
+ req = Net::HTTP::Get.new(uri)
33
+ req['User-Agent'] = 'timely-calendar/0.1 g@isene.com'
34
+
35
+ res = Net::HTTP.start(uri.hostname, uri.port,
36
+ use_ssl: true,
37
+ read_timeout: 10,
38
+ open_timeout: 5) do |http|
39
+ http.request(req)
40
+ end
41
+
42
+ return {} unless res.is_a?(Net::HTTPSuccess)
43
+
44
+ series = JSON.parse(res.body).dig('properties', 'timeseries') || []
45
+
46
+ # Group by date, find high/low temps and midday conditions
47
+ by_date = {}
48
+ series.each do |ts|
49
+ det = ts.dig('data', 'instant', 'details')
50
+ next unless det
51
+ time = ts['time']
52
+ date = time[0..9]
53
+ hour = time[11..12].to_i
54
+ temp = det['air_temperature'].to_f
55
+
56
+ by_date[date] ||= { temps: [], wind: 0, cloud: 0, midday_temp: nil }
57
+ by_date[date][:temps] << temp
58
+ # Capture midday (12:00) conditions for the symbol
59
+ if hour == 12
60
+ by_date[date][:midday_temp] = temp
61
+ by_date[date][:wind] = det['wind_speed'].to_f.round(1)
62
+ by_date[date][:cloud] = det['cloud_area_fraction'].to_i
63
+ end
64
+ end
65
+
66
+ forecast = {}
67
+ by_date.each do |date, data|
68
+ cloud = data[:cloud]
69
+ symbol = if cloud < 15
70
+ SYMBOLS[0]
71
+ elsif cloud < 40
72
+ SYMBOLS[1]
73
+ elsif cloud < 70
74
+ SYMBOLS[2]
75
+ else
76
+ SYMBOLS[3]
77
+ end
78
+
79
+ temps = data[:temps]
80
+ forecast[date] = {
81
+ 'temp_high' => temps.max.round(1),
82
+ 'temp_low' => temps.min.round(1),
83
+ 'temp_mid' => (data[:midday_temp] || temps[temps.size / 2]).round(1),
84
+ 'symbol' => symbol,
85
+ 'wind' => data[:wind],
86
+ 'cloud' => cloud
87
+ }
88
+ end
89
+
90
+ # Cache result
91
+ if db
92
+ db.execute(
93
+ "INSERT OR REPLACE INTO weather_cache (date, hour, data, fetched_at) VALUES (?, ?, ?, ?)",
94
+ 'forecast', '00', JSON.generate(forecast), Time.now.to_i
95
+ )
96
+ end
97
+
98
+ forecast
99
+ rescue SocketError, Timeout::Error, Net::OpenTimeout, Errno::ECONNREFUSED => e
100
+ {}
101
+ end
102
+
103
+ # Get a short weather string for a date: "☀ 12°"
104
+ def self.short_for_date(forecast, date)
105
+ date_str = date.strftime('%Y-%m-%d')
106
+ w = forecast[date_str]
107
+ return nil unless w
108
+ "#{w['symbol']} #{w['temp_mid']}°"
109
+ end
110
+ end
111
+ end
data/lib/timely.rb ADDED
@@ -0,0 +1,45 @@
1
+ # Main Timely module
2
+ require 'fileutils'
3
+ require 'yaml'
4
+ require 'json'
5
+ require 'sqlite3'
6
+ require 'time'
7
+ require 'date'
8
+
9
+ # Create Timely home directory structure
10
+ TIMELY_HOME = File.expand_path('~/.timely')
11
+ TIMELY_DB = File.join(TIMELY_HOME, 'timely.db')
12
+ TIMELY_CONFIG = File.join(TIMELY_HOME, 'config.yml')
13
+ TIMELY_LOGS = File.join(TIMELY_HOME, 'logs')
14
+ TIMELY_CACHE = File.join(TIMELY_HOME, 'cache')
15
+
16
+ # Create directory structure
17
+ [TIMELY_HOME, TIMELY_LOGS, TIMELY_CACHE].each do |dir|
18
+ FileUtils.mkdir_p(dir)
19
+ end
20
+
21
+ # Load all components
22
+ require_relative 'timely/version'
23
+
24
+ # Core infrastructure
25
+ require_relative 'timely/database'
26
+ require_relative 'timely/config'
27
+ require_relative 'timely/event'
28
+ require_relative 'timely/astronomy'
29
+ require_relative 'timely/weather'
30
+
31
+ # Sources and sync
32
+ require_relative 'timely/sources/ics_file'
33
+ require_relative 'timely/sources/google'
34
+ require_relative 'timely/sources/outlook'
35
+ require_relative 'timely/sync/poller'
36
+ require_relative 'timely/notifications'
37
+
38
+ # UI components
39
+ require_relative 'timely/ui/panes'
40
+ require_relative 'timely/ui/views/month'
41
+ require_relative 'timely/ui/application'
42
+
43
+ module Timely
44
+ class Error < StandardError; end
45
+ end
data/timely.gemspec ADDED
@@ -0,0 +1,33 @@
1
+ require_relative 'lib/timely/version'
2
+
3
+ Gem::Specification.new do |spec|
4
+ spec.name = 'timely-calendar'
5
+ spec.version = Timely::VERSION
6
+ spec.authors = ['Geir Isene', 'Claude Code']
7
+ spec.email = ['g@isene.com']
8
+
9
+ spec.summary = 'Terminal Calendar - companion to Heathrow'
10
+ spec.description = 'A unified TUI calendar with Google Calendar and Outlook/365 integration, moon phases, weather, astronomy, desktop notifications, and Heathrow messaging handoff. Built on rcurses.'
11
+ spec.homepage = 'https://github.com/isene/timely'
12
+ spec.license = 'Unlicense'
13
+
14
+ spec.required_ruby_version = '>= 2.7.0'
15
+
16
+ spec.metadata['homepage_uri'] = spec.homepage
17
+ spec.metadata['source_code_uri'] = 'https://github.com/isene/timely'
18
+
19
+ # Specify which files should be added to the gem
20
+ spec.files = Dir.chdir(File.expand_path(__dir__)) do
21
+ `git ls-files -z`.split("\x0").reject do |f|
22
+ f.match(%r{\A(?:test|spec|features)/})
23
+ end
24
+ end
25
+
26
+ spec.bindir = 'bin'
27
+ spec.executables = ['timely']
28
+ spec.require_paths = ['lib']
29
+
30
+ # Runtime dependencies
31
+ spec.add_runtime_dependency 'rcurses', '>= 5.0'
32
+ spec.add_runtime_dependency 'sqlite3', '>= 1.4'
33
+ end
metadata ADDED
@@ -0,0 +1,98 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: timely-calendar
3
+ version: !ruby/object:Gem::Version
4
+ version: 1.0.0
5
+ platform: ruby
6
+ authors:
7
+ - Geir Isene
8
+ - Claude Code
9
+ autorequire:
10
+ bindir: bin
11
+ cert_chain: []
12
+ date: 2026-03-23 00:00:00.000000000 Z
13
+ dependencies:
14
+ - !ruby/object:Gem::Dependency
15
+ name: rcurses
16
+ requirement: !ruby/object:Gem::Requirement
17
+ requirements:
18
+ - - ">="
19
+ - !ruby/object:Gem::Version
20
+ version: '5.0'
21
+ type: :runtime
22
+ prerelease: false
23
+ version_requirements: !ruby/object:Gem::Requirement
24
+ requirements:
25
+ - - ">="
26
+ - !ruby/object:Gem::Version
27
+ version: '5.0'
28
+ - !ruby/object:Gem::Dependency
29
+ name: sqlite3
30
+ requirement: !ruby/object:Gem::Requirement
31
+ requirements:
32
+ - - ">="
33
+ - !ruby/object:Gem::Version
34
+ version: '1.4'
35
+ type: :runtime
36
+ prerelease: false
37
+ version_requirements: !ruby/object:Gem::Requirement
38
+ requirements:
39
+ - - ">="
40
+ - !ruby/object:Gem::Version
41
+ version: '1.4'
42
+ description: A unified TUI calendar with Google Calendar and Outlook/365 integration,
43
+ moon phases, weather, astronomy, desktop notifications, and Heathrow messaging handoff.
44
+ Built on rcurses.
45
+ email:
46
+ - g@isene.com
47
+ executables:
48
+ - timely
49
+ extensions: []
50
+ extra_rdoc_files: []
51
+ files:
52
+ - ".gitignore"
53
+ - CLAUDE.md
54
+ - README.md
55
+ - bin/timely
56
+ - img/timely.svg
57
+ - lib/timely.rb
58
+ - lib/timely/astronomy.rb
59
+ - lib/timely/config.rb
60
+ - lib/timely/database.rb
61
+ - lib/timely/event.rb
62
+ - lib/timely/notifications.rb
63
+ - lib/timely/sources/google.rb
64
+ - lib/timely/sources/ics_file.rb
65
+ - lib/timely/sources/outlook.rb
66
+ - lib/timely/sync/poller.rb
67
+ - lib/timely/ui/application.rb
68
+ - lib/timely/ui/panes.rb
69
+ - lib/timely/ui/views/month.rb
70
+ - lib/timely/version.rb
71
+ - lib/timely/weather.rb
72
+ - timely.gemspec
73
+ homepage: https://github.com/isene/timely
74
+ licenses:
75
+ - Unlicense
76
+ metadata:
77
+ homepage_uri: https://github.com/isene/timely
78
+ source_code_uri: https://github.com/isene/timely
79
+ post_install_message:
80
+ rdoc_options: []
81
+ require_paths:
82
+ - lib
83
+ required_ruby_version: !ruby/object:Gem::Requirement
84
+ requirements:
85
+ - - ">="
86
+ - !ruby/object:Gem::Version
87
+ version: 2.7.0
88
+ required_rubygems_version: !ruby/object:Gem::Requirement
89
+ requirements:
90
+ - - ">="
91
+ - !ruby/object:Gem::Version
92
+ version: '0'
93
+ requirements: []
94
+ rubygems_version: 3.4.20
95
+ signing_key:
96
+ specification_version: 4
97
+ summary: Terminal Calendar - companion to Heathrow
98
+ test_files: []