id-calendar-tui 0.1.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.
Files changed (7) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE +21 -0
  3. data/README.md +114 -0
  4. data/bin/calendar +5 -0
  5. data/lib/calendar_app.rb +424 -0
  6. data/lib/holidays.rb +281 -0
  7. metadata +78 -0
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: e7e59e83950bac97f72de40a45422b6035270492f193edb07f614a4323baf891
4
+ data.tar.gz: 58028fab76721d61225377d832369bd3c4e23cb3e05881299fa1cb9166135ad6
5
+ SHA512:
6
+ metadata.gz: 1785d5b6c927f4df7fb56789eaef1912bdc029a09fed7043bcfdcae288c69985d14699288fc682c793d4e13e1967b302a75fdfb7d24e38a3adcb4469831c27dc
7
+ data.tar.gz: be63881a690a06699e4a2d61c3b4a22d84dbf0b43603ef1e467b1dcef2ff33fc679c11008092028318e310cf507a6849c20403103393386b6e9e075c9a3d8f0e
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,114 @@
1
+ # ID Calendar TUI
2
+
3
+ [![Gem Version](https://badge.fury.io/rb/id-calendar-tui.svg)](https://badge.fury.io/rb/id-calendar-tui)
4
+ [![Docker Image](https://img.shields.io/badge/GHCR-latest-blue?logo=docker)](https://github.com/adiprnm/indonesia-cal-tui/pkgs/container/indonesia-cal-tui)
5
+
6
+ A terminal-based calendar application for Indonesian public holidays with vim-like navigation.
7
+
8
+ ![Calendar Screenshot](screenshot.png)
9
+
10
+ ## Features
11
+
12
+ - Full calendar view with Indonesian public holidays
13
+ - Vim-like navigation (h/j/k/l)
14
+ - Navigate months with arrow keys
15
+ - Jump to today with `g`
16
+ - Color-coded holidays (National, Religious, Cultural)
17
+ - Clean, centered TUI interface
18
+
19
+ ## Installation
20
+
21
+ ### Via RubyGems
22
+
23
+ ```bash
24
+ gem install id-calendar-tui
25
+ calendar
26
+ ```
27
+
28
+ ### Via Docker (GHCR)
29
+
30
+ ```bash
31
+ docker run --rm -it ghcr.io/adiprnm/indonesia-cal-tui:latest
32
+ ```
33
+
34
+ ### From Source
35
+
36
+ ```bash
37
+ git clone https://github.com/adiprnm/indonesia-cal-tui.git
38
+ cd indonesia-cal-tui
39
+ bundle install
40
+ bundle exec ruby bin/calendar
41
+ ```
42
+
43
+ ## Usage
44
+
45
+ ```bash
46
+ # Run the calendar
47
+ calendar
48
+
49
+ # Or with bundle
50
+ bundle exec calendar
51
+ ```
52
+
53
+ ## Navigation
54
+
55
+ | Key | Action |
56
+ |-----|--------|
57
+ | `h`, `←` | Previous month |
58
+ | `l`, `→` | Next month |
59
+ | `j`, `k` | Previous/Next year |
60
+ | `g` | Go to today |
61
+ | `q`, `Ctrl+C` | Quit |
62
+
63
+ ## Holidays Included
64
+
65
+ - **National Holidays**: New Year, Independence Day, etc.
66
+ - **Islamic Holidays**: Eid al-Fitr, Eid al-Adha, etc.
67
+ - **Hindu Holidays**: Nyepi (Day of Silence)
68
+ - **Chinese New Year**: Imlek, Cap Go Meh
69
+ - **Christian Holidays**: Good Friday, Easter, Christmas
70
+
71
+ ## Requirements
72
+
73
+ - Ruby 2.6+
74
+ - Terminal with color support
75
+
76
+ ## Development
77
+
78
+ ```bash
79
+ # Install dependencies
80
+ bundle install
81
+
82
+ # Run the application
83
+ bundle exec ruby bin/calendar
84
+
85
+ # Build gem
86
+ bundle exec gem build id-calendar-tui.gemspec
87
+
88
+ # Build Docker image
89
+ docker build -t id-calendar-tui .
90
+ ```
91
+
92
+ ## Releasing
93
+
94
+ To release a new version:
95
+
96
+ ```bash
97
+ # Update version in id-calendar-tui.gemspec
98
+ git add .
99
+ git commit -m "Bump version to x.x.x"
100
+ git tag vx.x.x
101
+ git push origin vx.x.x
102
+ ```
103
+
104
+ This will automatically:
105
+ - Publish to [RubyGems](https://rubygems.org/gems/id-calendar-tui)
106
+ - Publish Docker image to [GHCR](https://github.com/adiprnm/indonesia-cal-tui/pkgs/container/indonesia-cal-tui)
107
+
108
+ ## Contributing
109
+
110
+ Bug reports and pull requests are welcome.
111
+
112
+ ## License
113
+
114
+ 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,424 @@
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 month_holidays.empty?
282
+ text = ' Tidak ada hari libur bulan ini'
283
+ lines << @pastel.yellow('│') + text.ljust(@screen_width - 2) + @pastel.yellow('│')
284
+ else
285
+ month_holidays.each do |date, holiday|
286
+ day_str = @pastel.cyan(date.day.to_s.rjust(2))
287
+ name = @pastel.white(holiday[:name])
288
+ type = format_holiday_type(holiday[:type])
289
+
290
+ line = " #{day_str}. #{name} #{type}"
291
+ line_visible = line.gsub(/\e\[[0-9;]*m/, '')
292
+ lines << @pastel.yellow('│') + line + ' ' * (@screen_width - 2 - line_visible.length) + @pastel.yellow('│')
293
+ end
294
+ end
295
+
296
+ lines << @pastel.yellow("│#{' ' * (@screen_width - 2)}│")
297
+ lines << @pastel.yellow("└#{'─' * (@screen_width - 2)}┘")
298
+
299
+ lines.join("\n")
300
+ end
301
+
302
+ def format_holiday_type(type)
303
+ case type
304
+ when :national
305
+ @pastel.red('[Nasional]')
306
+ when :religious
307
+ @pastel.magenta('[Keagamaan]')
308
+ when :cultural
309
+ @pastel.cyan('[Budaya]')
310
+ else
311
+ ''
312
+ end
313
+ end
314
+
315
+ def render_status_bar
316
+ lines = []
317
+
318
+ help_items = [
319
+ "#{@pastel.bright_black('h')} #{@pastel.bright_black('Bulan')}#{@pastel.bright_black(' -')}",
320
+ "#{@pastel.bright_black('l')} #{@pastel.bright_black('Bulan')}#{@pastel.bright_black(' +')}",
321
+ "#{@pastel.bright_black('j')} #{@pastel.bright_black('Tahun')}#{@pastel.bright_black(' -')}",
322
+ "#{@pastel.bright_black('k')} #{@pastel.bright_black('Tahun')}#{@pastel.bright_black(' +')}",
323
+ "#{@pastel.bright_black('g')} #{@pastel.bright_black('Today')}",
324
+ "#{@pastel.bright_black('q')} #{@pastel.bright_black('Keluar')}"
325
+ ]
326
+
327
+ help_text = help_items.join(' ')
328
+ help_visible = help_text.gsub(/\e\[[0-9;]*m/, '')
329
+ content_width = @screen_width - 4 # │ space ... space │
330
+ help_padding = (content_width - help_visible.length) / 2
331
+ help_line = " #{' ' * help_padding}#{help_text}#{' ' * (content_width - help_visible.length - help_padding)} "
332
+
333
+ lines << ''
334
+ lines << " #{help_line} "
335
+
336
+ lines.join("\n")
337
+ end
338
+
339
+ def handle_input
340
+ changed = false
341
+ begin
342
+ key = read_char
343
+ return false if key.nil?
344
+
345
+ if key == "\e"
346
+ c2 = read_char
347
+ return false if c2.nil?
348
+
349
+ if c2 == '['
350
+ c3 = read_char
351
+ case c3
352
+ when 'C'
353
+ @view_date = @view_date >> 1
354
+ changed = true
355
+ when 'D'
356
+ @view_date = @view_date << 1
357
+ changed = true
358
+ end
359
+ end
360
+ return changed
361
+ end
362
+
363
+ case key
364
+ when 'q', 'Q', "\u0003"
365
+ @running = false
366
+ when 'h', 'H'
367
+ @view_date = @view_date << 1
368
+ changed = true
369
+ when 'l', 'L'
370
+ @view_date = @view_date >> 1
371
+ changed = true
372
+ when 'j', 'J'
373
+ @view_date = Date.new(@view_date.year - 1, @view_date.month, 1)
374
+ changed = true
375
+ when 'k', 'K'
376
+ @view_date = Date.new(@view_date.year + 1, @view_date.month, 1)
377
+ changed = true
378
+ when 'g'
379
+ @view_date = Date.today
380
+ changed = true
381
+ end
382
+ rescue IOError, Errno::EIO
383
+ @running = false
384
+ rescue StandardError => e
385
+ # Ignore errors
386
+ end
387
+ changed
388
+ end
389
+
390
+ def read_char
391
+ if windows?
392
+ read_char_windows
393
+ else
394
+ read_char_unix
395
+ end
396
+ end
397
+
398
+ def read_char_windows
399
+ require 'io/console'
400
+ # On Windows, use console API for proper key reading
401
+ begin
402
+ # Use IO#raw if available (Ruby 2.4+)
403
+ $stdin.raw do
404
+ return $stdin.getch
405
+ end
406
+ rescue StandardError
407
+ # Fallback for older Ruby versions
408
+ $stdin.getch
409
+ end
410
+ end
411
+
412
+ def read_char_unix
413
+ $stdin.getc
414
+ end
415
+
416
+ def center_text(text, width)
417
+ visible_width = text.gsub(/\e\[[0-9;]*m/, '').length
418
+ total_padding = [(width - visible_width), 0].max
419
+ left_padding = total_padding / 2
420
+ right_padding = total_padding - left_padding
421
+ ' ' * left_padding + text + ' ' * right_padding
422
+ end
423
+ end
424
+ end
data/lib/holidays.rb ADDED
@@ -0,0 +1,281 @@
1
+ module IndonesiaCalendar
2
+ module Holidays
3
+ FIXED_HOLIDAYS = {
4
+ '01-01' => { name: 'Tahun Baru Masehi', name_en: "New Year's Day", type: :national },
5
+ '05-01' => { name: 'Hari Buruh Internasional', name_en: 'Labour Day', type: :national },
6
+ '06-01' => { name: 'Hari Lahir Pancasila', name_en: 'Pancasila Day', type: :national },
7
+ '08-17' => { name: 'Hari Kemerdekaan RI', name_en: 'Independence Day', type: :national },
8
+ '12-25' => { name: 'Hari Raya Natal', name_en: 'Christmas Day', type: :national },
9
+ '12-31' => { name: 'Malam Tahun Baru', name_en: "New Year's Eve", type: :national }
10
+ }.freeze
11
+
12
+ ISLAMIC_HOLIDAYS = {
13
+ '1-1' => { name: 'Tahun Baru Hijriah', name_en: 'Islamic New Year', type: :religious },
14
+ '10-1' => { name: 'Hari Raya Idul Fitri', name_en: 'Eid al-Fitr', type: :religious },
15
+ '10-2' => { name: 'Hari Raya Idul Fitri Cuti Bersama', name_en: 'Eid al-Fitr (Shared Leave)', type: :religious },
16
+ '12-10' => { name: 'Hari Raya Kurban (Idul Adha)', name_en: 'Eid al-Adha', type: :religious },
17
+ '3-12' => { name: 'Maulid Nabi Muhammad SAW', name_en: "Prophet's Birthday", type: :religious },
18
+ '9-1' => { name: 'Awal Ramadhan', name_en: 'Start of Ramadan', type: :religious },
19
+ '7-27' => { name: 'Isra Mikraj Nabi Muhammad SAW', name_en: "Isra and Mi'raj", type: :religious },
20
+ '1-10' => { name: 'Hari Asyura', name_en: 'Day of Ashura', type: :religious, optional: true }
21
+ }.freeze
22
+
23
+ CHINESE_HOLIDAYS = {
24
+ '1-1' => { name: 'Tahun Baru Imlek', name_en: 'Chinese New Year', type: :cultural },
25
+ '1-15' => { name: 'Cap Go Meh', name_en: 'Lantern Festival', type: :cultural }
26
+ }.freeze
27
+
28
+ HINDU_HOLIDAYS = {
29
+ 'nyepi' => { name: 'Hari Suci Nyepi', name_en: 'Day of Silence', type: :religious }
30
+ }.freeze
31
+
32
+ CHRISTIAN_HOLIDAYS = {
33
+ 'good_friday' => { name: 'Wafat Yesus Kristus (Jumat Agung)', name_en: 'Good Friday', type: :religious },
34
+ 'easter' => { name: 'Hari Paskah', name_en: 'Easter Sunday', type: :religious },
35
+ 'ascension' => { name: 'Kenaikan Yesus Kristus', name_en: 'Ascension Day', type: :religious },
36
+ 'vesak' => { name: 'Hari Raya Waisak', name_en: 'Vesak Day', type: :religious }
37
+ }.freeze
38
+
39
+ ISLAMIC_YEAR_OFFSET = 579
40
+
41
+ ISLAMIC_DATES = {
42
+ 2024 => {
43
+ '1-1' => { month: 7, day: 8 },
44
+ '3-12' => { month: 9, day: 15 },
45
+ '7-27' => { month: 1, day: 26 },
46
+ '9-1' => { month: 3, day: 11 },
47
+ '10-1' => { month: 4, day: 10 },
48
+ '10-2' => { month: 4, day: 11 },
49
+ '12-10' => { month: 6, day: 17 }
50
+ },
51
+ 2025 => {
52
+ '1-1' => { month: 7, day: 7 },
53
+ '3-12' => { month: 9, day: 5 },
54
+ '7-27' => { month: 1, day: 15 },
55
+ '9-1' => { month: 3, day: 1 },
56
+ '10-1' => { month: 4, day: 1 },
57
+ '10-2' => { month: 4, day: 2 },
58
+ '12-10' => { month: 6, day: 7 }
59
+ },
60
+ 2026 => {
61
+ '1-1' => { month: 6, day: 26 },
62
+ '3-12' => { month: 8, day: 25 },
63
+ '7-27' => { month: 1, day: 5 },
64
+ '9-1' => { month: 2, day: 18 },
65
+ '10-1' => { month: 3, day: 20 },
66
+ '10-2' => { month: 3, day: 21 },
67
+ '12-10' => { month: 5, day: 27 }
68
+ },
69
+ 2027 => {
70
+ '1-1' => { month: 6, day: 16 },
71
+ '3-12' => { month: 8, day: 14 },
72
+ '7-27' => { month: 1, day: 6 },
73
+ '9-1' => { month: 2, day: 8 },
74
+ '10-1' => { month: 3, day: 10 },
75
+ '10-2' => { month: 3, day: 11 },
76
+ '12-10' => { month: 5, day: 16 }
77
+ },
78
+ 2028 => {
79
+ '1-1' => { month: 7, day: 5 },
80
+ '3-12' => { month: 9, day: 3 },
81
+ '7-27' => { month: 1, day: 25 },
82
+ '9-1' => { month: 2, day: 27 },
83
+ '10-1' => { month: 3, day: 28 },
84
+ '10-2' => { month: 3, day: 29 },
85
+ '12-10' => { month: 6, day: 5 }
86
+ },
87
+ 2029 => {
88
+ '1-1' => { month: 6, day: 24 },
89
+ '3-12' => { month: 8, day: 22 },
90
+ '7-27' => { month: 1, day: 14 },
91
+ '9-1' => { month: 2, day: 16 },
92
+ '10-1' => { month: 3, day: 18 },
93
+ '10-2' => { month: 3, day: 19 },
94
+ '12-10' => { month: 5, day: 25 }
95
+ },
96
+ 2030 => {
97
+ '1-1' => { month: 6, day: 14 },
98
+ '3-12' => { month: 8, day: 12 },
99
+ '7-27' => { month: 1, day: 4 },
100
+ '9-1' => { month: 2, day: 6 },
101
+ '10-1' => { month: 3, day: 8 },
102
+ '10-2' => { month: 3, day: 9 },
103
+ '12-10' => { month: 5, day: 15 }
104
+ }
105
+ }.freeze
106
+
107
+ CNY_DATES = {
108
+ 2024 => Date.new(2024, 2, 10),
109
+ 2025 => Date.new(2025, 1, 29),
110
+ 2026 => Date.new(2026, 2, 17),
111
+ 2027 => Date.new(2027, 2, 6),
112
+ 2028 => Date.new(2028, 1, 26),
113
+ 2029 => Date.new(2029, 2, 13),
114
+ 2030 => Date.new(2030, 2, 3)
115
+ }.freeze
116
+
117
+ NYEPI_DATES = {
118
+ 2024 => Date.new(2024, 3, 9),
119
+ 2025 => Date.new(2025, 3, 29),
120
+ 2026 => Date.new(2026, 3, 19),
121
+ 2027 => Date.new(2027, 3, 9),
122
+ 2028 => Date.new(2028, 3, 28),
123
+ 2029 => Date.new(2029, 3, 17),
124
+ 2030 => Date.new(2030, 3, 7)
125
+ }.freeze
126
+
127
+ VESAK_DATES = {
128
+ 2024 => Date.new(2024, 5, 23),
129
+ 2025 => Date.new(2025, 5, 13),
130
+ 2026 => Date.new(2026, 5, 2),
131
+ 2027 => Date.new(2027, 5, 20),
132
+ 2028 => Date.new(2028, 5, 9),
133
+ 2029 => Date.new(2029, 4, 28),
134
+ 2030 => Date.new(2030, 5, 17)
135
+ }.freeze
136
+
137
+ def self.fixed_holiday(date)
138
+ key = "#{date.month.to_s.rjust(2, '0')}-#{date.day.to_s.rjust(2, '0')}"
139
+ FIXED_HOLIDAYS[key]
140
+ end
141
+
142
+ def self.islamic_holiday(year)
143
+ hijri_year = year - ISLAMIC_YEAR_OFFSET
144
+ holidays = {}
145
+
146
+ begin
147
+ require 'hijri'
148
+ ISLAMIC_HOLIDAYS.each do |hijri_key, holiday_info|
149
+ month, day = hijri_key.split('-').map(&:to_i)
150
+
151
+ begin
152
+ hijri_date = Hijri::Date.new(hijri_year, month, day)
153
+ gregorian_date = hijri_date.to_gregorian
154
+
155
+ key = "#{gregorian_date.month}-#{gregorian_date.day}"
156
+ holidays[key] = holiday_info.merge(hijri_date: "#{day} #{hijri_month_name(month)} #{hijri_year}H")
157
+ rescue StandardError
158
+ end
159
+ end
160
+ return holidays unless holidays.empty?
161
+ rescue LoadError
162
+ end
163
+
164
+ if ISLAMIC_DATES.key?(year)
165
+ ISLAMIC_DATES[year].each do |hijri_key, gregorian|
166
+ day = hijri_key.split('-').last
167
+ holiday_info = ISLAMIC_HOLIDAYS[hijri_key]
168
+ next unless holiday_info
169
+
170
+ key = "#{gregorian[:month]}-#{gregorian[:day]}"
171
+ holidays[key] =
172
+ holiday_info.merge(hijri_date: "#{day} #{hijri_month_name(hijri_key.split('-').first.to_i)} #{hijri_year}H")
173
+ end
174
+ end
175
+
176
+ holidays
177
+ end
178
+
179
+ def self.chinese_new_year(year)
180
+ CNY_DATES[year]
181
+ end
182
+
183
+ def self.nyepi_date(year)
184
+ NYEPI_DATES[year]
185
+ end
186
+
187
+ def self.vesak_date(year)
188
+ VESAK_DATES[year]
189
+ end
190
+
191
+ def self.good_friday(year)
192
+ calculate_easter(year) - 2
193
+ end
194
+
195
+ def self.easter_sunday(year)
196
+ calculate_easter(year)
197
+ end
198
+
199
+ def self.ascension_day(year)
200
+ calculate_easter(year) + 39
201
+ end
202
+
203
+ def self.calculate_easter(year)
204
+ a = year % 19
205
+ b = year / 100
206
+ c = year % 100
207
+ d = b / 4
208
+ e = b % 4
209
+ f = (b + 8) / 25
210
+ g = (b - f + 1) / 3
211
+ h = (19 * a + b - d - g + 15) % 30
212
+ i = c / 4
213
+ k = c % 4
214
+ l = (32 + 2 * e + 2 * i - h - k) % 7
215
+ m = (a + 11 * h + 22 * l) / 451
216
+ month = (h + l - 7 * m + 114) / 31
217
+ day = ((h + l - 7 * m + 114) % 31) + 1
218
+
219
+ Date.new(year, month, day)
220
+ end
221
+
222
+ def self.hijri_month_name(month)
223
+ names = {
224
+ 1 => 'Muharram', 2 => 'Safar', 3 => "Rabi' al-Awwal",
225
+ 4 => "Rabi' al-Thani", 5 => 'Jumada al-Awwal', 6 => 'Jumada al-Thani',
226
+ 7 => 'Rajab', 8 => "Sha'ban", 9 => 'Ramadan',
227
+ 10 => 'Shawwal', 11 => "Dhu al-Qi'dah", 12 => 'Dhu al-Hijjah'
228
+ }
229
+ names[month]
230
+ end
231
+
232
+ def self.all_holidays_for_year(year)
233
+ holidays = {}
234
+
235
+ FIXED_HOLIDAYS.each do |key, info|
236
+ month, day = key.split('-').map(&:to_i)
237
+ date = Date.new(year, month, day)
238
+ holidays[date] = info
239
+ end
240
+
241
+ begin
242
+ islamic = islamic_holiday(year)
243
+ islamic.each do |key, info|
244
+ month, day = key.split('-').map(&:to_i)
245
+ date = Date.new(year, month, day)
246
+ holidays[date] = info
247
+ end
248
+ rescue StandardError
249
+ end
250
+
251
+ cny = chinese_new_year(year)
252
+ if cny
253
+ holidays[cny] = CHINESE_HOLIDAYS['1-1']
254
+ cap_go_meh = cny + 14
255
+ holidays[cap_go_meh] = CHINESE_HOLIDAYS['1-15']
256
+ end
257
+
258
+ nyepi = nyepi_date(year)
259
+ holidays[nyepi] = HINDU_HOLIDAYS['nyepi'] if nyepi
260
+
261
+ vesak = vesak_date(year)
262
+ holidays[vesak] = CHRISTIAN_HOLIDAYS['vesak'] if vesak
263
+
264
+ good_friday = self.good_friday(year)
265
+ holidays[good_friday] = CHRISTIAN_HOLIDAYS['good_friday']
266
+
267
+ easter = easter_sunday(year)
268
+ holidays[easter] = CHRISTIAN_HOLIDAYS['easter']
269
+
270
+ ascension = ascension_day(year)
271
+ holidays[ascension] = CHRISTIAN_HOLIDAYS['ascension']
272
+
273
+ holidays.sort.to_h
274
+ end
275
+
276
+ def self.holidays_for_month(year, month)
277
+ all_holidays = all_holidays_for_year(year)
278
+ all_holidays.select { |date, _| date.month == month }
279
+ end
280
+ end
281
+ end
metadata ADDED
@@ -0,0 +1,78 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: id-calendar-tui
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Your Name
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2026-03-09 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: pastel
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - "~>"
18
+ - !ruby/object:Gem::Version
19
+ version: '0.8'
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - "~>"
25
+ - !ruby/object:Gem::Version
26
+ version: '0.8'
27
+ - !ruby/object:Gem::Dependency
28
+ name: tty-cursor
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - "~>"
32
+ - !ruby/object:Gem::Version
33
+ version: '0.7'
34
+ type: :runtime
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - "~>"
39
+ - !ruby/object:Gem::Version
40
+ version: '0.7'
41
+ description: A TUI calendar application displaying Indonesian public holidays with
42
+ vim-like navigation
43
+ email:
44
+ - your.email@example.com
45
+ executables:
46
+ - calendar
47
+ extensions: []
48
+ extra_rdoc_files: []
49
+ files:
50
+ - LICENSE
51
+ - README.md
52
+ - bin/calendar
53
+ - lib/calendar_app.rb
54
+ - lib/holidays.rb
55
+ homepage: https://github.com/adipurnm/indonesia-cal-tui
56
+ licenses:
57
+ - MIT
58
+ metadata: {}
59
+ post_install_message:
60
+ rdoc_options: []
61
+ require_paths:
62
+ - lib
63
+ required_ruby_version: !ruby/object:Gem::Requirement
64
+ requirements:
65
+ - - ">="
66
+ - !ruby/object:Gem::Version
67
+ version: 2.6.0
68
+ required_rubygems_version: !ruby/object:Gem::Requirement
69
+ requirements:
70
+ - - ">="
71
+ - !ruby/object:Gem::Version
72
+ version: '0'
73
+ requirements: []
74
+ rubygems_version: 3.2.33
75
+ signing_key:
76
+ specification_version: 4
77
+ summary: Terminal-based Indonesian calendar with holiday support
78
+ test_files: []