timely-calendar 1.0.0 → 1.0.2

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 6828a89cb80defec08b9eadaa1e03cf98e45e90d01efe6770423be86f14d8692
4
- data.tar.gz: c0857481b8a83267cd5289111e333a07d62c5cebf59a7d509e4a0ddceac80346
3
+ metadata.gz: a336a4e6f30f78ab53e5da5005c8a95114d0dc6657d2c5323101615d3cfa4015
4
+ data.tar.gz: fc5783786223c1d421832cb0cf46599192fb0f06386fae3c6a7cfc0050aeedf6
5
5
  SHA512:
6
- metadata.gz: c62100cb41044387038866e90e8061061be47c1c3e79c77771d60880133ea63312f325cc66084dddb905594f6951535b9cef98383e10386882836a63ff68a76e
7
- data.tar.gz: ce4f9e135b9c64afe3c6a195a8da0f2f3dd688a523051e31dd42af0764181f17710504d805ead890271b44c9660227cab6fe1986c98535fabcf0d4d879fcfc40
6
+ metadata.gz: 3a62d684e33d3f597748b142e18e0d0c5dc579d4bdb11c3d143efe4fec76129d982d5b47a4944b89e7466a72be92a680a8d10feac54539b1220a23048cc34cf4
7
+ data.tar.gz: f2613ecb9a86d8febc5cd8de611c42b371b54440ee6a6c5b7c2c4bffabaf29854be4795d7ba9aa601f982fee6179962956096e567e0a27ba200d67254e3be1cf
data/img/timely-kb.svg ADDED
@@ -0,0 +1,282 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1500 560" width="1500" height="560" font-family="monospace, Courier New, Courier">
2
+ <defs>
3
+ <style>
4
+ .key { fill: #333; stroke: #555; stroke-width: 1; rx: 5; ry: 5; }
5
+ .kw { fill: #2a2a2a; stroke: #555; stroke-width: 1; rx: 5; ry: 5; }
6
+ .kl { font-size: 12px; fill: #bbb; font-weight: bold; text-anchor: middle; }
7
+ .lo { font-size: 8.5px; fill: #FF8888; text-anchor: middle; }
8
+ .up { font-size: 8.5px; fill: #88BBFF; text-anchor: middle; }
9
+ .ct { font-size: 10px; fill: #88FF88; }
10
+ .sp { font-size: 8.5px; fill: #DDAAFF; text-anchor: middle; }
11
+ .nv { font-size: 8.5px; fill: #bbb; text-anchor: middle; }
12
+ </style>
13
+ </defs>
14
+ <rect width="1500" height="560" fill="#1a1a1a" rx="8"/>
15
+ <text x="470" y="22" text-anchor="middle" font-size="15" font-weight="bold" fill="#fff">Timely Keyboard Reference</text>
16
+
17
+ <!-- Row 0: Esc -->
18
+ <rect x="10" y="34" width="58" height="55" class="key"/>
19
+ <text x="39" y="54" class="kl">Esc</text>
20
+ <text x="39" y="80" class="sp">close popup</text>
21
+
22
+ <!-- Row 1: Number row kw=62 gap=4 -->
23
+ <rect x="10" y="97" width="58" height="55" class="key"/>
24
+ <text x="39" y="127" class="kl">` ~</text>
25
+
26
+ <rect x="72" y="97" width="62" height="55" class="key"/>
27
+ <text x="103" y="127" class="kl">1</text>
28
+
29
+ <rect x="138" y="97" width="62" height="55" class="key"/>
30
+ <text x="169" y="127" class="kl">2</text>
31
+
32
+ <rect x="204" y="97" width="62" height="55" class="key"/>
33
+ <text x="235" y="127" class="kl">3</text>
34
+
35
+ <rect x="270" y="97" width="62" height="55" class="key"/>
36
+ <text x="301" y="127" class="kl">4</text>
37
+
38
+ <rect x="336" y="97" width="62" height="55" class="key"/>
39
+ <text x="367" y="127" class="kl">5</text>
40
+
41
+ <rect x="402" y="97" width="62" height="55" class="key"/>
42
+ <text x="433" y="127" class="kl">6</text>
43
+
44
+ <rect x="468" y="97" width="62" height="55" class="key"/>
45
+ <text x="499" y="127" class="kl">7</text>
46
+
47
+ <rect x="534" y="97" width="62" height="55" class="key"/>
48
+ <text x="565" y="127" class="kl">8</text>
49
+
50
+ <rect x="600" y="97" width="62" height="55" class="key"/>
51
+ <text x="631" y="127" class="kl">9</text>
52
+
53
+ <rect x="666" y="97" width="62" height="55" class="key"/>
54
+ <text x="697" y="127" class="kl">0</text>
55
+
56
+ <rect x="732" y="97" width="62" height="55" class="key"/>
57
+ <text x="763" y="127" class="kl">- _</text>
58
+
59
+ <rect x="798" y="97" width="62" height="55" class="key"/>
60
+ <text x="829" y="127" class="kl">= +</text>
61
+
62
+ <rect x="864" y="97" width="76" height="55" class="kw"/>
63
+ <text x="902" y="130" class="nv">Bksp</text>
64
+
65
+ <!-- Row 2: QWERTY start x=10+80=90 offset, kw=62 -->
66
+ <rect x="10" y="160" width="76" height="55" class="kw"/>
67
+ <text x="48" y="193" class="nv">Tab</text>
68
+
69
+ <rect x="90" y="160" width="62" height="55" class="key"/>
70
+ <text x="121" y="176" class="kl">Q</text>
71
+ <text x="121" y="207" class="lo">q quit</text>
72
+
73
+ <rect x="156" y="160" width="62" height="55" class="key"/>
74
+ <text x="187" y="176" class="kl">W</text>
75
+ <text x="187" y="194" class="up">W Week-1</text>
76
+ <text x="187" y="207" class="lo">w Week+1</text>
77
+
78
+ <rect x="222" y="160" width="62" height="55" class="key"/>
79
+ <text x="253" y="176" class="kl">E</text>
80
+ <text x="253" y="194" class="up">E Prev evt</text>
81
+ <text x="253" y="207" class="lo">e Next evt</text>
82
+
83
+ <rect x="288" y="160" width="62" height="55" class="key"/>
84
+ <text x="319" y="176" class="kl">R</text>
85
+ <text x="319" y="207" class="lo">r reply</text>
86
+
87
+ <rect x="354" y="160" width="62" height="55" class="key"/>
88
+ <text x="385" y="176" class="kl">T</text>
89
+ <text x="385" y="207" class="lo">t today</text>
90
+
91
+ <rect x="420" y="160" width="62" height="55" class="key"/>
92
+ <text x="451" y="176" class="kl">Y</text>
93
+ <text x="451" y="194" class="up">Y Year-1</text>
94
+ <text x="451" y="207" class="lo">y Year+1</text>
95
+
96
+ <rect x="486" y="160" width="62" height="55" class="key"/>
97
+ <text x="517" y="193" class="kl">U</text>
98
+
99
+ <rect x="552" y="160" width="62" height="55" class="key"/>
100
+ <text x="583" y="176" class="kl">I</text>
101
+ <text x="583" y="207" class="lo">i import ICS</text>
102
+
103
+ <rect x="618" y="160" width="62" height="55" class="key"/>
104
+ <text x="649" y="176" class="kl">O</text>
105
+ <text x="649" y="194" class="up">O Outlook</text>
106
+
107
+ <rect x="684" y="160" width="62" height="55" class="key"/>
108
+ <text x="715" y="176" class="kl">P</text>
109
+ <text x="715" y="194" class="up">P Prefs</text>
110
+
111
+ <rect x="750" y="160" width="62" height="55" class="key"/>
112
+ <text x="781" y="193" class="kl">Å</text>
113
+
114
+ <rect x="816" y="160" width="62" height="55" class="key"/>
115
+ <text x="847" y="193" class="kl">^</text>
116
+
117
+ <rect x="882" y="160" width="58" height="55" class="key"/>
118
+ <text x="911" y="193" class="kl">\</text>
119
+
120
+ <!-- Row 3: ASDF start x=10+90=100 offset -->
121
+ <rect x="10" y="223" width="86" height="55" class="kw"/>
122
+ <text x="53" y="255" class="nv">Ctrl</text>
123
+
124
+ <rect x="100" y="223" width="62" height="55" class="key"/>
125
+ <text x="131" y="239" class="kl">A</text>
126
+ <text x="131" y="270" class="lo">a accept</text>
127
+
128
+ <rect x="166" y="223" width="62" height="55" class="key"/>
129
+ <text x="197" y="239" class="kl">S</text>
130
+ <text x="197" y="257" class="up">S Sync</text>
131
+
132
+ <rect x="232" y="223" width="62" height="55" class="key"/>
133
+ <text x="263" y="239" class="kl">D</text>
134
+ <text x="263" y="257" class="up">D Day-1</text>
135
+ <text x="263" y="270" class="lo">d Day+1</text>
136
+
137
+ <rect x="298" y="223" width="62" height="55" class="key"/>
138
+ <text x="329" y="255" class="kl">F</text>
139
+
140
+ <rect x="364" y="223" width="62" height="55" class="key"/>
141
+ <text x="395" y="239" class="kl">G</text>
142
+ <text x="395" y="257" class="up">G Google</text>
143
+ <text x="395" y="270" class="lo">g go to date</text>
144
+
145
+ <rect x="430" y="223" width="62" height="55" class="key"/>
146
+ <text x="461" y="239" class="kl">H</text>
147
+ <text x="461" y="270" class="nv">h = Day-1</text>
148
+
149
+ <rect x="496" y="223" width="62" height="55" class="key"/>
150
+ <text x="527" y="239" class="kl">J</text>
151
+ <text x="527" y="270" class="lo">j next e/d</text>
152
+
153
+ <rect x="562" y="223" width="62" height="55" class="key"/>
154
+ <text x="593" y="239" class="kl">K</text>
155
+ <text x="593" y="270" class="lo">k prev e/d</text>
156
+
157
+ <rect x="628" y="223" width="62" height="55" class="key"/>
158
+ <text x="659" y="239" class="kl">L</text>
159
+ <text x="659" y="270" class="nv">l = Day+1</text>
160
+
161
+ <rect x="694" y="223" width="62" height="55" class="key"/>
162
+ <text x="725" y="255" class="kl">Ø</text>
163
+
164
+ <rect x="760" y="223" width="62" height="55" class="key"/>
165
+ <text x="791" y="255" class="kl">Æ</text>
166
+
167
+ <rect x="826" y="223" width="114" height="55" class="kw"/>
168
+ <text x="883" y="248" class="nv">Enter</text>
169
+ <text x="883" y="268" class="sp">edit event</text>
170
+
171
+ <!-- Row 4: ZXCV start x=10+100=110 offset -->
172
+ <rect x="10" y="286" width="96" height="55" class="kw"/>
173
+ <text x="58" y="318" class="nv">Shift</text>
174
+
175
+ <rect x="110" y="286" width="62" height="55" class="key"/>
176
+ <text x="141" y="315" class="kl">Z</text>
177
+
178
+ <rect x="176" y="286" width="62" height="55" class="key"/>
179
+ <text x="207" y="302" class="kl">X</text>
180
+ <text x="207" y="333" class="sp">x del event</text>
181
+
182
+ <rect x="242" y="286" width="62" height="55" class="key"/>
183
+ <text x="273" y="302" class="kl">C</text>
184
+ <text x="273" y="320" class="up">C Calendars</text>
185
+
186
+ <rect x="308" y="286" width="62" height="55" class="key"/>
187
+ <text x="339" y="302" class="kl">V</text>
188
+ <text x="339" y="333" class="lo">v view popup</text>
189
+
190
+ <rect x="374" y="286" width="62" height="55" class="key"/>
191
+ <text x="405" y="315" class="kl">B</text>
192
+
193
+ <rect x="440" y="286" width="62" height="55" class="key"/>
194
+ <text x="471" y="302" class="kl">N</text>
195
+ <text x="471" y="333" class="lo">n new event</text>
196
+
197
+ <rect x="506" y="286" width="62" height="55" class="key"/>
198
+ <text x="537" y="302" class="kl">M</text>
199
+ <text x="537" y="320" class="up">M Month-1</text>
200
+ <text x="537" y="333" class="lo">m Month+1</text>
201
+
202
+ <rect x="572" y="286" width="62" height="55" class="key"/>
203
+ <text x="603" y="315" class="kl">, &lt;</text>
204
+
205
+ <rect x="638" y="286" width="62" height="55" class="key"/>
206
+ <text x="669" y="315" class="kl">. &gt;</text>
207
+
208
+ <rect x="704" y="286" width="62" height="55" class="key"/>
209
+ <text x="735" y="302" class="kl">/ ?</text>
210
+ <text x="735" y="333" class="sp">? help</text>
211
+
212
+ <rect x="770" y="286" width="170" height="55" class="kw"/>
213
+ <text x="855" y="318" class="nv">Shift</text>
214
+
215
+ <!-- Row 5: Bottom -->
216
+ <rect x="10" y="349" width="70" height="50" class="kw"/>
217
+ <text x="45" y="378" class="nv">Ctrl</text>
218
+
219
+ <rect x="84" y="349" width="58" height="50" class="kw"/>
220
+ <text x="113" y="378" class="nv">Alt</text>
221
+
222
+ <rect x="146" y="349" width="330" height="50" class="key"/>
223
+ <text x="311" y="378" class="nv">Space</text>
224
+
225
+ <rect x="480" y="349" width="62" height="50" class="key"/>
226
+ <text x="511" y="378" class="kl"># @</text>
227
+
228
+ <!-- Nav cluster -->
229
+ <rect x="990" y="97" width="64" height="55" class="key"/>
230
+ <text x="1022" y="120" class="nv">Home</text>
231
+ <text x="1022" y="143" class="nv">top slot</text>
232
+
233
+ <rect x="1058" y="97" width="64" height="55" class="key"/>
234
+ <text x="1090" y="120" class="nv">End</text>
235
+ <text x="1090" y="143" class="nv">btm slot</text>
236
+
237
+ <rect x="990" y="160" width="64" height="55" class="key"/>
238
+ <text x="1022" y="183" class="nv">PgUp</text>
239
+ <text x="1022" y="206" class="nv">page slots</text>
240
+
241
+ <rect x="1058" y="160" width="64" height="55" class="key"/>
242
+ <text x="1090" y="183" class="nv">PgDn</text>
243
+ <text x="1090" y="206" class="nv">page slots</text>
244
+
245
+ <rect x="990" y="223" width="64" height="55" class="key"/>
246
+ <text x="1022" y="248" class="nv">Del</text>
247
+ <text x="1022" y="268" class="sp">del event</text>
248
+
249
+ <rect x="1022" y="286" width="60" height="55" class="key"/>
250
+ <text x="1052" y="310" class="nv">UP</text>
251
+ <text x="1052" y="333" class="nv">prev slot</text>
252
+
253
+ <rect x="958" y="349" width="60" height="50" class="key"/>
254
+ <text x="988" y="370" class="nv">LEFT</text>
255
+ <text x="988" y="390" class="nv">prev day</text>
256
+
257
+ <rect x="1022" y="349" width="60" height="50" class="key"/>
258
+ <text x="1052" y="370" class="nv">DOWN</text>
259
+ <text x="1052" y="390" class="nv">next slot</text>
260
+
261
+ <rect x="1086" y="349" width="60" height="50" class="key"/>
262
+ <text x="1116" y="370" class="nv">RIGHT</text>
263
+ <text x="1116" y="390" class="nv">next day</text>
264
+
265
+ <!-- Right panel: Ctrl combos -->
266
+ <rect x="1180" y="34" width="300" height="200" rx="6" fill="#1a2a1a" stroke="#4a4" stroke-width="1"/>
267
+ <text x="1330" y="56" text-anchor="middle" font-size="13" font-weight="bold" fill="#66FF66">Ctrl Combos</text>
268
+ <text x="1194" y="80" class="ct">C-R Refresh all calendars</text>
269
+ <text x="1194" y="105" class="ct">C-L Redraw screen</text>
270
+ <text x="1194" y="130" class="ct">C-Y Copy event to clipboard</text>
271
+
272
+ <!-- Legend -->
273
+ <rect x="20" y="470" width="14" height="14" fill="#3a2222" rx="2" stroke="#FF6666" stroke-width="0.5"/>
274
+ <text x="40" y="482" font-size="10" fill="#FF6666">lowercase</text>
275
+ <rect x="120" y="470" width="14" height="14" fill="#22223a" rx="2" stroke="#6699FF" stroke-width="0.5"/>
276
+ <text x="140" y="482" font-size="10" fill="#6699FF">UPPERCASE</text>
277
+ <rect x="240" y="470" width="14" height="14" fill="#223a22" rx="2" stroke="#66FF66" stroke-width="0.5"/>
278
+ <text x="260" y="482" font-size="10" fill="#66FF66">Ctrl+key</text>
279
+ <rect x="350" y="470" width="14" height="14" fill="#2a223a" rx="2" stroke="#CC99FF" stroke-width="0.5"/>
280
+ <text x="370" y="482" font-size="10" fill="#CC99FF">special</text>
281
+ <text x="460" y="482" font-size="10" fill="#888">gray = navigation</text>
282
+ </svg>
@@ -211,13 +211,15 @@ module Timely
211
211
  body
212
212
  end
213
213
 
214
- def api_get(path)
214
+ def api_get(path, retries: 2)
215
215
  token = get_access_token
216
216
  return nil unless token
217
- uri = URI("#{API_BASE}#{path}")
217
+ full_url = path.start_with?('http') ? path : "#{API_BASE}#{path}"
218
+ uri = URI(full_url)
218
219
  req = Net::HTTP::Get.new(uri)
219
220
  req['Authorization'] = "Bearer #{token}"
220
- res = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true, read_timeout: 30, open_timeout: 10) { |http| http.request(req) }
221
+ req['Accept-Encoding'] = 'identity' # Disable chunked/gzip (rcurses raw mode breaks chunked reads)
222
+ res = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true, read_timeout: 60, open_timeout: 15) { |http| http.request(req) }
221
223
  if res.is_a?(Net::HTTPSuccess)
222
224
  JSON.parse(res.body)
223
225
  else
@@ -225,6 +227,10 @@ module Timely
225
227
  nil
226
228
  end
227
229
  rescue Timeout::Error, Net::OpenTimeout, SocketError, Errno::ECONNREFUSED => e
230
+ if retries > 0
231
+ sleep 1
232
+ retry if (retries -= 1) >= 0
233
+ end
228
234
  @last_error = "Network error: #{e.message}"
229
235
  nil
230
236
  rescue => e
@@ -239,8 +245,9 @@ module Timely
239
245
  req = Net::HTTP::Post.new(uri)
240
246
  req['Authorization'] = "Bearer #{token}"
241
247
  req['Content-Type'] = 'application/json'
248
+ req['Accept-Encoding'] = 'identity'
242
249
  req.body = JSON.generate(body)
243
- res = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true, read_timeout: 30, open_timeout: 10) { |http| http.request(req) }
250
+ res = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true, read_timeout: 60, open_timeout: 15) { |http| http.request(req) }
244
251
  res.is_a?(Net::HTTPSuccess) ? JSON.parse(res.body) : nil
245
252
  rescue => e
246
253
  nil
@@ -253,8 +260,9 @@ module Timely
253
260
  req = Net::HTTP::Put.new(uri)
254
261
  req['Authorization'] = "Bearer #{token}"
255
262
  req['Content-Type'] = 'application/json'
263
+ req['Accept-Encoding'] = 'identity'
256
264
  req.body = JSON.generate(body)
257
- res = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true, read_timeout: 30, open_timeout: 10) { |http| http.request(req) }
265
+ res = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true, read_timeout: 60, open_timeout: 15) { |http| http.request(req) }
258
266
  res.is_a?(Net::HTTPSuccess) ? JSON.parse(res.body) : nil
259
267
  rescue => e
260
268
  nil
@@ -266,7 +274,7 @@ module Timely
266
274
  uri = URI("#{API_BASE}#{path}")
267
275
  req = Net::HTTP::Delete.new(uri)
268
276
  req['Authorization'] = "Bearer #{token}"
269
- res = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true, read_timeout: 30, open_timeout: 10) { |http| http.request(req) }
277
+ res = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true, read_timeout: 60, open_timeout: 15) { |http| http.request(req) }
270
278
  res.is_a?(Net::HTTPSuccess) || res.code == '204'
271
279
  rescue => e
272
280
  false
@@ -227,6 +227,7 @@ module Timely
227
227
  uri = URI(full_url)
228
228
  req = Net::HTTP::Get.new(uri)
229
229
  req['Authorization'] = "Bearer #{token}"
230
+ req['Accept-Encoding'] = 'identity'
230
231
  res = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true, read_timeout: 30, open_timeout: 10) { |http| http.request(req) }
231
232
  if res.is_a?(Net::HTTPSuccess)
232
233
  JSON.parse(res.body)
@@ -248,6 +249,7 @@ module Timely
248
249
  uri = URI("#{GRAPH_BASE}#{path}")
249
250
  req = Net::HTTP::Post.new(uri)
250
251
  req['Authorization'] = "Bearer #{token}"
252
+ req['Accept-Encoding'] = 'identity'
251
253
  req['Content-Type'] = 'application/json'
252
254
  req.body = JSON.generate(body)
253
255
  res = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true, read_timeout: 30, open_timeout: 10) { |http| http.request(req) }
@@ -262,6 +264,7 @@ module Timely
262
264
  uri = URI("#{GRAPH_BASE}#{path}")
263
265
  req = Net::HTTP::Patch.new(uri)
264
266
  req['Authorization'] = "Bearer #{token}"
267
+ req['Accept-Encoding'] = 'identity'
265
268
  req['Content-Type'] = 'application/json'
266
269
  req.body = JSON.generate(body)
267
270
  res = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true, read_timeout: 30, open_timeout: 10) { |http| http.request(req) }
@@ -276,6 +279,7 @@ module Timely
276
279
  uri = URI("#{GRAPH_BASE}#{path}")
277
280
  req = Net::HTTP::Delete.new(uri)
278
281
  req['Authorization'] = "Bearer #{token}"
282
+ req['Accept-Encoding'] = 'identity'
279
283
  res = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true, read_timeout: 30, open_timeout: 10) { |http| http.request(req) }
280
284
  res.is_a?(Net::HTTPSuccess) || res.code == '204'
281
285
  rescue => e
@@ -123,6 +123,9 @@ module Timely
123
123
  when 't'
124
124
  @selected_date = Date.today
125
125
  @selected_event_index = 0
126
+ now = Time.now
127
+ @selected_slot = now.hour * 2 + (now.min >= 30 ? 1 : 0)
128
+ @slot_offset = [@selected_slot - 5, 0].max
126
129
  date_changed
127
130
  when 'g'
128
131
  go_to_date
@@ -150,6 +153,17 @@ module Timely
150
153
  manual_sync
151
154
  when 'C'
152
155
  show_calendars
156
+ when 'C-R'
157
+ @_cached_planets_date = nil # Force planet recalculation
158
+ @weather_forecast = nil # Force weather refresh
159
+ @_weather_fetched_at = nil
160
+ @db.execute("DELETE FROM weather_cache") rescue nil # Clear DB cache too
161
+ load_events_for_range
162
+ render_all
163
+ when 'C-L'
164
+ Rcurses.clear_screen
165
+ create_panes
166
+ render_all
153
167
  when 'P'
154
168
  show_preferences
155
169
  when '?'
@@ -267,6 +281,19 @@ module Timely
267
281
  def date_changed
268
282
  @selected_event_index = 0
269
283
  load_events_for_range
284
+ # If slot is in all-day area but no event there, jump to first timed event or current time
285
+ if @selected_slot && @selected_slot < 0 && event_at_selected_slot.nil?
286
+ events = events_on_selected_day
287
+ first_timed = events.find { |e| e['all_day'].to_i != 1 }
288
+ if first_timed
289
+ t = Time.at(first_timed['start_time'].to_i)
290
+ @selected_slot = t.hour * 2 + (t.min >= 30 ? 1 : 0)
291
+ else
292
+ now = Time.now
293
+ @selected_slot = now.hour * 2 + (now.min >= 30 ? 1 : 0)
294
+ end
295
+ @slot_offset = [@selected_slot - 5, 0].max
296
+ end
270
297
  render_all
271
298
  end
272
299
 
@@ -298,8 +325,28 @@ module Timely
298
325
 
299
326
  def clean_description(desc)
300
327
  return nil unless desc
301
- # Remove common garbage from Google/Outlook descriptions
302
- desc.gsub(/BC\d+-Color:\s*-?\d+\s*/, '').gsub(/-::~:~::~:~.*$/m, '').strip
328
+ desc = desc.to_s
329
+ # Convert HTML to plain text via w3m if it looks like HTML
330
+ if desc =~ /\A\s*<(!DOCTYPE|html|head|body|div|p\b)/i
331
+ begin
332
+ text = IO.popen(['w3m', '-T', 'text/html', '-dump', '-cols', '200'], 'r+') do |io|
333
+ io.write(desc)
334
+ io.close_write
335
+ io.read
336
+ end
337
+ desc = text if text && !text.strip.empty?
338
+ rescue Errno::ENOENT
339
+ # w3m not installed, strip tags manually
340
+ desc = desc.gsub(/<[^>]+>/, ' ').gsub(/&nbsp;/i, ' ').gsub(/&amp;/i, '&').gsub(/&lt;/i, '<').gsub(/&gt;/i, '>')
341
+ end
342
+ end
343
+ # Remove common garbage from Google/Outlook/Teams descriptions
344
+ desc.gsub(/BC\d+-Color:\s*-?\d+\s*/, '') # Google color metadata
345
+ .gsub(/-::~:~::~:~.*$/m, '') # Google Meet block
346
+ .gsub(/_{3,}/, '') # Long underscore lines (Teams HR)
347
+ .gsub(/-{5,}/, '') # Long dash lines
348
+ .gsub(/\n{3,}/, "\n\n") # Collapse excessive blank lines
349
+ .strip
303
350
  end
304
351
 
305
352
  # Find the event at the currently selected time slot
@@ -308,11 +355,18 @@ module Timely
308
355
  events = events_on_selected_day
309
356
 
310
357
  if @selected_slot < 0
311
- # Negative slot: -n = top (first allday), -1 = bottom (last allday)
358
+ # Negative slot uses max_allday across the visible week (same as render_mid_pane)
359
+ week_start = @selected_date - (@selected_date.cwday - 1)
360
+ max_allday = 0
361
+ 7.times do |i|
362
+ day = week_start + i
363
+ n = (@events_by_date[day] || []).count { |e| e['all_day'].to_i == 1 }
364
+ max_allday = n if n > max_allday
365
+ end
312
366
  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]
367
+ idx = max_allday - @selected_slot.abs # -max -> 0 (first), -1 -> max-1 (last)
368
+ return allday[idx] if idx >= 0 && idx < allday.size
369
+ return nil
316
370
  end
317
371
 
318
372
  hour = @selected_slot / 2
@@ -348,6 +402,7 @@ module Timely
348
402
  # If there are more events on the current day after the selected one, go to next
349
403
  if events.length > 0 && @selected_event_index < events.length - 1
350
404
  @selected_event_index += 1
405
+ move_slot_to_event(events[@selected_event_index], events)
351
406
  render_mid_pane
352
407
  render_bottom_pane
353
408
  return
@@ -360,7 +415,10 @@ module Timely
360
415
  if day_events && !day_events.empty?
361
416
  @selected_date = check_date
362
417
  @selected_event_index = 0
363
- date_changed
418
+ load_events_for_range
419
+ events = events_on_selected_day
420
+ move_slot_to_event(events.first, events) if events.any?
421
+ render_all
364
422
  return
365
423
  end
366
424
  end
@@ -373,6 +431,7 @@ module Timely
373
431
  # If there are more events on the current day before the selected one, go to prev
374
432
  if events.length > 0 && @selected_event_index > 0
375
433
  @selected_event_index -= 1
434
+ move_slot_to_event(events[@selected_event_index], events)
376
435
  render_mid_pane
377
436
  render_bottom_pane
378
437
  return
@@ -384,8 +443,11 @@ module Timely
384
443
  day_events = @db.get_events_for_date(check_date)
385
444
  if day_events && !day_events.empty?
386
445
  @selected_date = check_date
387
- @selected_event_index = day_events.length - 1
388
- date_changed
446
+ load_events_for_range
447
+ events = events_on_selected_day
448
+ @selected_event_index = [events.length - 1, 0].max
449
+ move_slot_to_event(events.last, events) if events.any?
450
+ render_all
389
451
  return
390
452
  end
391
453
  end
@@ -393,6 +455,27 @@ module Timely
393
455
  show_feedback("No earlier events found within the past year", 245)
394
456
  end
395
457
 
458
+ def move_slot_to_event(evt, day_events)
459
+ return unless evt
460
+ if evt['all_day'].to_i == 1
461
+ # Compute max_allday across the visible week (same as render_mid_pane)
462
+ week_start = @selected_date - (@selected_date.cwday - 1)
463
+ max_allday = 0
464
+ 7.times do |i|
465
+ day = week_start + i
466
+ n = (@events_by_date[day] || []).count { |e| e['all_day'].to_i == 1 }
467
+ max_allday = n if n > max_allday
468
+ end
469
+ allday = day_events.select { |e| e['all_day'].to_i == 1 }
470
+ idx = allday.index { |e| e['id'] == evt['id'] } || 0
471
+ @selected_slot = -(max_allday - idx)
472
+ else
473
+ t = Time.at(evt['start_time'].to_i)
474
+ @selected_slot = t.hour * 2 + (t.min >= 30 ? 1 : 0)
475
+ @slot_offset = [@selected_slot - 5, 0].max
476
+ end
477
+ end
478
+
396
479
  # --- Data loading ---
397
480
 
398
481
  def load_events_for_range
@@ -824,7 +907,16 @@ module Timely
824
907
  astro.each { |evt| lines << " #{evt}".fg(180) } if astro.any?
825
908
 
826
909
  lines << ""
827
- lines << " No events scheduled".fg(240)
910
+ if events.any?
911
+ allday = events.count { |e| e['all_day'].to_i == 1 }
912
+ timed = events.size - allday
913
+ parts = []
914
+ parts << "#{timed} timed" if timed > 0
915
+ parts << "#{allday} all-day" if allday > 0
916
+ lines << " #{parts.join(', ')} event#{'s' if events.size > 1} today".fg(240)
917
+ else
918
+ lines << " No events scheduled".fg(240)
919
+ end
828
920
  end
829
921
 
830
922
  # Pad to fill pane
@@ -839,6 +931,7 @@ module Timely
839
931
  # --- Actions ---
840
932
 
841
933
  def go_to_date
934
+ blank_bottom("")
842
935
  input = bottom_ask("Go to: ", "")
843
936
  return if input.nil? || input.strip.empty?
844
937
 
@@ -1097,8 +1190,8 @@ module Timely
1097
1190
  end
1098
1191
 
1099
1192
  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
1193
+ IO.popen('xclip -selection clipboard', 'w') { |io| io.write(text) } rescue nil
1194
+ IO.popen('xclip -selection primary', 'w') { |io| io.write(text) } rescue nil
1102
1195
  show_feedback("Event copied to clipboard", 156)
1103
1196
  end
1104
1197
 
@@ -1177,7 +1270,7 @@ module Timely
1177
1270
  end
1178
1271
 
1179
1272
  lines << ""
1180
- lines << " " + "UP/DOWN:scroll ESC/q:close".fg(245)
1273
+ lines << " " + "UP/DOWN:scroll C-Y:copy ESC/q:close".fg(245)
1181
1274
 
1182
1275
  popup.text = lines.join("\n")
1183
1276
  popup.refresh
@@ -1195,6 +1288,13 @@ module Timely
1195
1288
  popup.pagedown
1196
1289
  when 'PgUP'
1197
1290
  popup.pageup
1291
+ when 'C-Y'
1292
+ clean = lines.map { |l| l.respond_to?(:pure) ? l.pure : l.gsub(/\e\[[0-9;]*m/, '') }.join("\n")
1293
+ IO.popen('xclip -selection clipboard', 'w') { |io| io.write(clean) } rescue nil
1294
+ IO.popen('xclip -selection primary', 'w') { |io| io.write(clean) } rescue nil
1295
+ lines[-1] = " " + "Copied to clipboard".fg(156)
1296
+ popup.text = lines.join("\n")
1297
+ popup.refresh
1198
1298
  end
1199
1299
  end
1200
1300
 
@@ -14,9 +14,9 @@ module Timely
14
14
  def create_panes
15
15
  @panes = {}
16
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
17
+ # Layout: info(1) + top(months, fixed 10) + mid(week, flexible) + bottom(details) + status(1)
18
+ # Top pane: 1 blank row + 8 month rows + 1 padding row = 10
19
+ top_h = 10
20
20
  bottom_h = (@h * 0.2).to_i
21
21
  bottom_h = [bottom_h, 5].max
22
22
  mid_h = @h - 2 - top_h - bottom_h # 2 = info + status
@@ -1,3 +1,3 @@
1
1
  module Timely
2
- VERSION = '1.0.0'
2
+ VERSION = '1.0.2'
3
3
  end
@@ -31,6 +31,7 @@ module Timely
31
31
  uri = URI("https://api.met.no/weatherapi/locationforecast/2.0/complete?lat=#{lat}&lon=#{lon}")
32
32
  req = Net::HTTP::Get.new(uri)
33
33
  req['User-Agent'] = 'timely-calendar/0.1 g@isene.com'
34
+ req['Accept-Encoding'] = 'identity'
34
35
 
35
36
  res = Net::HTTP.start(uri.hostname, uri.port,
36
37
  use_ssl: true,
@@ -91,7 +92,7 @@ module Timely
91
92
  if db
92
93
  db.execute(
93
94
  "INSERT OR REPLACE INTO weather_cache (date, hour, data, fetched_at) VALUES (?, ?, ?, ?)",
94
- 'forecast', '00', JSON.generate(forecast), Time.now.to_i
95
+ ['forecast', '00', JSON.generate(forecast), Time.now.to_i]
95
96
  )
96
97
  end
97
98
 
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: timely-calendar
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.0.0
4
+ version: 1.0.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - Geir Isene
@@ -9,7 +9,7 @@ authors:
9
9
  autorequire:
10
10
  bindir: bin
11
11
  cert_chain: []
12
- date: 2026-03-23 00:00:00.000000000 Z
12
+ date: 2026-03-25 00:00:00.000000000 Z
13
13
  dependencies:
14
14
  - !ruby/object:Gem::Dependency
15
15
  name: rcurses
@@ -53,6 +53,7 @@ files:
53
53
  - CLAUDE.md
54
54
  - README.md
55
55
  - bin/timely
56
+ - img/timely-kb.svg
56
57
  - img/timely.svg
57
58
  - lib/timely.rb
58
59
  - lib/timely/astronomy.rb