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,1960 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require 'rcurses'
5
+ require 'io/wait'
6
+ require 'date'
7
+
8
+ module Timely
9
+ class Application
10
+ include Rcurses
11
+ include Rcurses::Input
12
+ include Rcurses::Cursor
13
+ include UI::Panes
14
+
15
+ def initialize
16
+ @db = Database.new
17
+ @config = Config.new
18
+ @running = false
19
+ @selected_date = Date.today
20
+ @selected_event_index = 0
21
+ now = Time.now
22
+ @selected_slot = now.hour * 2 + (now.min >= 30 ? 1 : 0)
23
+ @slot_offset = [@selected_slot - 5, 0].max # Show a few rows above current time
24
+ @events_by_date = {}
25
+ end
26
+
27
+ def run
28
+ Rcurses.init!
29
+ Rcurses.clear_screen
30
+
31
+ setup_display
32
+ create_panes
33
+
34
+ load_events_for_range
35
+
36
+ # Auto-import ICS files from incoming directory
37
+ incoming_count = Sources::IcsFile.watch_incoming(@db)
38
+ load_events_for_range if incoming_count > 0
39
+
40
+ render_all
41
+
42
+ # Start background sync poller for Google Calendar
43
+ @poller = Sync::Poller.new(@db, @config)
44
+ @poller.start
45
+
46
+ # Flush stdin before loop
47
+ $stdin.getc while $stdin.wait_readable(0)
48
+
49
+ @running = true
50
+ loop do
51
+ chr = getchr(2, flush: false)
52
+ if chr
53
+ handle_input(chr)
54
+ else
55
+ # No input received (timeout); check if poller has new data
56
+ if @poller&.needs_refresh?
57
+ @poller.clear_refresh_flag
58
+ load_events_for_range
59
+ render_all
60
+ end
61
+ # Check notifications on idle (runs inexpensively)
62
+ Notifications.check_and_notify(@db) rescue nil
63
+ # Check for Heathrow goto trigger
64
+ check_heathrow_goto
65
+ end
66
+ break unless @running
67
+ end
68
+ ensure
69
+ @poller&.stop
70
+ Cursor.show
71
+ end
72
+
73
+ private
74
+
75
+ # --- Input handling ---
76
+
77
+ def handle_input(chr)
78
+ case chr
79
+ when 'y'
80
+ @selected_date = safe_date(@selected_date.year + 1, @selected_date.month, @selected_date.day)
81
+ date_changed
82
+ when 'Y'
83
+ @selected_date = safe_date(@selected_date.year - 1, @selected_date.month, @selected_date.day)
84
+ date_changed
85
+ when 'm'
86
+ @selected_date = @selected_date >> 1
87
+ date_changed
88
+ when 'M'
89
+ @selected_date = @selected_date << 1
90
+ date_changed
91
+ when 'w'
92
+ @selected_date += 7
93
+ date_changed
94
+ when 'W'
95
+ @selected_date -= 7
96
+ date_changed
97
+ when 'd', 'l', 'RIGHT'
98
+ @selected_date += 1
99
+ date_changed
100
+ when 'D', 'h', 'LEFT'
101
+ @selected_date -= 1
102
+ date_changed
103
+ when 'DOWN'
104
+ move_slot_down
105
+ when 'UP'
106
+ move_slot_up
107
+ when 'PgDOWN'
108
+ page_slots_down
109
+ when 'PgUP'
110
+ page_slots_up
111
+ when 'HOME'
112
+ go_slot_top
113
+ when 'END'
114
+ go_slot_bottom
115
+ when 'j'
116
+ select_next_event_on_day
117
+ when 'k'
118
+ select_prev_event_on_day
119
+ when 'e'
120
+ jump_to_next_event
121
+ when 'E'
122
+ jump_to_prev_event
123
+ when 't'
124
+ @selected_date = Date.today
125
+ @selected_event_index = 0
126
+ date_changed
127
+ when 'g'
128
+ go_to_date
129
+ when 'n'
130
+ create_event
131
+ when 'ENTER'
132
+ edit_event
133
+ when 'x', 'DEL'
134
+ delete_event
135
+ when 'C-Y'
136
+ copy_event_to_clipboard
137
+ when 'v'
138
+ view_event_popup
139
+ when 'a'
140
+ accept_invite
141
+ when 'r'
142
+ show_feedback("Reply via Heathrow: not yet implemented", 226)
143
+ when 'i'
144
+ import_ics_file
145
+ when 'G'
146
+ setup_google_calendar
147
+ when 'O'
148
+ setup_outlook_calendar
149
+ when 'S'
150
+ manual_sync
151
+ when 'C'
152
+ show_calendars
153
+ when 'P'
154
+ show_preferences
155
+ when '?'
156
+ show_help
157
+ when 'q'
158
+ @running = false
159
+ end
160
+ end
161
+
162
+ # --- Time slot navigation ---
163
+
164
+ # @selected_slot: 0-47 = time slots (00:00-23:30)
165
+ # negative = all-day event rows (-1 = first, -2 = second...)
166
+ def allday_count
167
+ @_allday_count_date ||= nil
168
+ if @_allday_count_date != @selected_date
169
+ events = events_on_selected_day
170
+ @_allday_count = events.count { |e| e['all_day'].to_i == 1 }
171
+ @_allday_count_date = @selected_date
172
+ end
173
+ @_allday_count
174
+ end
175
+
176
+ def min_slot
177
+ n = allday_count
178
+ n > 0 ? -n : 0
179
+ end
180
+
181
+ def adjust_slot_offset
182
+ return unless @selected_slot >= 0
183
+ allday_rows = allday_count > 0 ? allday_count + 1 : 0
184
+ available_rows = @panes[:mid].h - 3 - allday_rows
185
+ available_rows = [available_rows, 1].max
186
+ if @selected_slot - @slot_offset >= available_rows
187
+ @slot_offset = @selected_slot - available_rows + 1
188
+ elsif @selected_slot < @slot_offset
189
+ @slot_offset = @selected_slot
190
+ end
191
+ end
192
+
193
+ def move_slot_down
194
+ @selected_slot ||= (@config.get('work_hours.start', 8) rescue 8) * 2
195
+ @selected_slot = @selected_slot >= 47 ? min_slot : @selected_slot + 1
196
+ @slot_offset = 0 if @selected_slot == min_slot
197
+ adjust_slot_offset
198
+ render_mid_pane
199
+ render_bottom_pane
200
+ end
201
+
202
+ def move_slot_up
203
+ @selected_slot ||= (@config.get('work_hours.start', 8) rescue 8) * 2
204
+ @selected_slot = @selected_slot <= min_slot ? 47 : @selected_slot - 1
205
+ if @selected_slot == 47
206
+ allday_rows = allday_count > 0 ? allday_count + 1 : 0
207
+ available = [@panes[:mid].h - 3 - allday_rows, 1].max
208
+ @slot_offset = [48 - available, 0].max
209
+ end
210
+ adjust_slot_offset
211
+ render_mid_pane
212
+ render_bottom_pane
213
+ end
214
+
215
+ def page_slots_down
216
+ @selected_slot ||= (@config.get('work_hours.start', 8) rescue 8) * 2
217
+ @selected_slot = [[@selected_slot + 10, 47].min, min_slot].max
218
+ adjust_slot_offset
219
+ render_mid_pane
220
+ render_bottom_pane
221
+ end
222
+
223
+ def page_slots_up
224
+ @selected_slot ||= (@config.get('work_hours.start', 8) rescue 8) * 2
225
+ @selected_slot = [[@selected_slot - 10, min_slot].max, min_slot].max
226
+ adjust_slot_offset
227
+ render_mid_pane
228
+ render_bottom_pane
229
+ end
230
+
231
+ def go_slot_top
232
+ @selected_slot = min_slot
233
+ @slot_offset = 0
234
+ render_mid_pane
235
+ render_bottom_pane
236
+ end
237
+
238
+ def go_slot_bottom
239
+ @selected_slot = 47
240
+ allday_rows = allday_count > 0 ? allday_count + 1 : 0
241
+ available_rows = @panes[:mid].h - 3 - allday_rows
242
+ available_rows = [available_rows, 1].max
243
+ @slot_offset = [48 - available_rows, 0].max
244
+ render_mid_pane
245
+ render_bottom_pane
246
+ end
247
+
248
+ # --- Date/event state changes ---
249
+
250
+ def check_heathrow_goto
251
+ goto_file = File.join(TIMELY_HOME, 'goto')
252
+ return unless File.exist?(goto_file)
253
+ content = File.read(goto_file).strip
254
+ File.delete(goto_file)
255
+ return if content.empty?
256
+ date = Date.parse(content) rescue nil
257
+ if date
258
+ @selected_date = date
259
+ @selected_event_index = 0
260
+ load_events_for_range
261
+ render_all
262
+ end
263
+ rescue => e
264
+ nil
265
+ end
266
+
267
+ def date_changed
268
+ @selected_event_index = 0
269
+ load_events_for_range
270
+ render_all
271
+ end
272
+
273
+ def safe_date(year, month, day)
274
+ # Clamp day to valid range for the target month
275
+ last_day = Date.new(year, month, -1).day
276
+ Date.new(year, month, [day, last_day].min)
277
+ rescue Date::Error
278
+ Date.today
279
+ end
280
+
281
+ # --- Event navigation ---
282
+
283
+ def events_on_selected_day
284
+ @events_by_date[@selected_date] || []
285
+ end
286
+
287
+ def humanize_status(status)
288
+ case status.to_s
289
+ when 'needsAction' then 'Needs response'
290
+ when 'accepted' then 'Accepted'
291
+ when 'declined' then 'Declined'
292
+ when 'tentative', 'tentativelyAccepted' then 'Tentative'
293
+ when 'confirmed' then 'Confirmed'
294
+ when 'cancelled' then 'Cancelled'
295
+ else status.to_s
296
+ end
297
+ end
298
+
299
+ def clean_description(desc)
300
+ return nil unless desc
301
+ # Remove common garbage from Google/Outlook descriptions
302
+ desc.gsub(/BC\d+-Color:\s*-?\d+\s*/, '').gsub(/-::~:~::~:~.*$/m, '').strip
303
+ end
304
+
305
+ # Find the event at the currently selected time slot
306
+ def event_at_selected_slot
307
+ return nil unless @selected_slot
308
+ events = events_on_selected_day
309
+
310
+ if @selected_slot < 0
311
+ # Negative slot: -n = top (first allday), -1 = bottom (last allday)
312
+ allday = events.select { |e| e['all_day'].to_i == 1 }
313
+ n = allday.size
314
+ idx = n - @selected_slot.abs # -n -> 0 (first), -1 -> n-1 (last)
315
+ return allday[idx]
316
+ end
317
+
318
+ hour = @selected_slot / 2
319
+ minute = (@selected_slot % 2) * 30
320
+ slot_start = Time.new(@selected_date.year, @selected_date.month, @selected_date.day, hour, minute, 0).to_i
321
+ slot_end = slot_start + 1800
322
+ events.find do |e|
323
+ next if e['all_day'].to_i == 1
324
+ es = e['start_time'].to_i
325
+ ee = e['end_time'].to_i
326
+ es < slot_end && ee > slot_start
327
+ end
328
+ end
329
+
330
+ def select_next_event_on_day
331
+ events = events_on_selected_day
332
+ return if events.empty?
333
+ @selected_event_index = (@selected_event_index + 1) % events.length
334
+ render_mid_pane
335
+ render_bottom_pane
336
+ end
337
+
338
+ def select_prev_event_on_day
339
+ events = events_on_selected_day
340
+ return if events.empty?
341
+ @selected_event_index = (@selected_event_index - 1) % events.length
342
+ render_mid_pane
343
+ render_bottom_pane
344
+ end
345
+
346
+ def jump_to_next_event
347
+ events = events_on_selected_day
348
+ # If there are more events on the current day after the selected one, go to next
349
+ if events.length > 0 && @selected_event_index < events.length - 1
350
+ @selected_event_index += 1
351
+ render_mid_pane
352
+ render_bottom_pane
353
+ return
354
+ end
355
+
356
+ # Scan forward day by day (up to 365 days)
357
+ (1..365).each do |offset|
358
+ check_date = @selected_date + offset
359
+ day_events = @db.get_events_for_date(check_date)
360
+ if day_events && !day_events.empty?
361
+ @selected_date = check_date
362
+ @selected_event_index = 0
363
+ date_changed
364
+ return
365
+ end
366
+ end
367
+
368
+ show_feedback("No more events found within the next year", 245)
369
+ end
370
+
371
+ def jump_to_prev_event
372
+ events = events_on_selected_day
373
+ # If there are more events on the current day before the selected one, go to prev
374
+ if events.length > 0 && @selected_event_index > 0
375
+ @selected_event_index -= 1
376
+ render_mid_pane
377
+ render_bottom_pane
378
+ return
379
+ end
380
+
381
+ # Scan backward day by day (up to 365 days)
382
+ (1..365).each do |offset|
383
+ check_date = @selected_date - offset
384
+ day_events = @db.get_events_for_date(check_date)
385
+ if day_events && !day_events.empty?
386
+ @selected_date = check_date
387
+ @selected_event_index = day_events.length - 1
388
+ date_changed
389
+ return
390
+ end
391
+ end
392
+
393
+ show_feedback("No earlier events found within the past year", 245)
394
+ end
395
+
396
+ # --- Data loading ---
397
+
398
+ def load_events_for_range
399
+ # Load events covering visible months (a generous range around the selected date)
400
+ range_start = Date.new(@selected_date.year, @selected_date.month, 1) << 3
401
+ range_end = Date.new(@selected_date.year, @selected_date.month, -1) >> 3
402
+
403
+ start_ts = Time.new(range_start.year, range_start.month, range_start.day, 0, 0, 0).to_i
404
+ end_ts = Time.new(range_end.year, range_end.month, range_end.day, 23, 59, 59).to_i
405
+
406
+ raw_events = @db.get_events_in_range(start_ts, end_ts)
407
+
408
+ @events_by_date = {}
409
+ raw_events.each do |evt|
410
+ st = Time.at(evt['start_time'].to_i).to_date
411
+ et = evt['end_time'] ? Time.at(evt['end_time'].to_i).to_date : st
412
+
413
+ (st..et).each do |d|
414
+ next unless d >= range_start && d <= range_end
415
+ @events_by_date[d] ||= []
416
+ @events_by_date[d] << evt
417
+ end
418
+ end
419
+
420
+ # Sort each day's events once up front
421
+ @events_by_date.each_value { |evts| evts.sort_by! { |e| e['start_time'].to_i } }
422
+
423
+ # Clamp selected event index
424
+ events = events_on_selected_day
425
+ @selected_event_index = 0 if events.empty?
426
+ @selected_event_index = events.length - 1 if @selected_event_index >= events.length
427
+
428
+ # Load weather (cached, refetch every 6 hours)
429
+ if !@weather_forecast || @weather_forecast.empty? || !@_weather_fetched_at || (Time.now.to_i - @_weather_fetched_at) > 21600
430
+ lat = @config.get('location.lat', 59.9139)
431
+ lon = @config.get('location.lon', 10.7522)
432
+ @weather_forecast = Weather.fetch(lat, lon, @db) rescue {}
433
+ @_weather_fetched_at = Time.now.to_i
434
+ end
435
+ end
436
+
437
+ # --- Rendering ---
438
+
439
+ def render_all
440
+ # Check for terminal resize
441
+ old_h, old_w = @h, @w
442
+ setup_display
443
+ if @h != old_h || @w != old_w
444
+ Rcurses.clear_screen
445
+ create_panes
446
+ end
447
+
448
+ # Set terminal window title
449
+ events = events_on_selected_day
450
+ evt_count = events.size
451
+ title_str = "Timely: #{@selected_date.strftime('%a %b %d, %Y')}"
452
+ title_str += " (#{evt_count} event#{evt_count == 1 ? '' : 's'})" if evt_count > 0
453
+ $stdout.print "\033]0;#{title_str}\007"
454
+
455
+ render_info_bar
456
+ render_top_pane
457
+ render_mid_pane
458
+ render_bottom_pane
459
+ render_status_bar
460
+ end
461
+
462
+ # Info bar: top row with bg color
463
+ def render_info_bar
464
+ title = " Timely".b
465
+ date_str = @selected_date.strftime(" %A, %B %d, %Y")
466
+ phase = Astronomy.moon_phase(@selected_date)
467
+ moon = " #{phase[:symbol]} #{phase[:phase_name]} (#{(phase[:illumination] * 100).round}%)"
468
+
469
+ lat = @config.get('location.lat', 59.9139)
470
+ lon = @config.get('location.lon', 10.7522)
471
+ tz = @config.get('timezone_offset', 1)
472
+
473
+ # Sunrise/sunset (yellow sun)
474
+ sun = Astronomy.sun_times(@selected_date, lat, lon, tz)
475
+ sun_color = Astronomy::BODY_COLORS['sun']
476
+ sun_str = sun ? " " + "\u2600".fg(sun_color) + "\u2191#{sun[:rise]} " + "\u2600".fg(sun_color) + "\u2193#{sun[:set]}" : ""
477
+
478
+ # Visible planets (cached per date)
479
+ @_cached_planets_date ||= nil
480
+ if @_cached_planets_date != @selected_date
481
+ @_cached_planets = Astronomy.visible_planets(@selected_date, lat, lon, tz)
482
+ @_cached_planets_date = @selected_date
483
+ end
484
+ planets = @_cached_planets || []
485
+ planet_str = planets.any? ? " " + planets.map { |p|
486
+ color = Astronomy::BODY_COLORS[p[:name].downcase] || '888888'
487
+ p[:symbol].fg(color)
488
+ }.join(" ") : ""
489
+
490
+ @panes[:info].text = title + date_str + moon + sun_str + planet_str
491
+ @panes[:info].refresh
492
+ end
493
+
494
+ # Status bar: bottom row with key hints
495
+ def render_status_bar
496
+ keys = "d/D:Day w/W:Week m/M:Month y/Y:Year e/E:Event n:New g:GoTo t:Today i:Import G:Google O:Outlook S:Sync C:Cal P:Prefs ?:Help q:Quit"
497
+ if @syncing
498
+ sync_indicator = " Syncing...".fg(226)
499
+ pad = @w - keys.length - 12
500
+ pad = [pad, 1].max
501
+ @panes[:status].text = " " + keys + " " * pad + sync_indicator
502
+ else
503
+ @panes[:status].text = " " + keys
504
+ end
505
+ @panes[:status].refresh
506
+ end
507
+
508
+ # Top pane: horizontal strip of mini-month calendars
509
+ def render_top_pane
510
+ today = Date.today
511
+ month_width = 26 # 25 chars + 1 space separator
512
+ months_visible = [@w / month_width, 1].max
513
+
514
+ offset = 3 # Selected month is always the 4th (middle of 8)
515
+
516
+ months = []
517
+ months_visible.times do |i|
518
+ m_offset = i - offset
519
+ d = @selected_date >> m_offset
520
+ months << [d.year, d.month]
521
+ end
522
+
523
+ # Render each mini-month; current month gets bg color
524
+ rendered = months.map do |year, month|
525
+ sel_day = (year == @selected_date.year && month == @selected_date.month) ? @selected_date.day : nil
526
+ is_current = (year == @selected_date.year && month == @selected_date.month)
527
+ tbg = @config.get('colors.today_bg', 246)
528
+ lines = UI::Views::Month.render_mini_month(year, month, sel_day, today, @events_by_date, month_width - 1, today_bg: tbg)
529
+ # Apply bg to current month
530
+ if is_current
531
+ lines.map { |l| l.bg(@config.get('colors.current_month_bg', 233)) }
532
+ else
533
+ lines
534
+ end
535
+ end
536
+
537
+ max_lines = rendered.map(&:length).max || 0
538
+ combined_lines = [""] # One row top padding
539
+
540
+ max_lines.times do |row|
541
+ parts = rendered.map do |month_lines|
542
+ line = month_lines[row] || ""
543
+ pure_len = Rcurses.display_width(line.respond_to?(:pure) ? line.pure : line)
544
+ padding = (month_width - 1) - pure_len
545
+ padding = 0 if padding < 0
546
+ line + " " * padding
547
+ end
548
+ combined_lines << " " + parts.join(" ")
549
+ end
550
+
551
+ while combined_lines.length < @panes[:top].h
552
+ combined_lines << ""
553
+ end
554
+
555
+ @panes[:top].text = combined_lines.join("\n")
556
+ @panes[:top].full_refresh
557
+ end
558
+
559
+ # Mid pane: week view with time column + day columns
560
+ def render_mid_pane
561
+ week_start = @selected_date - (@selected_date.cwday - 1)
562
+ time_col = 6 # "HH:MM " width
563
+ gap = 1 # gap between day columns
564
+ day_col = (@w - time_col - gap * 6) / 7 # 7 days, 6 gaps between them
565
+ day_col = [day_col, 8].max
566
+ sel_alt_a = @config.get('colors.selected_bg_a', 235)
567
+ sel_alt_b = @config.get('colors.selected_bg_b', 234)
568
+ alt_bg_a = @config.get('colors.alt_bg_a', 233)
569
+ alt_bg_b = @config.get('colors.alt_bg_b', 0)
570
+ slot_sel_bg = @config.get('colors.slot_selected_bg', 237)
571
+
572
+ lines = []
573
+
574
+ # Weather row above day headers
575
+ weather_parts = [" " * time_col]
576
+ 7.times do |i|
577
+ day = week_start + i
578
+ w_str = Weather.short_for_date(@weather_forecast || {}, day)
579
+ w_str ||= ""
580
+ pure_len = Rcurses.display_width(w_str)
581
+ pad = [day_col - pure_len, 0].max
582
+ weather_parts << w_str.fg(245) + " " * pad
583
+ end
584
+ lines << weather_parts.join(" ")
585
+
586
+ # Column headers: week number + time column + day headers
587
+ wk_num = "W#{week_start.cweek}".fg(238)
588
+ header_parts = [wk_num + " " * [time_col - 3, 1].max]
589
+ 7.times do |i|
590
+ day = week_start + i
591
+ header = "#{day.strftime('%a')} #{day.day}"
592
+ is_sel = (day == @selected_date)
593
+ is_today = (day == Date.today)
594
+
595
+ # Base color: weekend colors or default gray
596
+ base_color = if day.sunday?
597
+ @config.get('colors.sunday', 167)
598
+ elsif day.saturday?
599
+ @config.get('colors.saturday', 208)
600
+ else
601
+ 245
602
+ end
603
+
604
+ today_bg = @config.get('colors.today_bg', 246)
605
+ today_fg = @config.get('colors.today_fg', 232)
606
+ header = if is_sel && is_today
607
+ header.b.u.fg(today_fg).bg(today_bg)
608
+ elsif is_sel
609
+ header.b.u.fg(base_color).bg(sel_alt_a)
610
+ elsif is_today
611
+ header.b.u.fg(today_fg).bg(today_bg)
612
+ else
613
+ header.fg(base_color)
614
+ end
615
+
616
+ pure_len = Rcurses.display_width(header.respond_to?(:pure) ? header.pure : header)
617
+ pad = [day_col - pure_len, 0].max
618
+ padding = if is_sel && is_today
619
+ " ".bg(today_bg) * pad
620
+ elsif is_sel
621
+ " ".bg(sel_alt_a) * pad
622
+ elsif is_today
623
+ " ".bg(today_bg) * pad
624
+ else
625
+ " " * pad
626
+ end
627
+ header_parts << header + padding
628
+ end
629
+ lines << header_parts.join(" ")
630
+ lines << ("-" * @w).fg(238)
631
+
632
+ # Gather events for each day, split all-day from timed
633
+ week_events = []
634
+ week_allday = []
635
+ 7.times do |i|
636
+ day = week_start + i
637
+ all = @events_by_date[day] || []
638
+ week_allday << all.select { |e| e['all_day'].to_i == 1 }
639
+ week_events << all.reject { |e| e['all_day'].to_i == 1 }
640
+ end
641
+
642
+ # All-day event row(s) above time grid
643
+ max_allday = week_allday.map(&:size).max || 0
644
+ if max_allday > 0
645
+ max_allday.times do |row|
646
+ allday_slot = -(max_allday - row) # top row = most negative, bottom = -1
647
+ is_row_selected = (@selected_slot == allday_slot)
648
+ parts = [is_row_selected ? " All".fg(255).b + " " : " " * time_col]
649
+ 7.times do |col|
650
+ evt = week_allday[col][row]
651
+ day = week_start + col
652
+ is_sel = (day == @selected_date)
653
+ is_at = is_sel && is_row_selected
654
+ cell_bg = is_at ? slot_sel_bg : (is_sel ? sel_alt_a : nil)
655
+ if evt
656
+ title = evt['title'] || "(No title)"
657
+ color = evt['calendar_color'] || 39
658
+ marker = is_at ? ">" : " "
659
+ entry = "#{marker}#{title}"[0, day_col - 1]
660
+ cell = cell_bg ? entry.fg(color).b.bg(cell_bg) : entry.fg(color)
661
+ else
662
+ cell = cell_bg ? " ".bg(cell_bg) : " "
663
+ end
664
+ pure_len = Rcurses.display_width(cell.respond_to?(:pure) ? cell.pure : cell)
665
+ pad = [day_col - pure_len, 0].max
666
+ pad_str = is_sel ? " ".bg(sel_alt_a) * pad : " " * pad
667
+ parts << cell + pad_str
668
+ end
669
+ lines << parts.join(" ")
670
+ end
671
+ lines << ("-" * @w).fg(238)
672
+ end
673
+
674
+ # Build half-hour time slots with scroll offset
675
+ work_start = @config.get('work_hours.start', 8) rescue 8
676
+ extra_rows = max_allday > 0 ? max_allday + 1 : 0 # allday rows + separator
677
+ available_rows = @panes[:mid].h - 3 - extra_rows
678
+ # Default slot offset to work_start if not set
679
+ @slot_offset ||= work_start * 2
680
+ # Clamp offset
681
+ @slot_offset = [[@slot_offset, 0].max, [48 - available_rows, 0].max].min
682
+
683
+ slots = []
684
+ (@slot_offset...[@slot_offset + available_rows, 48].min).each do |slot|
685
+ hour = slot / 2
686
+ minute = (slot % 2) * 30
687
+ slots << [hour, minute, slot]
688
+ end
689
+
690
+ slots.each_with_index do |(hour, minute, slot_idx), row|
691
+ is_slot_selected = (@selected_slot == slot_idx)
692
+ row_bg = row.even? ? alt_bg_a : alt_bg_b
693
+
694
+ # Time label: highlight if selected
695
+ time_label = format("%02d:%02d ", hour, minute)
696
+ time_label = is_slot_selected ? time_label.fg(255).b : time_label.fg(238)
697
+
698
+ parts = [time_label]
699
+ 7.times do |col|
700
+ day = week_start + col
701
+ is_sel = (day == @selected_date)
702
+ cell_bg_base = row.even? ? alt_bg_a : alt_bg_b
703
+ if is_sel && is_slot_selected
704
+ cell_bg = slot_sel_bg
705
+ elsif is_sel
706
+ cell_bg = row.even? ? sel_alt_a : sel_alt_b
707
+ else
708
+ cell_bg = cell_bg_base
709
+ end
710
+
711
+ # Find event at this time slot
712
+ day_ts_start = Time.new(day.year, day.month, day.day, hour, minute, 0).to_i
713
+ day_ts_end = day_ts_start + 1800 # 30 min slot
714
+
715
+ evt = week_events[col].find do |e|
716
+ es = e['start_time'].to_i
717
+ ee = e['end_time'].to_i
718
+ es < day_ts_end && ee > day_ts_start
719
+ end
720
+
721
+ if evt
722
+ is_at_slot = is_sel && is_slot_selected
723
+ marker = is_at_slot ? ">" : " "
724
+ title = evt['title'] || "(No title)"
725
+ color = evt['calendar_color'] || 39
726
+ entry = "#{marker}#{title}"
727
+ entry = entry[0, day_col - 1] + "." if entry.length > day_col
728
+ cell = is_at_slot ? entry.fg(color).b.bg(cell_bg) : entry.fg(color).bg(cell_bg)
729
+ else
730
+ cell = " ".bg(cell_bg)
731
+ end
732
+
733
+ pure_len = Rcurses.display_width(cell.respond_to?(:pure) ? cell.pure : cell)
734
+ pad = [day_col - pure_len, 0].max
735
+ parts << cell + " ".bg(cell_bg) * pad
736
+ end
737
+ lines << parts.join(" ")
738
+ end
739
+
740
+ while lines.length < @panes[:mid].h
741
+ lines << ""
742
+ end
743
+
744
+ @panes[:mid].text = lines.join("\n")
745
+ @panes[:mid].full_refresh
746
+ end
747
+
748
+ # Bottom pane: event details or day summary
749
+ def render_bottom_pane
750
+ lines = []
751
+ events = events_on_selected_day
752
+
753
+ # Separator
754
+ lines << ("-" * @w).fg(238)
755
+
756
+ evt = event_at_selected_slot
757
+ if evt
758
+ color = evt['calendar_color'] || 39
759
+ max_lines = 50 # Allow scrolling for long descriptions
760
+
761
+ # Line 1: Title + time on same line
762
+ title = evt['title'] || '(No title)'
763
+ if evt['all_day'].to_i == 1
764
+ time_info = @selected_date.strftime('%a %Y-%m-%d') + " All day"
765
+ else
766
+ st = Time.at(evt['start_time'].to_i)
767
+ time_info = st.strftime('%a %Y-%m-%d %H:%M')
768
+ time_info += " - #{Time.at(evt['end_time'].to_i).strftime('%H:%M')}" if evt['end_time']
769
+ end
770
+ lines << " #{title}".fg(color).b + " #{time_info}".fg(252)
771
+
772
+ # Line 2: Location + Organizer + Calendar on same line
773
+ details = []
774
+ if evt['location'] && !evt['location'].to_s.strip.empty?
775
+ details << "Location: #{evt['location'].to_s.strip}"
776
+ end
777
+ if evt['organizer'] && !evt['organizer'].to_s.strip.empty?
778
+ details << "Organizer: #{evt['organizer']}"
779
+ end
780
+ cal_name = evt['calendar_name'] || 'Unknown'
781
+ details << "Calendar: #{cal_name}"
782
+ detail_line = " " + details.join(" | ")
783
+ detail_line = detail_line[0, @w - 2] if detail_line.length > @w - 2
784
+ lines << detail_line.fg(245)
785
+
786
+ # Line 3: Status (if relevant)
787
+ status_parts = []
788
+ status_parts << "Status: #{evt['status']}" if evt['status']
789
+ status_parts << "My status: #{humanize_status(evt['my_status'])}" if evt['my_status']
790
+ lines << " #{status_parts.join(' | ')}".fg(245) unless status_parts.empty?
791
+
792
+ # Remaining lines: Description (wrapped to pane width)
793
+ desc = clean_description(evt['description'])
794
+ if desc && !desc.empty?
795
+ desc = desc.gsub(/\r?\n/, " ")
796
+ remaining = max_lines - lines.size
797
+ if remaining > 1
798
+ lines << ""
799
+ # Word-wrap description to fit remaining lines
800
+ words = desc.split(' ')
801
+ line = " "
802
+ words.each do |word|
803
+ if line.length + word.length + 1 > @w - 2
804
+ lines << line.fg(248)
805
+ break if lines.size >= max_lines
806
+ line = " " + word
807
+ else
808
+ line += (line == " " ? "" : " ") + word
809
+ end
810
+ end
811
+ lines << line.fg(248) if lines.size < max_lines && line.strip.length > 0
812
+ end
813
+ end
814
+
815
+ else
816
+ # No events: show day summary
817
+ lines << " #{@selected_date.strftime('%A, %B %d, %Y')}".b
818
+
819
+ # Astronomical events (solstices, meteor showers, etc.)
820
+ lat = @config.get('location.lat', 59.9139)
821
+ lon = @config.get('location.lon', 10.7522)
822
+ tz = @config.get('timezone_offset', 1)
823
+ astro = Astronomy.astro_events(@selected_date, lat, lon, tz)
824
+ astro.each { |evt| lines << " #{evt}".fg(180) } if astro.any?
825
+
826
+ lines << ""
827
+ lines << " No events scheduled".fg(240)
828
+ end
829
+
830
+ # Pad to fill pane
831
+ while lines.length < @panes[:bottom].h
832
+ lines << ""
833
+ end
834
+
835
+ @panes[:bottom].text = lines.join("\n")
836
+ @panes[:bottom].full_refresh
837
+ end
838
+
839
+ # --- Actions ---
840
+
841
+ def go_to_date
842
+ input = bottom_ask("Go to: ", "")
843
+ return if input.nil? || input.strip.empty?
844
+
845
+ input = input.strip
846
+
847
+ parsed = parse_go_to_input(input)
848
+ if parsed
849
+ @selected_date = parsed
850
+ @selected_event_index = 0
851
+ date_changed
852
+ else
853
+ show_feedback("Could not parse date: #{input}", 196)
854
+ end
855
+ end
856
+
857
+ def parse_go_to_input(input)
858
+ return Date.today if input.downcase == 'today'
859
+
860
+ # Try exact date first
861
+ return Date.parse(input) if input =~ /\d{4}-\d{1,2}-\d{1,2}/
862
+
863
+ # Year only
864
+ return Date.new(input.to_i, 1, 1) if input =~ /\A\d{4}\z/
865
+
866
+ # Month name
867
+ months = %w[jan feb mar apr may jun jul aug sep oct nov dec]
868
+ months.each_with_index do |m, i|
869
+ if input.downcase.start_with?(m)
870
+ return Date.new(@selected_date.year, i + 1, 1)
871
+ end
872
+ end
873
+
874
+ # Day number (1-31)
875
+ if input =~ /\A\d{1,2}\z/ && input.to_i.between?(1, 31)
876
+ day = [input.to_i, Date.new(@selected_date.year, @selected_date.month, -1).day].min
877
+ return Date.new(@selected_date.year, @selected_date.month, day)
878
+ end
879
+
880
+ Date.parse(input) rescue nil
881
+ end
882
+
883
+ def create_event
884
+ default_time = @selected_slot ? format("%02d:%02d", @selected_slot / 2, (@selected_slot % 2) * 30) : "09:00"
885
+ calendars = @db.get_calendars
886
+ default_cal_id = @config.get('default_calendar', 1)
887
+ cal = calendars.find { |c| c['id'] == default_cal_id } || calendars.first
888
+ return show_feedback("No calendars configured", 196) unless cal
889
+ cal_color = cal['color'] || 39
890
+
891
+ # Calendar picker (if multiple)
892
+ if calendars.size > 1
893
+ cal_list = calendars.each_with_index.map { |c, i| "#{i + 1}:#{c['name']}" }.join(" ")
894
+ default_idx = calendars.index(cal) || 0
895
+ blank_bottom(" New Event".fg(cal_color).b)
896
+ pick = bottom_ask(" Calendar (#{cal_list}): ", (default_idx + 1).to_s)
897
+ return cancel_create if pick.nil?
898
+ idx = pick.strip.to_i - 1
899
+ cal = calendars[idx] if idx >= 0 && idx < calendars.size
900
+ cal_color = cal['color'] || 39
901
+ end
902
+
903
+ blank_bottom(" New Event on #{@selected_date.strftime('%A, %B %d, %Y')}".fg(cal_color).b)
904
+ title = bottom_ask(" Title: ", "")
905
+ return cancel_create if title.nil? || title.strip.empty?
906
+
907
+ blank_bottom(" #{title.strip}".fg(cal_color).b)
908
+ time_str = bottom_ask(" Start time (HH:MM or 'all day'): ", default_time)
909
+ return cancel_create if time_str.nil?
910
+
911
+ all_day = (time_str.strip.downcase == 'all day')
912
+
913
+ if all_day
914
+ start_ts = Time.new(@selected_date.year, @selected_date.month, @selected_date.day, 0, 0, 0).to_i
915
+ end_ts = start_ts + 86400
916
+ else
917
+ parts = time_str.strip.split(':')
918
+ hour = parts[0].to_i
919
+ minute = (parts[1] || 0).to_i
920
+ start_ts = Time.new(@selected_date.year, @selected_date.month, @selected_date.day, hour, minute, 0).to_i
921
+
922
+ blank_bottom(" #{title.strip} at #{time_str.strip}".fg(cal_color).b)
923
+ dur_str = bottom_ask(" Duration in minutes: ", "60")
924
+ return cancel_create if dur_str.nil?
925
+ duration = dur_str.strip.to_i
926
+ duration = 60 if duration <= 0
927
+ end_ts = start_ts + duration * 60
928
+ end
929
+
930
+ # Location
931
+ blank_bottom(" #{title.strip}".fg(cal_color).b)
932
+ location = bottom_ask(" Location (Enter to skip): ", "")
933
+ location = nil if location.nil? || location.strip.empty?
934
+
935
+ # Invitees
936
+ blank_bottom(" #{title.strip}".fg(cal_color).b)
937
+ invitees_str = bottom_ask(" Invite (comma-separated emails, Enter to skip): ", "")
938
+ attendees = nil
939
+ if invitees_str && !invitees_str.strip.empty?
940
+ attendees = invitees_str.strip.split(',').map { |e| { 'email' => e.strip } }
941
+ end
942
+
943
+ # Attachments via rtfm --pick
944
+ blank_bottom(" #{title.strip}".fg(cal_color).b)
945
+ attach_str = bottom_ask(" Add attachments? (y/N): ", "")
946
+ attachments = nil
947
+ if attach_str&.strip&.downcase == 'y'
948
+ files = run_rtfm_picker
949
+ if files && !files.empty?
950
+ attachments = files.map { |f| { 'path' => f } }
951
+ end
952
+ end
953
+
954
+ @db.save_event(
955
+ title: title.strip,
956
+ start_time: start_ts,
957
+ end_time: end_ts,
958
+ all_day: all_day,
959
+ calendar_id: cal['id'],
960
+ location: location&.strip,
961
+ attendees: attendees,
962
+ metadata: attachments ? { 'attachments' => attachments } : nil,
963
+ status: 'confirmed'
964
+ )
965
+
966
+ load_events_for_range
967
+ render_all
968
+ msg = "Event created: #{title.strip}"
969
+ msg += " (invites will be sent when calendar sync is configured)" if attendees
970
+ show_feedback(msg, cal_color)
971
+ end
972
+
973
+ def blank_bottom(header = "")
974
+ lines = []
975
+ lines << ("-" * @w).fg(238)
976
+ lines << ""
977
+ lines << header unless header.empty?
978
+ while lines.length < @panes[:bottom].h
979
+ lines << ""
980
+ end
981
+ @panes[:bottom].text = lines.join("\n")
982
+ @panes[:bottom].full_refresh
983
+ end
984
+
985
+ def bottom_ask(prompt, default = "")
986
+ # Prompt pane below separator + header + blank line
987
+ prompt_y = @panes[:bottom].y + 3
988
+ prompt_pane = Rcurses::Pane.new(1, prompt_y, @w, 1)
989
+ prompt_pane.border = false
990
+ prompt_pane.scroll = false
991
+ result = prompt_pane.ask(prompt, default)
992
+ result
993
+ end
994
+
995
+ def cancel_create
996
+ render_all
997
+ end
998
+
999
+ def run_rtfm_picker
1000
+ require 'shellwords'
1001
+ pick_file = "/tmp/timely_pick_#{Process.pid}.txt"
1002
+ File.delete(pick_file) if File.exist?(pick_file)
1003
+
1004
+ system("stty sane 2>/dev/null")
1005
+ Cursor.show
1006
+ system("rtfm --pick=#{Shellwords.escape(pick_file)}")
1007
+ $stdin.raw!
1008
+ $stdin.echo = false
1009
+ Cursor.hide
1010
+ Rcurses.clear_screen
1011
+ setup_display
1012
+ create_panes
1013
+ render_all
1014
+
1015
+ if File.exist?(pick_file)
1016
+ files = File.read(pick_file).lines.map(&:strip).reject(&:empty?)
1017
+ File.delete(pick_file) rescue nil
1018
+ files.select { |f| File.exist?(f) && File.file?(f) }
1019
+ else
1020
+ []
1021
+ end
1022
+ end
1023
+
1024
+ def edit_event
1025
+ evt = event_at_selected_slot
1026
+ return show_feedback("No event at this time slot", 245) unless evt
1027
+
1028
+ blank_bottom(" Edit Event".b)
1029
+ new_title = bottom_ask(" Title: ", evt['title'] || "")
1030
+ return if new_title.nil?
1031
+
1032
+ @db.save_event(
1033
+ id: evt['id'],
1034
+ calendar_id: evt['calendar_id'],
1035
+ external_id: evt['external_id'],
1036
+ title: new_title.strip,
1037
+ description: evt['description'],
1038
+ location: evt['location'],
1039
+ start_time: evt['start_time'],
1040
+ end_time: evt['end_time'],
1041
+ all_day: evt['all_day'].to_i == 1,
1042
+ timezone: evt['timezone'],
1043
+ recurrence_rule: evt['recurrence_rule'],
1044
+ status: evt['status'],
1045
+ organizer: evt['organizer'],
1046
+ attendees: evt['attendees'],
1047
+ my_status: evt['my_status'],
1048
+ alarms: evt['alarms'],
1049
+ metadata: evt['metadata']
1050
+ )
1051
+
1052
+ load_events_for_range
1053
+ render_all
1054
+ show_feedback("Event updated", 156)
1055
+ end
1056
+
1057
+ def delete_event
1058
+ events = events_on_selected_day
1059
+ evt = event_at_selected_slot
1060
+ return show_feedback("No event at this time slot", 245) unless evt
1061
+
1062
+ blank_bottom(" Delete Event".b)
1063
+ confirm = bottom_ask(" Delete '#{evt['title']}'? (y/n): ", "")
1064
+ return unless confirm&.strip&.downcase == 'y'
1065
+
1066
+ @db.delete_event(evt['id'])
1067
+
1068
+ load_events_for_range
1069
+ render_all
1070
+ show_feedback("Event deleted", 156)
1071
+ end
1072
+
1073
+ def copy_event_to_clipboard
1074
+ evt = event_at_selected_slot
1075
+ return show_feedback("No event at this time slot", 245) unless evt
1076
+
1077
+ lines = []
1078
+ lines << evt['title'] || '(No title)'
1079
+ if evt['all_day'].to_i == 1
1080
+ lines << "#{@selected_date.strftime('%A, %B %d, %Y')} All day"
1081
+ else
1082
+ st = Time.at(evt['start_time'].to_i)
1083
+ time_str = st.strftime('%A, %B %d, %Y %H:%M')
1084
+ time_str += " - #{Time.at(evt['end_time'].to_i).strftime('%H:%M')}" if evt['end_time']
1085
+ lines << time_str
1086
+ end
1087
+ lines << "Location: #{evt['location']}" if evt['location'] && !evt['location'].to_s.strip.empty?
1088
+ lines << "Organizer: #{evt['organizer']}" if evt['organizer'] && !evt['organizer'].to_s.strip.empty?
1089
+ lines << "Calendar: #{evt['calendar_name'] || 'Unknown'}"
1090
+ lines << "Status: #{humanize_status(evt['status'])}" if evt['status']
1091
+ lines << "My status: #{humanize_status(evt['my_status'])}" if evt['my_status']
1092
+
1093
+ desc = clean_description(evt['description'])
1094
+ if desc && !desc.empty?
1095
+ lines << ""
1096
+ lines << desc
1097
+ end
1098
+
1099
+ text = lines.join("\n")
1100
+ IO.popen('xclip -selection clipboard', 'w') { |io| io.write(text) } rescue
1101
+ IO.popen('xsel --clipboard --input', 'w') { |io| io.write(text) } rescue nil
1102
+ show_feedback("Event copied to clipboard", 156)
1103
+ end
1104
+
1105
+ def view_event_popup
1106
+ evt = event_at_selected_slot
1107
+ return show_feedback("No event at this time slot", 245) unless evt
1108
+
1109
+ rows, cols = IO.console.winsize
1110
+ pw = [cols - 10, 80].min
1111
+ pw = [pw, 50].max
1112
+ ph = [rows - 6, 30].min
1113
+ px = (cols - pw) / 2
1114
+ py = (rows - ph) / 2
1115
+
1116
+ popup = Rcurses::Pane.new(px, py, pw, ph, 252, 0)
1117
+ popup.border = true
1118
+ popup.scroll = true
1119
+
1120
+ color = evt['calendar_color'] || 39
1121
+ lines = []
1122
+ lines << ""
1123
+ lines << " #{evt['title'] || '(No title)'}".fg(color).b
1124
+ lines << ""
1125
+
1126
+ if evt['all_day'].to_i == 1
1127
+ lines << " #{"When:".fg(51)} #{@selected_date.strftime('%A, %B %d, %Y')} All day".fg(252)
1128
+ else
1129
+ st = Time.at(evt['start_time'].to_i)
1130
+ time_str = st.strftime('%A, %B %d, %Y %H:%M')
1131
+ time_str += " - #{Time.at(evt['end_time'].to_i).strftime('%H:%M')}" if evt['end_time']
1132
+ lines << " #{"When:".fg(51)} #{time_str}".fg(252)
1133
+ end
1134
+
1135
+ if evt['location'] && !evt['location'].to_s.strip.empty?
1136
+ lines << " #{"Location:".fg(51)} #{evt['location']}".fg(252)
1137
+ end
1138
+
1139
+ if evt['organizer'] && !evt['organizer'].to_s.strip.empty?
1140
+ lines << " #{"Organizer:".fg(51)} #{evt['organizer']}".fg(252)
1141
+ end
1142
+
1143
+ cal_name = evt['calendar_name'] || 'Unknown'
1144
+ lines << " #{"Calendar:".fg(51)} #{cal_name}".fg(252)
1145
+
1146
+ status_parts = []
1147
+ status_parts << "Status: #{evt['status']}" if evt['status']
1148
+ status_parts << "My status: #{humanize_status(evt['my_status'])}" if evt['my_status']
1149
+ lines << " #{status_parts.join(' | ')}".fg(245) unless status_parts.empty?
1150
+
1151
+ # Attendees
1152
+ attendees = evt['attendees']
1153
+ attendees = JSON.parse(attendees) if attendees.is_a?(String)
1154
+ if attendees.is_a?(Array) && attendees.any?
1155
+ lines << ""
1156
+ lines << " #{"Attendees:".fg(51)}"
1157
+ attendees.each do |a|
1158
+ name = a['name'] || a['email'] || a['displayName'] || '?'
1159
+ status = a['status'] || a['responseStatus'] || ''
1160
+ lines << " #{name}".fg(252) + (status.empty? ? "" : " (#{status})".fg(245))
1161
+ end
1162
+ end
1163
+
1164
+ # Description
1165
+ desc = clean_description(evt['description'])
1166
+ if desc && !desc.empty?
1167
+ lines << ""
1168
+ lines << " " + ("-" * [pw - 6, 1].max).fg(238)
1169
+ desc.split("\n").each do |dline|
1170
+ # Word-wrap long lines
1171
+ while dline.length > pw - 6
1172
+ lines << " #{dline[0, pw - 6]}".fg(248)
1173
+ dline = dline[pw - 6..]
1174
+ end
1175
+ lines << " #{dline}".fg(248)
1176
+ end
1177
+ end
1178
+
1179
+ lines << ""
1180
+ lines << " " + "UP/DOWN:scroll ESC/q:close".fg(245)
1181
+
1182
+ popup.text = lines.join("\n")
1183
+ popup.refresh
1184
+
1185
+ loop do
1186
+ k = getchr
1187
+ case k
1188
+ when 'ESC', 'q', 'v'
1189
+ break
1190
+ when 'DOWN', 'j'
1191
+ popup.linedown
1192
+ when 'UP', 'k'
1193
+ popup.lineup
1194
+ when 'PgDOWN'
1195
+ popup.pagedown
1196
+ when 'PgUP'
1197
+ popup.pageup
1198
+ end
1199
+ end
1200
+
1201
+ Rcurses.clear_screen
1202
+ create_panes
1203
+ render_all
1204
+ end
1205
+
1206
+ def accept_invite
1207
+ evt = event_at_selected_slot
1208
+ return show_feedback("No event at this time slot", 245) unless evt
1209
+
1210
+ show_feedback("Accepting '#{evt['title']}'...", 226)
1211
+
1212
+ @db.save_event(
1213
+ id: evt['id'],
1214
+ calendar_id: evt['calendar_id'],
1215
+ external_id: evt['external_id'],
1216
+ title: evt['title'],
1217
+ description: evt['description'],
1218
+ location: evt['location'],
1219
+ start_time: evt['start_time'],
1220
+ end_time: evt['end_time'],
1221
+ all_day: evt['all_day'].to_i == 1,
1222
+ timezone: evt['timezone'],
1223
+ recurrence_rule: evt['recurrence_rule'],
1224
+ status: evt['status'],
1225
+ organizer: evt['organizer'],
1226
+ attendees: evt['attendees'],
1227
+ my_status: 'accepted',
1228
+ alarms: evt['alarms'],
1229
+ metadata: evt['metadata']
1230
+ )
1231
+
1232
+ # Push RSVP to the original calendar source
1233
+ push_rsvp_to_google(evt)
1234
+ push_rsvp_to_outlook(evt)
1235
+
1236
+ load_events_for_range
1237
+ render_all
1238
+ show_feedback("Invite accepted", 156)
1239
+ end
1240
+
1241
+ def push_rsvp_to_google(evt)
1242
+ cal = @db.get_calendars(false).find { |c| c['id'] == evt['calendar_id'] }
1243
+ return unless cal && cal['source_type'] == 'google' && evt['external_id']
1244
+
1245
+ config = cal['source_config']
1246
+ config = JSON.parse(config) if config.is_a?(String)
1247
+ return unless config.is_a?(Hash)
1248
+
1249
+ google = Sources::Google.new(config['email'], safe_dir: config['safe_dir'] || '~/.config/timely/credentials')
1250
+ return unless google.get_access_token
1251
+
1252
+ gcal_id = config['google_calendar_id'] || config['email']
1253
+ # Google handles RSVP via the attendees list; we patch the event
1254
+ google.update_event(gcal_id, evt['external_id'], {
1255
+ title: evt['title'],
1256
+ start_time: evt['start_time'].to_i,
1257
+ end_time: evt['end_time'].to_i,
1258
+ all_day: evt['all_day'].to_i == 1,
1259
+ attendees: evt['attendees']
1260
+ })
1261
+ rescue => e
1262
+ # Silently fail; local status is already updated
1263
+ nil
1264
+ end
1265
+
1266
+ def push_rsvp_to_outlook(evt)
1267
+ cal = @db.get_calendars(false).find { |c| c['id'] == evt['calendar_id'] }
1268
+ return unless cal && cal['source_type'] == 'outlook' && evt['external_id']
1269
+
1270
+ config = cal['source_config']
1271
+ config = JSON.parse(config) if config.is_a?(String)
1272
+ return unless config.is_a?(Hash)
1273
+
1274
+ outlook = Sources::Outlook.new(config)
1275
+ return unless outlook.refresh_access_token
1276
+
1277
+ outlook.respond_to_event(evt['external_id'], 'accepted')
1278
+ rescue => e
1279
+ # Silently fail; local status is already updated
1280
+ nil
1281
+ end
1282
+
1283
+ # --- ICS Import ---
1284
+
1285
+ def import_ics_file
1286
+ blank_bottom(" Import ICS File".b)
1287
+ path = bottom_ask(" File path: ", "")
1288
+ return cancel_create if path.nil? || path.strip.empty?
1289
+
1290
+ path = File.expand_path(path.strip)
1291
+ unless File.exist?(path)
1292
+ show_feedback("File not found: #{path}", 196)
1293
+ return
1294
+ end
1295
+
1296
+ result = Sources::IcsFile.import_file(path, @db)
1297
+ load_events_for_range
1298
+ render_all
1299
+ msg = "Imported #{result[:imported]} event(s)"
1300
+ msg += ", skipped #{result[:skipped]}" if result[:skipped] > 0
1301
+ msg += " (#{result[:error]})" if result[:error]
1302
+ show_feedback(msg, result[:error] ? 196 : 156)
1303
+ end
1304
+
1305
+ # --- Google Calendar ---
1306
+
1307
+ def setup_google_calendar
1308
+ blank_bottom(" Google Calendar Setup".b.fg(39))
1309
+ email = bottom_ask(" Google email: ", "")
1310
+ return cancel_create if email.nil? || email.strip.empty?
1311
+ email = email.strip
1312
+
1313
+ safe_dir = @config.get('google.safe_dir', '~/.config/timely/credentials')
1314
+
1315
+ show_feedback("Connecting to Google Calendar...", 226)
1316
+
1317
+ google = Sources::Google.new(email, safe_dir: safe_dir)
1318
+ token = google.get_access_token
1319
+ unless token
1320
+ err = google.last_error || "Check credentials in #{safe_dir}"
1321
+ show_feedback("Token failed: #{err}", 196)
1322
+ return
1323
+ end
1324
+
1325
+ calendars = google.list_calendars
1326
+ if calendars.empty?
1327
+ err = google.last_error || "No calendars found"
1328
+ show_feedback("#{email}: #{err}", 196)
1329
+ return
1330
+ end
1331
+
1332
+ # Show calendars and let user pick
1333
+ cal_list = calendars.each_with_index.map { |c, i| "#{i + 1}:#{c[:summary]}" }.join(" ")
1334
+ blank_bottom(" Found #{calendars.size} calendar(s)".fg(39).b)
1335
+ pick = bottom_ask(" Add which? (#{cal_list}, 'all', or ESC): ", "all")
1336
+ return cancel_create if pick.nil?
1337
+
1338
+ selected = if pick.strip.downcase == 'all'
1339
+ calendars
1340
+ else
1341
+ indices = pick.strip.split(',').map { |s| s.strip.to_i - 1 }
1342
+ indices.filter_map { |i| calendars[i] if i >= 0 && i < calendars.size }
1343
+ end
1344
+
1345
+ selected.each do |gcal|
1346
+ # Check if already added
1347
+ existing = @db.get_calendars(false).find { |c|
1348
+ config = c['source_config']
1349
+ config = JSON.parse(config) if config.is_a?(String)
1350
+ config.is_a?(Hash) && config['google_calendar_id'] == gcal[:id]
1351
+ }
1352
+ next if existing
1353
+
1354
+ @db.save_calendar(
1355
+ name: gcal[:summary],
1356
+ source_type: 'google',
1357
+ source_config: { 'email' => email, 'safe_dir' => safe_dir, 'google_calendar_id' => gcal[:id] },
1358
+ color: source_color_to_256(gcal[:color]),
1359
+ enabled: true
1360
+ )
1361
+ end
1362
+
1363
+ # Start sync immediately
1364
+ manual_sync
1365
+ show_feedback("Added #{selected.size} Google calendar(s). Syncing...", 156)
1366
+ end
1367
+
1368
+ def source_color_to_256(color)
1369
+ return 39 unless color
1370
+ case color.to_s.downcase
1371
+ when '#7986cb', '#4285f4', 'blue', 'lightblue' then 69
1372
+ when '#33b679', '#0b8043', 'green', 'lightgreen' then 35
1373
+ when '#8e24aa', '#9e69af', 'purple', 'lightpurple', 'grape' then 134
1374
+ when '#e67c73', '#d50000', 'red', 'lightred', 'cranberry' then 167
1375
+ when '#f6bf26', '#f4511e', 'yellow', 'lightyellow', 'pumpkin' then 214
1376
+ when '#039be5', '#4fc3f7', 'teal', 'lightteal' then 37
1377
+ when '#616161', '#a79b8e', 'gray', 'lightgray', 'grey' then 245
1378
+ when 'orange', 'lightorange' then 208
1379
+ when 'pink', 'lightpink' then 205
1380
+ when 'auto' then 39
1381
+ else 39
1382
+ end
1383
+ end
1384
+
1385
+ # --- Outlook Calendar ---
1386
+
1387
+ def setup_outlook_calendar
1388
+ blank_bottom(" Outlook/365 Calendar Setup".b.fg(33))
1389
+
1390
+ # Get client_id (from Azure app registration)
1391
+ default_client_id = @config.get('outlook.client_id', '')
1392
+ client_id = bottom_ask(" Azure App client_id: ", default_client_id)
1393
+ return cancel_create if client_id.nil? || client_id.strip.empty?
1394
+ client_id = client_id.strip
1395
+
1396
+ # Get tenant_id (optional, defaults to 'common')
1397
+ default_tenant = @config.get('outlook.tenant_id', 'common')
1398
+ tenant_id = bottom_ask(" Tenant ID (Enter for '#{default_tenant}'): ", default_tenant)
1399
+ tenant_id = (tenant_id || default_tenant).strip
1400
+ tenant_id = 'common' if tenant_id.empty?
1401
+
1402
+ # Save config for next time
1403
+ @config.set('outlook.client_id', client_id)
1404
+ @config.set('outlook.tenant_id', tenant_id)
1405
+ @config.save
1406
+
1407
+ show_feedback("Starting device code authentication...", 226)
1408
+
1409
+ outlook = Sources::Outlook.new({
1410
+ 'client_id' => client_id,
1411
+ 'tenant_id' => tenant_id
1412
+ })
1413
+
1414
+ auth = outlook.start_device_auth
1415
+ unless auth
1416
+ show_feedback("Device auth failed: #{outlook.last_error}", 196)
1417
+ return
1418
+ end
1419
+
1420
+ # Show user the code and URL
1421
+ user_code = auth['user_code']
1422
+ verify_url = auth['verification_uri'] || 'https://microsoft.com/devicelogin'
1423
+
1424
+ blank_bottom(" Outlook Device Login".b.fg(33))
1425
+ lines = [("-" * @w).fg(238)]
1426
+ lines << ""
1427
+ lines << " Go to: #{verify_url}".fg(51)
1428
+ lines << " Enter code: #{user_code}".fg(226).b
1429
+ lines << ""
1430
+ lines << " Waiting for authorization...".fg(245)
1431
+ while lines.length < @panes[:bottom].h
1432
+ lines << ""
1433
+ end
1434
+ @panes[:bottom].text = lines.join("\n")
1435
+ @panes[:bottom].full_refresh
1436
+
1437
+ # Poll for token in a thread so we can show progress
1438
+ token_result = nil
1439
+ auth_thread = Thread.new do
1440
+ token_result = outlook.poll_for_token(auth['device_code'])
1441
+ end
1442
+
1443
+ # Wait for the auth thread (with a timeout of 5 minutes)
1444
+ auth_thread.join(300)
1445
+
1446
+ unless token_result
1447
+ show_feedback("Auth failed or timed out: #{outlook.last_error}", 196)
1448
+ auth_thread.kill if auth_thread.alive?
1449
+ return
1450
+ end
1451
+
1452
+ show_feedback("Authenticated. Fetching calendars...", 226)
1453
+
1454
+ # List calendars
1455
+ calendars = outlook.list_calendars
1456
+ if calendars.empty?
1457
+ show_feedback("No Outlook calendars found: #{outlook.last_error}", 196)
1458
+ return
1459
+ end
1460
+
1461
+ # Let user pick calendars
1462
+ cal_list = calendars.each_with_index.map { |c, i| "#{i + 1}:#{c[:name]}" }.join(" ")
1463
+ blank_bottom(" Found #{calendars.size} Outlook calendar(s)".fg(33).b)
1464
+ pick = bottom_ask(" Add which? (#{cal_list}, 'all', or ESC): ", "all")
1465
+ return cancel_create if pick.nil?
1466
+
1467
+ selected = if pick.strip.downcase == 'all'
1468
+ calendars
1469
+ else
1470
+ indices = pick.strip.split(',').map { |s| s.strip.to_i - 1 }
1471
+ indices.filter_map { |i| calendars[i] if i >= 0 && i < calendars.size }
1472
+ end
1473
+
1474
+ selected.each do |ocal|
1475
+ # Check if already added
1476
+ existing = @db.get_calendars(false).find { |c|
1477
+ config = c['source_config']
1478
+ config = JSON.parse(config) if config.is_a?(String)
1479
+ config.is_a?(Hash) && config['outlook_calendar_id'] == ocal[:id]
1480
+ }
1481
+ next if existing
1482
+
1483
+ @db.save_calendar(
1484
+ name: ocal[:name],
1485
+ source_type: 'outlook',
1486
+ source_config: {
1487
+ 'client_id' => client_id,
1488
+ 'tenant_id' => tenant_id,
1489
+ 'access_token' => token_result[:access_token],
1490
+ 'refresh_token' => token_result[:refresh_token],
1491
+ 'outlook_calendar_id' => ocal[:id]
1492
+ },
1493
+ color: source_color_to_256(ocal[:color]),
1494
+ enabled: true
1495
+ )
1496
+ end
1497
+
1498
+ manual_sync
1499
+ show_feedback("Added #{selected.size} Outlook calendar(s). Syncing...", 156)
1500
+ end
1501
+
1502
+ def manual_sync
1503
+ google_cals = @db.get_calendars.select { |c| c['source_type'] == 'google' }
1504
+ outlook_cals = @db.get_calendars.select { |c| c['source_type'] == 'outlook' }
1505
+
1506
+ if google_cals.empty? && outlook_cals.empty?
1507
+ show_feedback("No calendars configured. Press G (Google) or O (Outlook) to set up.", 245)
1508
+ return
1509
+ end
1510
+
1511
+ @syncing = true
1512
+ render_status_bar
1513
+
1514
+ Thread.new do
1515
+ begin
1516
+ total = 0
1517
+ errors = []
1518
+ now = Time.now
1519
+ time_min = (now - 30 * 86400).to_i # 30 days back
1520
+ time_max = (now + 60 * 86400).to_i # 60 days forward
1521
+
1522
+ # --- Google sync ---
1523
+ by_email = google_cals.group_by do |cal|
1524
+ config = cal['source_config']
1525
+ config = JSON.parse(config) if config.is_a?(String)
1526
+ config.is_a?(Hash) ? config['email'] : nil
1527
+ end
1528
+
1529
+ by_email.each do |email, cals|
1530
+ next unless email
1531
+ config = cals.first['source_config']
1532
+ config = JSON.parse(config) if config.is_a?(String)
1533
+ google = Sources::Google.new(email, safe_dir: config['safe_dir'] || '~/.config/timely/credentials')
1534
+ unless google.get_access_token
1535
+ errors << "#{email}: #{google.last_error || 'token failed'}"
1536
+ next
1537
+ end
1538
+
1539
+ cals.each do |cal|
1540
+ cfg = cal['source_config']
1541
+ cfg = JSON.parse(cfg) if cfg.is_a?(String)
1542
+ gcal_id = cfg['google_calendar_id'] || email
1543
+
1544
+ events = google.fetch_events(gcal_id, time_min, time_max)
1545
+ unless events
1546
+ errors << "#{cal['name']}: #{google.last_error || 'fetch failed'}"
1547
+ next
1548
+ end
1549
+
1550
+ events.each do |evt|
1551
+ total += 1 if @db.upsert_synced_event(cal['id'], evt) == :new
1552
+ end
1553
+ @db.update_calendar_sync(cal['id'], Time.now.to_i)
1554
+ end
1555
+ end
1556
+
1557
+ # --- Outlook sync ---
1558
+ outlook_cals.each do |cal|
1559
+ cfg = cal['source_config']
1560
+ cfg = JSON.parse(cfg) if cfg.is_a?(String)
1561
+ next unless cfg.is_a?(Hash)
1562
+
1563
+ outlook = Sources::Outlook.new(cfg)
1564
+ unless outlook.refresh_access_token
1565
+ errors << "#{cal['name']}: #{outlook.last_error || 'token failed'}"
1566
+ next
1567
+ end
1568
+
1569
+ events = outlook.fetch_events(time_min, time_max)
1570
+ unless events
1571
+ errors << "#{cal['name']}: #{outlook.last_error || 'fetch failed'}"
1572
+ next
1573
+ end
1574
+
1575
+ events.each do |evt|
1576
+ total += 1 if @db.upsert_synced_event(cal['id'], evt) == :new
1577
+ end
1578
+
1579
+ # Persist refreshed tokens
1580
+ new_config = cfg.merge(
1581
+ 'access_token' => outlook.instance_variable_get(:@access_token),
1582
+ 'refresh_token' => outlook.instance_variable_get(:@refresh_token)
1583
+ )
1584
+ @db.update_calendar_sync(cal['id'], Time.now.to_i, new_config)
1585
+ end
1586
+
1587
+ # Signal UI to refresh
1588
+ load_events_for_range
1589
+ if errors.any?
1590
+ show_feedback("Sync: #{total} new. Errors: #{errors.first}", 196)
1591
+ else
1592
+ show_feedback("Sync complete. #{total} new event(s).", 156)
1593
+ end
1594
+ @syncing = false
1595
+ render_all
1596
+ rescue => e
1597
+ @syncing = false
1598
+ File.open('/tmp/timely-sync.log', 'a') { |f| f.puts "#{Time.now} Sync thread error: #{e.message}\n#{e.backtrace.first(5).join("\n")}" }
1599
+ show_feedback("Sync error: #{e.message}", 196)
1600
+ end
1601
+ end
1602
+ end
1603
+
1604
+ # --- Feedback ---
1605
+
1606
+ def show_feedback(message, color = 156)
1607
+ lines = [("-" * @w).fg(238), " #{message}".fg(color)]
1608
+ while lines.length < @panes[:bottom].h
1609
+ lines << ""
1610
+ end
1611
+ @panes[:bottom].text = lines.join("\n")
1612
+ @panes[:bottom].full_refresh
1613
+ end
1614
+
1615
+ # --- Preferences ---
1616
+
1617
+ def pick_color(current = 39)
1618
+ rows, cols = IO.console.winsize
1619
+ # 16 columns x 16 rows = 256 colors, plus border and labels
1620
+ pw = 52 # 16 * 3 + 4
1621
+ ph = 20 # 16 rows + header + footer + borders
1622
+ px = (cols - pw) / 2
1623
+ py = (rows - ph) / 2
1624
+
1625
+ popup = Rcurses::Pane.new(px, py, pw, ph, 252, 0)
1626
+ popup.border = true
1627
+ popup.scroll = false
1628
+
1629
+ sel = current.to_i.clamp(0, 255)
1630
+
1631
+ build = -> {
1632
+ popup.full_refresh
1633
+ lines = []
1634
+ lines << ""
1635
+ lines << " " + "Pick Color".b + " current: " + "\u2588\u2588".fg(sel) + " #{sel}"
1636
+ lines << ""
1637
+ 16.times do |row|
1638
+ line = " "
1639
+ 16.times do |col|
1640
+ c = row * 16 + col
1641
+ if c == sel
1642
+ line += "X ".bg(c).fg(255).b
1643
+ else
1644
+ line += " ".bg(c)
1645
+ end
1646
+ line += " "
1647
+ end
1648
+ lines << line
1649
+ end
1650
+ lines << ""
1651
+ lines << " " + "Arrows:move ENTER:select ESC:cancel".fg(245)
1652
+ popup.text = lines.join("\n")
1653
+ popup.ix = 0
1654
+ popup.refresh
1655
+ }
1656
+
1657
+ build.call
1658
+
1659
+ result = nil
1660
+ loop do
1661
+ k = getchr
1662
+ case k
1663
+ when 'ESC', 'q'
1664
+ break
1665
+ when 'ENTER'
1666
+ result = sel
1667
+ break
1668
+ when 'RIGHT', 'l'
1669
+ sel = (sel + 1) % 256
1670
+ build.call
1671
+ when 'LEFT', 'h'
1672
+ sel = (sel - 1) % 256
1673
+ build.call
1674
+ when 'DOWN', 'j'
1675
+ sel = (sel + 16) % 256
1676
+ build.call
1677
+ when 'UP', 'k'
1678
+ sel = (sel - 16) % 256
1679
+ build.call
1680
+ end
1681
+ end
1682
+
1683
+ # Clear picker overlay
1684
+ Rcurses.clear_screen
1685
+ create_panes
1686
+ render_all
1687
+ result
1688
+ end
1689
+
1690
+ def show_calendars
1691
+ rows, cols = IO.console.winsize
1692
+ pw = [cols - 16, 64].min
1693
+ pw = [pw, 50].max
1694
+
1695
+ calendars = @db.get_calendars(false)
1696
+ return show_feedback("No calendars configured", 245) if calendars.empty?
1697
+
1698
+ ph = [calendars.size + 7, rows - 6].min
1699
+ px = (cols - pw) / 2
1700
+ py = (rows - ph) / 2
1701
+
1702
+ popup = Rcurses::Pane.new(px, py, pw, ph, 252, 0)
1703
+ popup.border = true
1704
+ popup.scroll = false
1705
+
1706
+ sel = 0
1707
+
1708
+ build = -> {
1709
+ popup.full_refresh
1710
+ lines = []
1711
+ lines << ""
1712
+ lines << " " + "Calendars".b
1713
+ lines << " " + ("-" * [pw - 6, 1].max).fg(238)
1714
+
1715
+ calendars.each_with_index do |cal, i|
1716
+ enabled = cal['enabled'].to_i == 1
1717
+ color = cal['color'] || 39
1718
+ swatch = "\u2588\u2588".fg(color)
1719
+ status = enabled ? "on".fg(35) : "off".fg(196)
1720
+ src = cal['source_type'] || 'local'
1721
+ name = cal['name'] || '(unnamed)'
1722
+ display = " #{swatch} %-22s %s [%s]" % [name[0..21], status, src]
1723
+ lines << (i == sel ? display.fg(39).b : display)
1724
+ end
1725
+
1726
+ lines << ""
1727
+ lines << " " + "j/k:nav c:color ENTER:toggle x:remove q:close".fg(245)
1728
+ popup.text = lines.join("\n")
1729
+ popup.ix = 0
1730
+ popup.refresh
1731
+ }
1732
+
1733
+ build.call
1734
+
1735
+ loop do
1736
+ k = getchr
1737
+ case k
1738
+ when 'ESC', 'q'
1739
+ break
1740
+ when 'k', 'UP'
1741
+ sel = (sel - 1) % calendars.size
1742
+ build.call
1743
+ when 'j', 'DOWN'
1744
+ sel = (sel + 1) % calendars.size
1745
+ build.call
1746
+ when 'c'
1747
+ cal = calendars[sel]
1748
+ new_color = pick_color(cal['color'] || 39)
1749
+ if new_color
1750
+ @db.update_calendar_color(cal['id'], new_color)
1751
+ cal['color'] = new_color
1752
+ end
1753
+ build.call
1754
+ when 'ENTER'
1755
+ cal = calendars[sel]
1756
+ @db.toggle_calendar_enabled(cal['id'])
1757
+ cal['enabled'] = cal['enabled'].to_i == 1 ? 0 : 1
1758
+ build.call
1759
+ when 'x'
1760
+ cal = calendars[sel]
1761
+ confirm = popup.ask(" Remove '#{cal['name']}'? (y/n): ", "")
1762
+ if confirm&.strip&.downcase == 'y'
1763
+ @db.delete_calendar_with_events(cal['id'])
1764
+ calendars.delete_at(sel)
1765
+ sel = [sel, calendars.size - 1].min
1766
+ break if calendars.empty?
1767
+ end
1768
+ build.call
1769
+ end
1770
+ end
1771
+
1772
+ Rcurses.clear_screen
1773
+ create_panes
1774
+ load_events_for_range
1775
+ render_all
1776
+ end
1777
+
1778
+ def show_preferences
1779
+ rows, cols = IO.console.winsize
1780
+ pw = [cols - 20, 56].min
1781
+ pw = [pw, 48].max
1782
+ ph = 19
1783
+ px = (cols - pw) / 2
1784
+ py = (rows - ph) / 2
1785
+
1786
+ popup = Rcurses::Pane.new(px, py, pw, ph, 252, 0)
1787
+ popup.border = true
1788
+ popup.scroll = false
1789
+
1790
+ pref_keys = [
1791
+ ['colors.selected_bg_a', 'Sel. alt bg A', 235],
1792
+ ['colors.selected_bg_b', 'Sel. alt bg B', 234],
1793
+ ['colors.alt_bg_a', 'Row alt bg A', 233],
1794
+ ['colors.alt_bg_b', 'Row alt bg B', 0],
1795
+ ['colors.current_month_bg','Current month bg', 233],
1796
+ ['colors.saturday', 'Saturday color', 208],
1797
+ ['colors.sunday', 'Sunday color', 167],
1798
+ ['colors.today_fg', 'Today fg', 232],
1799
+ ['colors.today_bg', 'Today bg', 246],
1800
+ ['colors.slot_selected_bg','Slot selected bg', 237],
1801
+ ['colors.info_bg', 'Info bar bg', 235],
1802
+ ['colors.status_bg', 'Status bar bg', 235],
1803
+ ['work_hours.start', 'Work hours start', 8],
1804
+ ['work_hours.end', 'Work hours end', 17],
1805
+ ['default_calendar', 'Default calendar', 1]
1806
+ ]
1807
+
1808
+ sel = 0
1809
+
1810
+ is_color = ->(key) { key.start_with?('colors.') }
1811
+
1812
+ build_popup = -> {
1813
+ popup.full_refresh
1814
+ inner_w = pw - 4
1815
+ lines = []
1816
+ lines << ""
1817
+ lines << " " + "Preferences".b
1818
+ lines << " " + ("\u2500" * [inner_w - 3, 1].max).fg(238)
1819
+
1820
+ pref_keys.each_with_index do |(key, label, default), i|
1821
+ val = @config.get(key, default)
1822
+ if is_color.call(key)
1823
+ swatch = key.include?('bg') ? " ".bg(val.to_i) : "\u2588\u2588".fg(val.to_i)
1824
+ val_str = val.to_s.rjust(3)
1825
+ display = " %-18s %s %s" % [label, val_str, swatch]
1826
+ elsif key == 'default_calendar'
1827
+ cal = @db.get_calendars(false).find { |c| c['id'] == val.to_i }
1828
+ cal_name = cal ? " (#{cal['name']})" : ""
1829
+ display = " %-18s %s%s" % [label, val.to_s, cal_name]
1830
+ else
1831
+ display = " %-18s %s" % [label, val.to_s]
1832
+ end
1833
+ if i == sel
1834
+ lines << display.fg(39).b
1835
+ else
1836
+ lines << display
1837
+ end
1838
+ end
1839
+
1840
+ lines << ""
1841
+ if is_color.call(pref_keys[sel][0])
1842
+ lines << " " + "j/k:navigate h/l:adjust H/L:x10 ENTER:type q:close".fg(245)
1843
+ else
1844
+ lines << " " + "j/k:navigate ENTER:edit q/ESC:close".fg(245)
1845
+ end
1846
+
1847
+ popup.text = lines.join("\n")
1848
+ popup.ix = 0
1849
+ popup.refresh
1850
+ }
1851
+
1852
+ build_popup.call
1853
+
1854
+ loop do
1855
+ k = getchr
1856
+ case k
1857
+ when 'ESC', 'q'
1858
+ break
1859
+ when 'k', 'UP'
1860
+ sel = (sel - 1) % pref_keys.length
1861
+ build_popup.call
1862
+ when 'j', 'DOWN'
1863
+ sel = (sel + 1) % pref_keys.length
1864
+ build_popup.call
1865
+ when 'h', 'LEFT', 'l', 'RIGHT', 'H', 'L'
1866
+ key, label, default = pref_keys[sel]
1867
+ if is_color.call(key)
1868
+ delta = case k
1869
+ when 'h', 'LEFT' then -1
1870
+ when 'l', 'RIGHT' then 1
1871
+ when 'H' then -10
1872
+ when 'L' then 10
1873
+ end
1874
+ val = (@config.get(key, default).to_i + delta).clamp(0, 255)
1875
+ @config.set(key, val)
1876
+ @config.save
1877
+ build_popup.call
1878
+ end
1879
+ when 'ENTER'
1880
+ key, label, default = pref_keys[sel]
1881
+ current = @config.get(key, default)
1882
+ if is_color.call(key)
1883
+ new_color = pick_color(current.to_i)
1884
+ if new_color
1885
+ @config.set(key, new_color)
1886
+ @config.save
1887
+ end
1888
+ else
1889
+ result = popup.ask("#{label}: ", current.to_s)
1890
+ if result && !result.strip.empty?
1891
+ val = result.strip
1892
+ val = val.to_i if current.is_a?(Integer)
1893
+ @config.set(key, val)
1894
+ @config.save
1895
+ end
1896
+ end
1897
+ build_popup.call
1898
+ end
1899
+ end
1900
+
1901
+ # Re-create panes to apply bar color changes
1902
+ Rcurses.clear_screen
1903
+ create_panes
1904
+ render_all
1905
+ end
1906
+
1907
+ # --- Help ---
1908
+
1909
+ def show_help
1910
+ rows, cols = IO.console.winsize
1911
+ pw = [cols - 16, 68].min
1912
+ pw = [pw, 56].max
1913
+ ph = 24
1914
+ px = (cols - pw) / 2
1915
+ py = (rows - ph) / 2
1916
+
1917
+ popup = Rcurses::Pane.new(px, py, pw, ph, 252, 0)
1918
+ popup.border = true
1919
+ popup.scroll = false
1920
+
1921
+ k = ->(s) { s.fg(51) } # key color
1922
+ d = ->(s) { s.fg(252) } # description color
1923
+ sep = " " + ("-" * [pw - 6, 1].max).fg(238)
1924
+
1925
+ help = []
1926
+ help << ""
1927
+ help << " " + "Timely - Terminal Calendar".b.fg(156)
1928
+ help << sep
1929
+ help << " " + "Navigation".b.fg(156)
1930
+ help << " #{k['d/RIGHT']} #{d['Next day']} #{k['D/LEFT']} #{d['Prev day']}"
1931
+ help << " #{k['w']} #{d['Next week']} #{k['W']} #{d['Prev week']}"
1932
+ help << " #{k['m']} #{d['Next month']} #{k['M']} #{d['Prev month']}"
1933
+ help << " #{k['y']} #{d['Next year']} #{k['Y']} #{d['Prev year']}"
1934
+ help << " #{k['UP/DOWN']} #{d['Select time slot (scrolls at edges)']}"
1935
+ help << " #{k['PgUp/Dn']} #{d['Jump 10 slots']} #{k['HOME']} #{d['Top/all-day']}"
1936
+ help << " #{k['END']} #{d['Bottom (23:30)']} #{k['j/k']} #{d['Cycle events']}"
1937
+ help << " #{k['e/E']} #{d['Jump to event (next/prev)']}"
1938
+ help << " #{k['t']} #{d['Today']} #{k['g']} #{d['Go to (date, Mon, yyyy)']}"
1939
+ help << sep
1940
+ help << " " + "Events".b.fg(156)
1941
+ help << " #{k['n']} #{d['New event']} #{k['ENTER']} #{d['Edit event']}"
1942
+ help << " #{k['x/DEL']} #{d['Delete event']} #{k['a']} #{d['Accept invite']}"
1943
+ help << " #{k['v']} #{d['View event details (scrollable popup)']}"
1944
+ help << " #{k['r']} #{d['Reply via Heathrow']}"
1945
+ help << sep
1946
+ help << " #{k['i']} #{d['Import ICS']} #{k['G']} #{d['Google setup']} #{k['O']} #{d['Outlook setup']}"
1947
+ help << " #{k['S']} #{d['Sync now']} #{k['C']} #{d['Calendars']} #{k['P']} #{d['Preferences']}"
1948
+ help << " #{k['q']} #{d['Quit']}"
1949
+ help << ""
1950
+ help << " " + "Press any key to close...".fg(245)
1951
+
1952
+ popup.text = help.join("\n")
1953
+ popup.refresh
1954
+ getchr
1955
+ Rcurses.clear_screen
1956
+ create_panes
1957
+ render_all
1958
+ end
1959
+ end
1960
+ end