id-calendar-tui-clariant 0.1.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 1e14fda49a87e3fd1897c0c50c1881d9c553d80f236c4088f4b545b320fa7622
4
+ data.tar.gz: 50c43991b6a0180eb526caeabf8b879dd5b6628fde6ee9f542555b2e146e121c
5
+ SHA512:
6
+ metadata.gz: 6c03f07c54d43aa7f5b2e590012a9c843da1c90682984c12b29991a21f1a154de9c51abc7729eae8274d960d69c3601456a17b6d39ac3ba5aebaa2dda923e0c7
7
+ data.tar.gz: a1be0fa468e16d463c335eac7921e49a297acaaf3566da6924de4092daf2f470634ac2446a9c55dda7297f4a177ca7c55e9e1c14ae9a01637e44ebab1342875f
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Adi Purnama
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,152 @@
1
+ # ID Calendar TUI
2
+
3
+ [![Gem Version](https://badge.fury.io/rb/id-calendar-tui-clariant.svg)](https://badge.fury.io/rb/id-calendar-tui-clariant)
4
+ [![Docker Image](https://img.shields.io/badge/GHCR-latest-blue?logo=docker)](https://github.com/clariantb/id-calendar-tui/pkgs/container/id-calendar-tui)
5
+
6
+ A terminal-based calendar application for Indonesian public holidays with vim-like navigation.
7
+
8
+ Fork of [adiprnm/id-calendar-tui](https://github.com/adiprnm/id-calendar-tui) that
9
+ takes holiday data from the official SKB 3 Menteri announcements and releases it
10
+ automatically, published as the `id-calendar-tui-clariant` gem.
11
+
12
+ ![Calendar Screenshot](screenshot.png)
13
+
14
+ ## Features
15
+
16
+ - Full calendar view with Indonesian public holidays
17
+ - Vim-like navigation (h/j/k/l)
18
+ - Navigate months with arrow keys
19
+ - Jump to today with `g`
20
+ - Color-coded holidays (National, Religious, Cultural)
21
+ - Clean, centered TUI interface
22
+
23
+ ## Installation
24
+
25
+ ### Via RubyGems
26
+
27
+ ```bash
28
+ gem install id-calendar-tui-clariant
29
+ calendar
30
+ ```
31
+
32
+ ### Via Docker (GHCR)
33
+
34
+ ```bash
35
+ docker run --rm -it ghcr.io/clariantb/id-calendar-tui:latest
36
+ ```
37
+
38
+ ### From Source
39
+
40
+ ```bash
41
+ git clone https://github.com/clariantb/id-calendar-tui.git
42
+ cd id-calendar-tui
43
+ bundle install
44
+ bundle exec ruby bin/calendar
45
+ ```
46
+
47
+ ## Usage
48
+
49
+ ```bash
50
+ # Run the calendar
51
+ calendar
52
+
53
+ # Or with bundle
54
+ bundle exec calendar
55
+ ```
56
+
57
+ ## Navigation
58
+
59
+ | Key | Action |
60
+ |-----|--------|
61
+ | `h`, `←` | Previous month |
62
+ | `l`, `→` | Next month |
63
+ | `j`, `k` | Previous/Next year |
64
+ | `g` | Go to today |
65
+ | `q`, `Ctrl+C` | Quit |
66
+
67
+ ## Holidays Included
68
+
69
+ - **National Holidays**: New Year, Independence Day, etc.
70
+ - **Islamic Holidays**: Eid al-Fitr, Eid al-Adha, etc.
71
+ - **Hindu Holidays**: Nyepi (Day of Silence)
72
+ - **Chinese New Year**: Imlek, Cap Go Meh
73
+ - **Christian Holidays**: Good Friday, Easter, Christmas
74
+
75
+ ### Holiday Data
76
+
77
+ Holiday dates (libur nasional and *cuti bersama*) live in `data/holidays.json` and
78
+ come from the government's official SKB 3 Menteri announcement as published by
79
+ Setneg/Setkab. There is no official machine-readable source, so
80
+ `scripts/fetch_holidays.rb` parses the press release and accepts it only if it
81
+ matches the article's own stated totals ("sebanyak N hari") and the weekday printed
82
+ beside every date.
83
+
84
+ A weekly GitHub Action (`.github/workflows/update-holidays.yml`) imports next
85
+ year's list as soon as it is announced (usually September–October) and commits it.
86
+ The action fails, so GitHub notifies the repository owner, when Google's public
87
+ Indonesian holiday calendar shows that a decree is out but the import found
88
+ nothing, or that a holiday was added by an amendment (*SKB perubahan*). To fix:
89
+
90
+ ```bash
91
+ # Announcement published under a new URL
92
+ ruby scripts/fetch_holidays.rb --year 2028 --url https://www.setneg.go.id/baca/index/...
93
+ ```
94
+
95
+ Amendments are added by hand to that year's `holidays` in `data/holidays.json`,
96
+ with the announcement URL in `sources`; `--year`/`--url` refuses to re-import an
97
+ existing year unless given `--force`, which would drop them. Scheduled runs never
98
+ overwrite a year that is already imported. Years that have not been announced
99
+ show *"belum diumumkan"* instead of guessed dates.
100
+
101
+ The app reads only this file (no network at runtime), so the same workflow also
102
+ releases: whenever `data/holidays.json` differs from the latest `v*` tag (a new
103
+ import or a pushed amendment), it bumps the patch version, adds a CHANGELOG
104
+ entry, tags, and dispatches `release.yml`, which validates the data and publishes
105
+ the gem and Docker image.
106
+
107
+ ## Requirements
108
+
109
+ - Ruby 2.6+
110
+ - Terminal with color support
111
+
112
+ ## Development
113
+
114
+ ```bash
115
+ # Install dependencies
116
+ bundle install
117
+
118
+ # Run the application
119
+ bundle exec ruby bin/calendar
120
+
121
+ # Build gem
122
+ bundle exec gem build id-calendar-tui.gemspec
123
+
124
+ # Build Docker image
125
+ docker build -t id-calendar-tui .
126
+ ```
127
+
128
+ ## Releasing
129
+
130
+ Holiday data releases are automatic (see [Holiday Data](#holiday-data)). For a
131
+ code release:
132
+
133
+ ```bash
134
+ # Update version in id-calendar-tui.gemspec and CHANGELOG.md
135
+ git add .
136
+ git commit -m "Bump version to x.x.x"
137
+ git tag vx.x.x
138
+ git push origin vx.x.x
139
+ ```
140
+
141
+ Either way `release.yml` publishes to
142
+ [RubyGems](https://rubygems.org/gems/id-calendar-tui-clariant) and
143
+ [GHCR](https://github.com/clariantb/id-calendar-tui/pkgs/container/id-calendar-tui).
144
+ One-time setup is in [RELEASING.md](RELEASING.md).
145
+
146
+ ## Contributing
147
+
148
+ Bug reports and pull requests are welcome.
149
+
150
+ ## License
151
+
152
+ MIT License - see [LICENSE](LICENSE) file for details.
data/bin/calendar ADDED
@@ -0,0 +1,5 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ require_relative '../lib/calendar_app'
4
+
5
+ IndonesiaCalendar::CalendarApp.new.run
@@ -0,0 +1,422 @@
1
+ {
2
+ "_comment": "Official SKB 3 Menteri holidays, imported by scripts/fetch_holidays.rb. Scheduled runs only add missing years; hand-added amendments are kept.",
3
+ "years": {
4
+ "2025": {
5
+ "sources": [
6
+ "https://setkab.go.id/pemerintah-tetapkan-hari-libur-nasional-dan-cuti-bersama-tahun-2025/",
7
+ "https://www.setneg.go.id/baca/index/sambut_hut_ke_80_kemerdekaan_ri_pemerintah_tetapkan_18_agustus_2025_sebagai_cuti_bersama"
8
+ ],
9
+ "holidays": [
10
+ {
11
+ "date": "2025-01-01",
12
+ "name": "Tahun Baru 2025 Masehi",
13
+ "type": "national"
14
+ },
15
+ {
16
+ "date": "2025-01-27",
17
+ "name": "Isra Mikraj Nabi Muhammad saw.",
18
+ "type": "religious"
19
+ },
20
+ {
21
+ "date": "2025-01-28",
22
+ "name": "Cuti Bersama Tahun Baru Imlek 2576 Kongzili",
23
+ "type": "cultural"
24
+ },
25
+ {
26
+ "date": "2025-01-29",
27
+ "name": "Tahun Baru Imlek 2576 Kongzili",
28
+ "type": "cultural"
29
+ },
30
+ {
31
+ "date": "2025-03-28",
32
+ "name": "Cuti Bersama Hari Suci Nyepi (Tahun Baru Saka 1947)",
33
+ "type": "religious"
34
+ },
35
+ {
36
+ "date": "2025-03-29",
37
+ "name": "Hari Suci Nyepi (Tahun Baru Saka 1947)",
38
+ "type": "religious"
39
+ },
40
+ {
41
+ "date": "2025-03-31",
42
+ "name": "Idulfitri 1446 Hijriah",
43
+ "type": "religious"
44
+ },
45
+ {
46
+ "date": "2025-04-01",
47
+ "name": "Idulfitri 1446 Hijriah",
48
+ "type": "religious"
49
+ },
50
+ {
51
+ "date": "2025-04-02",
52
+ "name": "Cuti Bersama Idulfitri 1446 Hijriah",
53
+ "type": "religious"
54
+ },
55
+ {
56
+ "date": "2025-04-03",
57
+ "name": "Cuti Bersama Idulfitri 1446 Hijriah",
58
+ "type": "religious"
59
+ },
60
+ {
61
+ "date": "2025-04-04",
62
+ "name": "Cuti Bersama Idulfitri 1446 Hijriah",
63
+ "type": "religious"
64
+ },
65
+ {
66
+ "date": "2025-04-07",
67
+ "name": "Cuti Bersama Idulfitri 1446 Hijriah",
68
+ "type": "religious"
69
+ },
70
+ {
71
+ "date": "2025-04-18",
72
+ "name": "Wafat Yesus Kristus",
73
+ "type": "religious"
74
+ },
75
+ {
76
+ "date": "2025-04-20",
77
+ "name": "Kebangkitan Yesus Kristus (Paskah)",
78
+ "type": "religious"
79
+ },
80
+ {
81
+ "date": "2025-05-01",
82
+ "name": "Hari Buruh Internasional",
83
+ "type": "national"
84
+ },
85
+ {
86
+ "date": "2025-05-12",
87
+ "name": "Hari Raya Waisak 2569 BE",
88
+ "type": "religious"
89
+ },
90
+ {
91
+ "date": "2025-05-13",
92
+ "name": "Cuti Bersama Hari Raya Waisak 2569 BE",
93
+ "type": "religious"
94
+ },
95
+ {
96
+ "date": "2025-05-29",
97
+ "name": "Kenaikan Yesus Kristus",
98
+ "type": "religious"
99
+ },
100
+ {
101
+ "date": "2025-05-30",
102
+ "name": "Cuti Bersama Kenaikan Yesus Kristus",
103
+ "type": "religious"
104
+ },
105
+ {
106
+ "date": "2025-06-01",
107
+ "name": "Hari Lahir Pancasila",
108
+ "type": "national"
109
+ },
110
+ {
111
+ "date": "2025-06-06",
112
+ "name": "Iduladha 1446 Hijriah",
113
+ "type": "religious"
114
+ },
115
+ {
116
+ "date": "2025-06-09",
117
+ "name": "Cuti Bersama Idul Adha 1446 Hijriah",
118
+ "type": "religious"
119
+ },
120
+ {
121
+ "date": "2025-06-27",
122
+ "name": "1 Muharam Tahun Baru Islam 1447 Hijriah",
123
+ "type": "religious"
124
+ },
125
+ {
126
+ "date": "2025-08-17",
127
+ "name": "Proklamasi Kemerdekaan",
128
+ "type": "national"
129
+ },
130
+ {
131
+ "date": "2025-08-18",
132
+ "name": "Cuti Bersama Proklamasi Kemerdekaan",
133
+ "type": "national"
134
+ },
135
+ {
136
+ "date": "2025-09-05",
137
+ "name": "Maulid Nabi Muhammad saw.",
138
+ "type": "religious"
139
+ },
140
+ {
141
+ "date": "2025-12-25",
142
+ "name": "Kelahiran Yesus Kristus",
143
+ "type": "religious"
144
+ },
145
+ {
146
+ "date": "2025-12-26",
147
+ "name": "Cuti Bersama Kelahiran Yesus Kristus",
148
+ "type": "religious"
149
+ }
150
+ ]
151
+ },
152
+ "2026": {
153
+ "sources": [
154
+ "https://www.setneg.go.id/baca/index/inilah_skb_3_menteri_libur_nasional_dan_cuti_bersama_2026"
155
+ ],
156
+ "holidays": [
157
+ {
158
+ "date": "2026-01-01",
159
+ "name": "Tahun Baru 2026 Masehi",
160
+ "type": "national"
161
+ },
162
+ {
163
+ "date": "2026-01-16",
164
+ "name": "Isra Mikraj Nabi Muhammad saw.",
165
+ "type": "religious"
166
+ },
167
+ {
168
+ "date": "2026-02-16",
169
+ "name": "Cuti Bersama Tahun Baru Imlek 2577 Kongzili",
170
+ "type": "cultural"
171
+ },
172
+ {
173
+ "date": "2026-02-17",
174
+ "name": "Tahun Baru Imlek 2577 Kongzili",
175
+ "type": "cultural"
176
+ },
177
+ {
178
+ "date": "2026-03-18",
179
+ "name": "Cuti Bersama Hari Suci Nyepi (Tahun Baru Saka 1948)",
180
+ "type": "religious"
181
+ },
182
+ {
183
+ "date": "2026-03-19",
184
+ "name": "Hari Suci Nyepi (Tahun Baru Saka 1948)",
185
+ "type": "religious"
186
+ },
187
+ {
188
+ "date": "2026-03-20",
189
+ "name": "Cuti Bersama Idulfitri 1447 H",
190
+ "type": "religious"
191
+ },
192
+ {
193
+ "date": "2026-03-21",
194
+ "name": "Idulfitri 1447 H",
195
+ "type": "religious"
196
+ },
197
+ {
198
+ "date": "2026-03-22",
199
+ "name": "Idulfitri 1447 H",
200
+ "type": "religious"
201
+ },
202
+ {
203
+ "date": "2026-03-23",
204
+ "name": "Cuti Bersama Idulfitri 1447 H",
205
+ "type": "religious"
206
+ },
207
+ {
208
+ "date": "2026-03-24",
209
+ "name": "Cuti Bersama Idulfitri 1447 H",
210
+ "type": "religious"
211
+ },
212
+ {
213
+ "date": "2026-04-03",
214
+ "name": "Wafat Yesus Kristus",
215
+ "type": "religious"
216
+ },
217
+ {
218
+ "date": "2026-04-05",
219
+ "name": "Kebangkitan Yesus Kristus (Paskah)",
220
+ "type": "religious"
221
+ },
222
+ {
223
+ "date": "2026-05-01",
224
+ "name": "Hari Buruh Internasional",
225
+ "type": "national"
226
+ },
227
+ {
228
+ "date": "2026-05-14",
229
+ "name": "Kenaikan Yesus Kristus",
230
+ "type": "religious"
231
+ },
232
+ {
233
+ "date": "2026-05-15",
234
+ "name": "Cuti Bersama Kenaikan Yesus Kristus",
235
+ "type": "religious"
236
+ },
237
+ {
238
+ "date": "2026-05-27",
239
+ "name": "Iduladha 1447 H",
240
+ "type": "religious"
241
+ },
242
+ {
243
+ "date": "2026-05-28",
244
+ "name": "Cuti Bersama Iduladha 1447 H",
245
+ "type": "religious"
246
+ },
247
+ {
248
+ "date": "2026-05-31",
249
+ "name": "Hari Raya Waisak 2570 BE",
250
+ "type": "religious"
251
+ },
252
+ {
253
+ "date": "2026-06-01",
254
+ "name": "Hari Lahir Pancasila",
255
+ "type": "national"
256
+ },
257
+ {
258
+ "date": "2026-06-16",
259
+ "name": "1 Muharam Tahun Baru Islam 1448 H",
260
+ "type": "religious"
261
+ },
262
+ {
263
+ "date": "2026-08-17",
264
+ "name": "Proklamasi Kemerdekaan",
265
+ "type": "national"
266
+ },
267
+ {
268
+ "date": "2026-08-25",
269
+ "name": "Maulid Nabi Muhammad saw.",
270
+ "type": "religious"
271
+ },
272
+ {
273
+ "date": "2026-12-24",
274
+ "name": "Cuti Bersama Kelahiran Yesus Kristus",
275
+ "type": "religious"
276
+ },
277
+ {
278
+ "date": "2026-12-25",
279
+ "name": "Kelahiran Yesus Kristus",
280
+ "type": "religious"
281
+ }
282
+ ]
283
+ },
284
+ "2027": {
285
+ "sources": [
286
+ "https://www.setneg.go.id/baca/index/inilah_skb_3_menteri_libur_nasional_dan_cuti_bersama_2027"
287
+ ],
288
+ "holidays": [
289
+ {
290
+ "date": "2027-01-01",
291
+ "name": "Tahun Baru 2027 Masehi",
292
+ "type": "national"
293
+ },
294
+ {
295
+ "date": "2027-01-05",
296
+ "name": "Isra Mikraj Nabi Muhammad saw.",
297
+ "type": "religious"
298
+ },
299
+ {
300
+ "date": "2027-02-05",
301
+ "name": "Cuti Bersama Tahun Baru Imlek 2578 Kongzili",
302
+ "type": "cultural"
303
+ },
304
+ {
305
+ "date": "2027-02-06",
306
+ "name": "Tahun Baru Imlek 2578 Kongzili",
307
+ "type": "cultural"
308
+ },
309
+ {
310
+ "date": "2027-03-08",
311
+ "name": "Hari Suci Nyepi (Tahun Baru Saka 1949)",
312
+ "type": "religious"
313
+ },
314
+ {
315
+ "date": "2027-03-09",
316
+ "name": "Cuti Bersama Hari Raya Idulfitri 1448 Hijriah",
317
+ "type": "religious"
318
+ },
319
+ {
320
+ "date": "2027-03-10",
321
+ "name": "Idulfitri 1448 Hijriah",
322
+ "type": "religious"
323
+ },
324
+ {
325
+ "date": "2027-03-11",
326
+ "name": "Idulfitri 1448 Hijriah",
327
+ "type": "religious"
328
+ },
329
+ {
330
+ "date": "2027-03-12",
331
+ "name": "Cuti Bersama Hari Raya Idulfitri 1448 Hijriah",
332
+ "type": "religious"
333
+ },
334
+ {
335
+ "date": "2027-03-15",
336
+ "name": "Cuti Bersama Hari Raya Idulfitri 1448 Hijriah",
337
+ "type": "religious"
338
+ },
339
+ {
340
+ "date": "2027-03-25",
341
+ "name": "Cuti Bersama Wafat Yesus Kristus",
342
+ "type": "religious"
343
+ },
344
+ {
345
+ "date": "2027-03-26",
346
+ "name": "Wafat Yesus Kristus",
347
+ "type": "religious"
348
+ },
349
+ {
350
+ "date": "2027-03-28",
351
+ "name": "Hari Kebangkitan Yesus Kristus (Paskah)",
352
+ "type": "religious"
353
+ },
354
+ {
355
+ "date": "2027-05-01",
356
+ "name": "Hari Buruh Internasional",
357
+ "type": "national"
358
+ },
359
+ {
360
+ "date": "2027-05-06",
361
+ "name": "Kenaikan Yesus Kristus",
362
+ "type": "religious"
363
+ },
364
+ {
365
+ "date": "2027-05-17",
366
+ "name": "Iduladha 1448 Hijriah",
367
+ "type": "religious"
368
+ },
369
+ {
370
+ "date": "2027-05-18",
371
+ "name": "Cuti Bersama Iduladha 1448 H",
372
+ "type": "religious"
373
+ },
374
+ {
375
+ "date": "2027-05-19",
376
+ "name": "Cuti Bersama Waisak 2571 BE",
377
+ "type": "religious"
378
+ },
379
+ {
380
+ "date": "2027-05-20",
381
+ "name": "Hari Raya Waisak 2571 BE",
382
+ "type": "religious"
383
+ },
384
+ {
385
+ "date": "2027-06-01",
386
+ "name": "Hari Lahir Pancasila",
387
+ "type": "national"
388
+ },
389
+ {
390
+ "date": "2027-06-06",
391
+ "name": "1 Muharam Tahun Baru Islam 1449 Hijriah",
392
+ "type": "religious"
393
+ },
394
+ {
395
+ "date": "2027-08-15",
396
+ "name": "Maulid Nabi Muhammad SAW",
397
+ "type": "religious"
398
+ },
399
+ {
400
+ "date": "2027-08-17",
401
+ "name": "Proklamasi Kemerdekaan",
402
+ "type": "national"
403
+ },
404
+ {
405
+ "date": "2027-12-24",
406
+ "name": "Cuti Bersama Kelahiran Yesus Kristus (Natal)",
407
+ "type": "religious"
408
+ },
409
+ {
410
+ "date": "2027-12-25",
411
+ "name": "Kelahiran Yesus Kristus (Natal)",
412
+ "type": "religious"
413
+ },
414
+ {
415
+ "date": "2027-12-26",
416
+ "name": "Isra Mikraj Nabi Muhammad saw.",
417
+ "type": "religious"
418
+ }
419
+ ]
420
+ }
421
+ }
422
+ }
@@ -0,0 +1,447 @@
1
+ require 'date'
2
+ require 'pastel'
3
+ require 'tty-cursor'
4
+
5
+ require_relative 'holidays'
6
+
7
+ module IndonesiaCalendar
8
+ class CalendarApp
9
+ MONTHS_ID = %w[
10
+ Januari Februari Maret April Mei Juni
11
+ Juli Agustus September Oktober November Desember
12
+ ].freeze
13
+
14
+ WEEKDAYS_ID = %w[Min Sen Sel Rab Kam Jum Sab].freeze
15
+
16
+ CELL_WIDTH = 4
17
+
18
+ attr_reader :view_date
19
+
20
+ def initialize
21
+ @pastel = Pastel.new
22
+ @view_date = Date.today
23
+ @running = true
24
+ @show_help = true
25
+ @mode = :calendar
26
+ @screen_width = 70
27
+ @cursor = TTY::Cursor
28
+ @needs_redraw = false
29
+ end
30
+
31
+ def run
32
+ setup_terminal
33
+ render
34
+ main_loop
35
+ ensure
36
+ cleanup_terminal
37
+ puts
38
+ puts @pastel.green('Terima kasih telah menggunakan Kalender Indonesia!')
39
+ end
40
+
41
+ private
42
+
43
+ def windows?
44
+ RUBY_PLATFORM =~ /mswin|mingw|cygwin/
45
+ end
46
+
47
+ def setup_terminal
48
+ @cursor.hide
49
+ print "\e[?1049h"
50
+ print "\e[?25l" # Hide cursor using escape sequence
51
+
52
+ if windows?
53
+ setup_windows_terminal
54
+ else
55
+ @original_stty = `stty -g 2>/dev/null`.chomp
56
+ system('stty cbreak -echo min 1 time 0 2>/dev/null') || system('stty -icanon -echo min 1 time 0 2>/dev/null')
57
+ end
58
+ $stdout.flush
59
+ end
60
+
61
+ def setup_windows_terminal
62
+ require 'io/console'
63
+ require 'win32api'
64
+
65
+ # Get the Windows console handle
66
+ kernel32 = Win32API.new('kernel32', 'GetStdHandle', ['L'], 'L')
67
+ @stdout_handle = kernel32.call(-11) # STD_OUTPUT_HANDLE = -11
68
+ @stdin_handle = kernel32.call(-10) # STD_INPUT_HANDLE = -10
69
+
70
+ # Save and set console input mode
71
+ get_mode = Win32API.new('kernel32', 'GetConsoleMode', %w[L P], 'I')
72
+ set_mode = Win32API.new('kernel32', 'SetConsoleMode', %w[L L], 'I')
73
+
74
+ mode_buf = [0].pack('L')
75
+ get_mode.call(@stdin_handle, mode_buf)
76
+ @original_console_mode = mode_buf.unpack1('L')
77
+
78
+ # Enable window input, disable line input and echo
79
+ new_mode = 0x0080 | 0x0008 # ENABLE_WINDOW_INPUT | ENABLE_PROCESSED_INPUT
80
+ set_mode.call(@stdin_handle, new_mode)
81
+
82
+ # Set output mode for virtual terminal processing
83
+ get_mode.call(@stdout_handle, mode_buf)
84
+ @original_stdout_mode = mode_buf.unpack1('L')
85
+ set_mode.call(@stdout_handle, @original_stdout_mode | 0x0004) # ENABLE_VIRTUAL_TERMINAL_PROCESSING
86
+ rescue LoadError
87
+ # Fall back to basic mode
88
+ end
89
+
90
+ def cleanup_terminal
91
+ if windows?
92
+ cleanup_windows_terminal
93
+ elsif @original_stty && !@original_stty.empty?
94
+ system("stty #{@original_stty} 2>/dev/null")
95
+ end
96
+ print "\e[?25h" # Show cursor using escape sequence
97
+ @cursor.show
98
+ print "\e[?1049l"
99
+ $stdout.flush
100
+ end
101
+
102
+ def cleanup_windows_terminal
103
+ if @stdin_handle && @original_console_mode
104
+ set_mode = Win32API.new('kernel32', 'SetConsoleMode', %w[L L], 'I')
105
+ set_mode.call(@stdin_handle, @original_console_mode)
106
+ end
107
+ if @stdout_handle && @original_stdout_mode
108
+ set_mode = Win32API.new('kernel32', 'SetConsoleMode', %w[L L], 'I')
109
+ set_mode.call(@stdout_handle, @original_stdout_mode)
110
+ end
111
+ rescue StandardError
112
+ # Ignore errors during cleanup
113
+ end
114
+
115
+ def get_terminal_size
116
+ if windows?
117
+ get_windows_terminal_size
118
+ else
119
+ height, width = `stty size`.split.map(&:to_i)
120
+ [height, width]
121
+ end
122
+ rescue StandardError
123
+ [24, 80] # Default fallback
124
+ end
125
+
126
+ def get_windows_terminal_size
127
+ require 'io/console'
128
+ $stdout.winsize
129
+ rescue StandardError
130
+ [24, 80]
131
+ end
132
+
133
+ def main_loop
134
+ while @running
135
+ changed = handle_input
136
+ render if changed || @needs_redraw
137
+ @needs_redraw = false
138
+ end
139
+ end
140
+
141
+ def render
142
+ terminal_height, terminal_width = get_terminal_size
143
+
144
+ # Calculate vertical centering - position in upper third of screen
145
+ ui_height = 15 # Smaller value = higher on screen
146
+ top_padding = [(terminal_height - ui_height) / 3, 0].max
147
+
148
+ # Calculate horizontal centering
149
+ left_padding = [(terminal_width - @screen_width) / 2, 0].max
150
+
151
+ print "\e[2J" # Clear entire screen
152
+ print "\e[H" # Move to home position
153
+
154
+ # Move cursor to calculated vertical position
155
+ print "\e[#{top_padding}H" if top_padding > 0
156
+
157
+ # Render each component centered
158
+ title = render_title
159
+ calendar = render_calendar
160
+ holiday_panel = render_holiday_panel
161
+ status_bar = render_status_bar
162
+
163
+ all_content = [title, calendar, holiday_panel, status_bar].join("\n")
164
+
165
+ # Add left padding to each line
166
+ centered_lines = all_content.split("\n").map do |line|
167
+ ' ' * left_padding + line
168
+ end
169
+
170
+ print centered_lines.join("\n")
171
+ $stdout.flush
172
+ end
173
+
174
+ def render_title
175
+ title_text = 'KALENDER INDONESIA'
176
+ date_text = "#{MONTHS_ID[@view_date.month - 1]} #{@view_date.year}"
177
+ today_text = "Hari Ini: #{Date.today.day} #{MONTHS_ID[Date.today.month - 1]} #{Date.today.year}"
178
+
179
+ lines = []
180
+ lines << @pastel.cyan("┌#{'─' * (@screen_width - 2)}┐")
181
+ lines << @pastel.cyan('│') + (' ' * (@screen_width - 2)) + @pastel.cyan('│')
182
+ lines << @pastel.cyan('│') + center_text(@pastel.bold.yellow("★ #{title_text} ★"),
183
+ @screen_width - 2) + @pastel.cyan('│')
184
+ lines << @pastel.cyan('│') + center_text(@pastel.white(date_text), @screen_width - 2) + @pastel.cyan('│')
185
+ lines << @pastel.cyan('│') + center_text(@pastel.bright_black(today_text), @screen_width - 2) + @pastel.cyan('│')
186
+ lines << @pastel.cyan('│') + (' ' * (@screen_width - 2)) + @pastel.cyan('│')
187
+ lines << @pastel.cyan("└#{'─' * (@screen_width - 2)}┘")
188
+
189
+ lines.join("\n")
190
+ end
191
+
192
+ def render_calendar
193
+ month_holidays = IndonesiaCalendar::Holidays.holidays_for_month(@view_date.year, @view_date.month)
194
+
195
+ first_day = Date.new(@view_date.year, @view_date.month, 1)
196
+ last_day = Date.new(@view_date.year, @view_date.month, -1)
197
+ days_in_month = last_day.day
198
+ start_wday = first_day.wday
199
+
200
+ lines = []
201
+
202
+ # Calendar content width should match screen width minus borders
203
+ content_width = @screen_width - 4 # │ space ... space │
204
+ calendar_grid_width = CELL_WIDTH * 7 + 6 # 7 cells + 6 spaces between
205
+ calendar_padding = (content_width - calendar_grid_width) / 2
206
+
207
+ title = " #{MONTHS_ID[@view_date.month - 1]} #{@view_date.year} "
208
+ lines << @pastel.cyan("┌─#{title}#{'─' * (@screen_width - 3 - title.length)}┐")
209
+ lines << @pastel.cyan('│') + (' ' * (@screen_width - 2)) + @pastel.cyan('│')
210
+
211
+ header = WEEKDAYS_ID.each_with_index.map do |d, i|
212
+ if [0, 6].include?(i)
213
+ @pastel.red(d.center(CELL_WIDTH))
214
+ else
215
+ @pastel.yellow(d.center(CELL_WIDTH))
216
+ end
217
+ end.join(' ')
218
+
219
+ header_visible = header.gsub(/\e\[[0-9;]*m/, '')
220
+ # Center the header in the content area
221
+ lines << @pastel.cyan("│ #{' ' * calendar_padding}#{header}#{' ' * (content_width - calendar_padding - header_visible.length)} │")
222
+ lines << @pastel.cyan("│ #{' ' * calendar_padding}#{'─' * calendar_grid_width}#{' ' * (content_width - calendar_padding - calendar_grid_width)} │")
223
+
224
+ current_line = Array.new(start_wday) { ' ' * CELL_WIDTH }
225
+
226
+ (1..days_in_month).each do |day|
227
+ current_date = Date.new(@view_date.year, @view_date.month, day)
228
+ day_str = format_day_cell(current_date, day, month_holidays)
229
+ current_line << day_str
230
+
231
+ next unless current_line.length == 7
232
+
233
+ line_content = current_line.join(' ')
234
+ line_visible = line_content.gsub(/\e\[[0-9;]*m/, '')
235
+ lines << @pastel.cyan("│ #{' ' * calendar_padding}#{line_content}#{' ' * (content_width - calendar_padding - line_visible.length)} │")
236
+ current_line = []
237
+ end
238
+
239
+ unless current_line.empty?
240
+ current_line += Array.new(7 - current_line.length, ' ' * CELL_WIDTH)
241
+ line_content = current_line.join(' ')
242
+ line_visible = line_content.gsub(/\e\[[0-9;]*m/, '')
243
+ lines << @pastel.cyan("│ #{' ' * calendar_padding}#{line_content}#{' ' * (content_width - calendar_padding - line_visible.length)} │")
244
+ end
245
+
246
+ weeks_count = ((days_in_month + start_wday) / 7.0).ceil
247
+ (5 - weeks_count).times do
248
+ lines << @pastel.cyan("│ #{' ' * calendar_padding}#{' ' * calendar_grid_width}#{' ' * (content_width - calendar_padding - calendar_grid_width)} │")
249
+ end
250
+
251
+ lines << @pastel.cyan('│') + (' ' * (@screen_width - 2)) + @pastel.cyan('│')
252
+ lines << @pastel.cyan("└#{'─' * (@screen_width - 2)}┘")
253
+
254
+ lines.join("\n")
255
+ end
256
+
257
+ def format_day_cell(date, day, month_holidays)
258
+ day_num = day.to_s.rjust(2)
259
+ is_holiday = month_holidays.key?(date)
260
+ is_today = date == Date.today
261
+ is_weekend = date.wday == 0 || date.wday == 6
262
+
263
+ if is_today
264
+ @pastel.on_green.black(" #{day_num} ")
265
+ elsif is_holiday
266
+ @pastel.red(" #{day_num}*")
267
+ elsif is_weekend
268
+ @pastel.bright_black(" #{day_num} ")
269
+ else
270
+ @pastel.white(" #{day_num} ")
271
+ end
272
+ end
273
+
274
+ def render_holiday_panel
275
+ month_holidays = IndonesiaCalendar::Holidays.holidays_for_month(@view_date.year, @view_date.month)
276
+
277
+ lines = []
278
+ lines << @pastel.yellow("┌─ Hari Libur #{'─' * (@screen_width - 15)}┐")
279
+ lines << @pastel.yellow('│') + (' ' * (@screen_width - 2)) + @pastel.yellow('│')
280
+
281
+ if !IndonesiaCalendar::Holidays.announced?(@view_date.year)
282
+ unknown_year_notice(@view_date.year).each do |text|
283
+ lines << @pastel.yellow('│') + @pastel.bright_black(text.ljust(@screen_width - 2)) + @pastel.yellow('│')
284
+ end
285
+ elsif month_holidays.empty?
286
+ text = ' Tidak ada hari libur bulan ini'
287
+ lines << @pastel.yellow('│') + text.ljust(@screen_width - 2) + @pastel.yellow('│')
288
+ else
289
+ month_holidays.each do |date, holiday|
290
+ type = format_holiday_type(holiday[:type])
291
+ type_visible = type.gsub(/\e\[[0-9;]*m/, '')
292
+
293
+ # Truncate long official names (e.g. "Cuti Bersama ...") so the panel
294
+ # border stays aligned and the padding below can never go negative.
295
+ fixed_width = 4 + 2 + 2 + 1 + type_visible.length # " " + day + ". " + " " + type
296
+ name_budget = [@screen_width - 2 - fixed_width, 1].max
297
+ name_text = holiday[:name]
298
+ name_text = "#{name_text[0, name_budget - 1]}…" if name_text.length > name_budget
299
+
300
+ day_str = @pastel.cyan(date.day.to_s.rjust(2))
301
+ name = @pastel.white(name_text)
302
+
303
+ line = " #{day_str}. #{name} #{type}"
304
+ line_visible = line.gsub(/\e\[[0-9;]*m/, '')
305
+ lines << @pastel.yellow('│') + line + ' ' * [@screen_width - 2 - line_visible.length, 0].max + @pastel.yellow('│')
306
+ end
307
+ end
308
+
309
+ lines << @pastel.yellow("│#{' ' * (@screen_width - 2)}│")
310
+ lines << @pastel.yellow("└#{'─' * (@screen_width - 2)}┘")
311
+
312
+ lines.join("\n")
313
+ end
314
+
315
+ def unknown_year_notice(year)
316
+ latest = IndonesiaCalendar::Holidays.latest_announced_year
317
+ if latest.nil? || year > latest
318
+ [" Hari libur #{year} belum diumumkan pemerintah.",
319
+ ' Menunggu SKB 3 Menteri tentang libur nasional dan cuti bersama.']
320
+ else
321
+ [" Data hari libur #{year} tidak tersedia."]
322
+ end
323
+ end
324
+
325
+ def format_holiday_type(type)
326
+ case type
327
+ when :national
328
+ @pastel.red('[Nasional]')
329
+ when :religious
330
+ @pastel.magenta('[Keagamaan]')
331
+ when :cultural
332
+ @pastel.cyan('[Budaya]')
333
+ else
334
+ ''
335
+ end
336
+ end
337
+
338
+ def render_status_bar
339
+ lines = []
340
+
341
+ help_items = [
342
+ "#{@pastel.bright_black('h')} #{@pastel.bright_black('Bulan')}#{@pastel.bright_black(' -')}",
343
+ "#{@pastel.bright_black('l')} #{@pastel.bright_black('Bulan')}#{@pastel.bright_black(' +')}",
344
+ "#{@pastel.bright_black('j')} #{@pastel.bright_black('Tahun')}#{@pastel.bright_black(' -')}",
345
+ "#{@pastel.bright_black('k')} #{@pastel.bright_black('Tahun')}#{@pastel.bright_black(' +')}",
346
+ "#{@pastel.bright_black('g')} #{@pastel.bright_black('Today')}",
347
+ "#{@pastel.bright_black('q')} #{@pastel.bright_black('Keluar')}"
348
+ ]
349
+
350
+ help_text = help_items.join(' ')
351
+ help_visible = help_text.gsub(/\e\[[0-9;]*m/, '')
352
+ content_width = @screen_width - 4 # │ space ... space │
353
+ help_padding = (content_width - help_visible.length) / 2
354
+ help_line = " #{' ' * help_padding}#{help_text}#{' ' * (content_width - help_visible.length - help_padding)} "
355
+
356
+ lines << ''
357
+ lines << " #{help_line} "
358
+
359
+ lines.join("\n")
360
+ end
361
+
362
+ def handle_input
363
+ changed = false
364
+ begin
365
+ key = read_char
366
+ return false if key.nil?
367
+
368
+ if key == "\e"
369
+ c2 = read_char
370
+ return false if c2.nil?
371
+
372
+ if c2 == '['
373
+ c3 = read_char
374
+ case c3
375
+ when 'C'
376
+ @view_date = @view_date >> 1
377
+ changed = true
378
+ when 'D'
379
+ @view_date = @view_date << 1
380
+ changed = true
381
+ end
382
+ end
383
+ return changed
384
+ end
385
+
386
+ case key
387
+ when 'q', 'Q', "\u0003"
388
+ @running = false
389
+ when 'h', 'H'
390
+ @view_date = @view_date << 1
391
+ changed = true
392
+ when 'l', 'L'
393
+ @view_date = @view_date >> 1
394
+ changed = true
395
+ when 'j', 'J'
396
+ @view_date = Date.new(@view_date.year - 1, @view_date.month, 1)
397
+ changed = true
398
+ when 'k', 'K'
399
+ @view_date = Date.new(@view_date.year + 1, @view_date.month, 1)
400
+ changed = true
401
+ when 'g'
402
+ @view_date = Date.today
403
+ changed = true
404
+ end
405
+ rescue IOError, Errno::EIO
406
+ @running = false
407
+ rescue StandardError => e
408
+ # Ignore errors
409
+ end
410
+ changed
411
+ end
412
+
413
+ def read_char
414
+ if windows?
415
+ read_char_windows
416
+ else
417
+ read_char_unix
418
+ end
419
+ end
420
+
421
+ def read_char_windows
422
+ require 'io/console'
423
+ # On Windows, use console API for proper key reading
424
+ begin
425
+ # Use IO#raw if available (Ruby 2.4+)
426
+ $stdin.raw do
427
+ return $stdin.getch
428
+ end
429
+ rescue StandardError
430
+ # Fallback for older Ruby versions
431
+ $stdin.getch
432
+ end
433
+ end
434
+
435
+ def read_char_unix
436
+ $stdin.getc
437
+ end
438
+
439
+ def center_text(text, width)
440
+ visible_width = text.gsub(/\e\[[0-9;]*m/, '').length
441
+ total_padding = [(width - visible_width), 0].max
442
+ left_padding = total_padding / 2
443
+ right_padding = total_padding - left_padding
444
+ ' ' * left_padding + text + ' ' * right_padding
445
+ end
446
+ end
447
+ end
data/lib/holidays.rb ADDED
@@ -0,0 +1,46 @@
1
+ require 'date'
2
+ require 'json'
3
+
4
+ module IndonesiaCalendar
5
+ module Holidays
6
+ DATA_FILE = File.expand_path('../data/holidays.json', __dir__)
7
+
8
+ TYPE_SYMBOLS = { 'national' => :national, 'religious' => :religious, 'cultural' => :cultural }.freeze
9
+
10
+ # Loaded once and memoized: { year(Integer) => { Date => { name:, type: } } }
11
+ # Only years whose SKB 3 Menteri has been imported are present; nothing is
12
+ # guessed for other years, because most holidays follow lunar calendars and
13
+ # cuti bersama is a yearly government decision.
14
+ def self.data
15
+ @data ||= load_data
16
+ end
17
+
18
+ def self.load_data
19
+ raw = JSON.parse(File.read(DATA_FILE))
20
+ (raw['years'] || {}).each_with_object({}) do |(year_str, record), result|
21
+ days = {}
22
+ record['holidays'].each do |entry|
23
+ date = Date.parse(entry['date'])
24
+ days[date] ||= { name: entry['name'], type: TYPE_SYMBOLS.fetch(entry['type'], :national) }
25
+ end
26
+ result[year_str.to_i] = days.sort.to_h
27
+ end
28
+ rescue JSON::ParserError, ArgumentError, NoMethodError => e
29
+ # The file is hand-edited for amendments; a broken edit must be loud, not
30
+ # silently turn every year into "belum diumumkan".
31
+ raise "#{DATA_FILE} is invalid (#{e.class}: #{e.message})"
32
+ end
33
+
34
+ def self.announced?(year)
35
+ data.key?(year)
36
+ end
37
+
38
+ def self.latest_announced_year
39
+ data.keys.max
40
+ end
41
+
42
+ def self.holidays_for_month(year, month)
43
+ data.fetch(year, {}).select { |date, _| date.month == month }
44
+ end
45
+ end
46
+ end
metadata ADDED
@@ -0,0 +1,74 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: id-calendar-tui-clariant
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.3
5
+ platform: ruby
6
+ authors:
7
+ - clariantb
8
+ bindir: bin
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: pastel
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - "~>"
17
+ - !ruby/object:Gem::Version
18
+ version: '0.8'
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - "~>"
24
+ - !ruby/object:Gem::Version
25
+ version: '0.8'
26
+ - !ruby/object:Gem::Dependency
27
+ name: tty-cursor
28
+ requirement: !ruby/object:Gem::Requirement
29
+ requirements:
30
+ - - "~>"
31
+ - !ruby/object:Gem::Version
32
+ version: '0.7'
33
+ type: :runtime
34
+ prerelease: false
35
+ version_requirements: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - "~>"
38
+ - !ruby/object:Gem::Version
39
+ version: '0.7'
40
+ description: A TUI calendar application displaying Indonesian public holidays and
41
+ cuti bersama from the official SKB 3 Menteri announcements, with vim-like navigation
42
+ executables:
43
+ - calendar
44
+ extensions: []
45
+ extra_rdoc_files: []
46
+ files:
47
+ - LICENSE
48
+ - README.md
49
+ - bin/calendar
50
+ - data/holidays.json
51
+ - lib/calendar_app.rb
52
+ - lib/holidays.rb
53
+ homepage: https://github.com/clariantb/id-calendar-tui
54
+ licenses:
55
+ - MIT
56
+ metadata: {}
57
+ rdoc_options: []
58
+ require_paths:
59
+ - lib
60
+ required_ruby_version: !ruby/object:Gem::Requirement
61
+ requirements:
62
+ - - ">="
63
+ - !ruby/object:Gem::Version
64
+ version: 2.6.0
65
+ required_rubygems_version: !ruby/object:Gem::Requirement
66
+ requirements:
67
+ - - ">="
68
+ - !ruby/object:Gem::Version
69
+ version: '0'
70
+ requirements: []
71
+ rubygems_version: 3.6.9
72
+ specification_version: 4
73
+ summary: Terminal-based Indonesian calendar with official holiday data
74
+ test_files: []