summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--init.el2
-rw-r--r--modules/calendar-sync.el91
-rw-r--r--modules/keyboard-compat.el170
-rw-r--r--modules/terminal-compat.el54
-rw-r--r--tests/test-calendar-sync--convert-tz-to-local.el225
-rw-r--r--tests/test-calendar-sync--expand-weekly.el4
-rw-r--r--tests/test-calendar-sync--extract-tzid.el114
-rw-r--r--tests/test-flycheck-languagetool-setup.el12
-rw-r--r--tests/test-integration-calendar-sync-timezone.el263
-rw-r--r--tests/testutil-calendar-sync.el83
10 files changed, 944 insertions, 74 deletions
diff --git a/init.el b/init.el
index 9bb1f7fb..9757dc48 100644
--- a/init.el
+++ b/init.el
@@ -22,7 +22,7 @@
(require 'config-utilities) ;; enable for extra Emacs config debug helpers
(require 'user-constants) ;; paths for files referenced in this config
(require 'host-environment) ;; convenience functions re: host environment
-(require 'terminal-compat) ;; terminal/mosh compatibility fixes
+(require 'keyboard-compat) ;; terminal/GUI keyboard compatibility
(require 'system-defaults) ;; native comp; log; unicode, backup, exec path
(require 'keybindings) ;; system-wide keybindings and keybinding discovery
diff --git a/modules/calendar-sync.el b/modules/calendar-sync.el
index fa524f6a..582c482d 100644
--- a/modules/calendar-sync.el
+++ b/modules/calendar-sync.el
@@ -320,6 +320,25 @@ Returns nil if property not found."
(setq start (match-end 0)))
value)))
+(defun calendar-sync--get-property-line (event property)
+ "Extract full PROPERTY line from EVENT string, including parameters.
+Returns the complete line like 'DTSTART;TZID=Europe/Lisbon:20260202T190000'.
+Returns nil if property not found."
+ (when (string-match (format "^\\(%s[^\n]*\\)$" (regexp-quote property)) event)
+ (match-string 1 event)))
+
+(defun calendar-sync--extract-tzid (property-line)
+ "Extract TZID parameter value from PROPERTY-LINE.
+PROPERTY-LINE is like 'DTSTART;TZID=Europe/Lisbon:20260202T190000'.
+Returns timezone string like 'Europe/Lisbon', or nil if no TZID.
+Returns nil for malformed lines (missing colon separator)."
+ (when (and property-line
+ (stringp property-line)
+ ;; Must have colon (property:value format)
+ (string-match-p ":" property-line)
+ (string-match ";TZID=\\([^;:]+\\)" property-line))
+ (match-string 1 property-line)))
+
(defun calendar-sync--convert-utc-to-local (year month day hour minute second)
"Convert UTC datetime to local time.
Returns list (year month day hour minute) in local timezone."
@@ -331,10 +350,42 @@ Returns list (year month day hour minute) in local timezone."
(nth 2 local-time) ; hour
(nth 1 local-time)))) ; minute
-(defun calendar-sync--parse-timestamp (timestamp-str)
+(defun calendar-sync--convert-tz-to-local (year month day hour minute source-tz)
+ "Convert datetime from SOURCE-TZ timezone to local time.
+SOURCE-TZ is a timezone name like 'Europe/Lisbon' or 'Asia/Yerevan'.
+Returns list (year month day hour minute) in local timezone, or nil on error.
+
+Uses the system `date` command for reliable timezone conversion."
+ (when (and source-tz (not (string-empty-p source-tz)))
+ (condition-case err
+ (let* ((date-input (format "%04d-%02d-%02d %02d:%02d"
+ year month day hour minute))
+ ;; Use date command: convert from source-tz to local
+ ;; TZ= sets output timezone (local), TZ=\"...\" in -d sets input timezone
+ (cmd (format "date -d 'TZ=\"%s\" %s' '+%%Y %%m %%d %%H %%M' 2>/dev/null"
+ source-tz date-input))
+ (result (string-trim (shell-command-to-string cmd)))
+ (parts (split-string result " ")))
+ (if (= 5 (length parts))
+ (list (string-to-number (nth 0 parts))
+ (string-to-number (nth 1 parts))
+ (string-to-number (nth 2 parts))
+ (string-to-number (nth 3 parts))
+ (string-to-number (nth 4 parts)))
+ ;; date command failed (invalid timezone, etc.)
+ (cj/log-silently "calendar-sync: Failed to convert timezone %s: %s"
+ source-tz result)
+ nil))
+ (error
+ (cj/log-silently "calendar-sync: Error converting timezone %s: %s"
+ source-tz (error-message-string err))
+ nil))))
+
+(defun calendar-sync--parse-timestamp (timestamp-str &optional tzid)
"Parse iCal timestamp string TIMESTAMP-STR.
Returns (year month day hour minute) or (year month day) for all-day events.
Converts UTC times (ending in Z) to local time.
+If TZID is provided (e.g., 'Europe/Lisbon'), converts from that timezone to local.
Returns nil if parsing fails."
(cond
;; DateTime format: 20251116T140000Z or 20251116T140000
@@ -346,9 +397,18 @@ Returns nil if parsing fails."
(minute (string-to-number (match-string 5 timestamp-str)))
(second (string-to-number (match-string 6 timestamp-str)))
(is-utc (match-string 7 timestamp-str)))
- (if is-utc
- (calendar-sync--convert-utc-to-local year month day hour minute second)
- (list year month day hour minute))))
+ (cond
+ ;; UTC timestamp (Z suffix) - convert from UTC
+ (is-utc
+ (calendar-sync--convert-utc-to-local year month day hour minute second))
+ ;; TZID provided - convert from that timezone
+ (tzid
+ (or (calendar-sync--convert-tz-to-local year month day hour minute tzid)
+ ;; Fallback to raw time if conversion fails
+ (list year month day hour minute)))
+ ;; No timezone info - assume local time
+ (t
+ (list year month day hour minute)))))
;; Date format: 20251116
((string-match "\\([0-9]\\{4\\}\\)\\([0-9]\\{2\\}\\)\\([0-9]\\{2\\}\\)" timestamp-str)
(list (string-to-number (match-string 1 timestamp-str))
@@ -582,17 +642,24 @@ Returns list of event plists, or nil if not a recurring event."
"Parse single VEVENT string EVENT-STR into plist.
Returns plist with :summary :description :location :start :end.
Returns nil if event lacks required fields (DTSTART, SUMMARY).
-Skips events with RECURRENCE-ID (individual instances of recurring events)."
+Skips events with RECURRENCE-ID (individual instances of recurring events).
+Handles TZID-qualified timestamps by converting to local time."
;; Skip individual instances of recurring events (they're handled by RRULE expansion)
(unless (calendar-sync--get-property event-str "RECURRENCE-ID")
- (let ((summary (calendar-sync--get-property event-str "SUMMARY"))
- (description (calendar-sync--get-property event-str "DESCRIPTION"))
- (location (calendar-sync--get-property event-str "LOCATION"))
- (dtstart (calendar-sync--get-property event-str "DTSTART"))
- (dtend (calendar-sync--get-property event-str "DTEND")))
+ (let* ((summary (calendar-sync--get-property event-str "SUMMARY"))
+ (description (calendar-sync--get-property event-str "DESCRIPTION"))
+ (location (calendar-sync--get-property event-str "LOCATION"))
+ ;; Get raw property values
+ (dtstart (calendar-sync--get-property event-str "DTSTART"))
+ (dtend (calendar-sync--get-property event-str "DTEND"))
+ ;; Extract TZID from property lines (if present)
+ (dtstart-line (calendar-sync--get-property-line event-str "DTSTART"))
+ (dtend-line (calendar-sync--get-property-line event-str "DTEND"))
+ (start-tzid (calendar-sync--extract-tzid dtstart-line))
+ (end-tzid (calendar-sync--extract-tzid dtend-line)))
(when (and summary dtstart)
- (let ((start-parsed (calendar-sync--parse-timestamp dtstart))
- (end-parsed (and dtend (calendar-sync--parse-timestamp dtend))))
+ (let ((start-parsed (calendar-sync--parse-timestamp dtstart start-tzid))
+ (end-parsed (and dtend (calendar-sync--parse-timestamp dtend end-tzid))))
(when start-parsed
(list :summary summary
:description description
diff --git a/modules/keyboard-compat.el b/modules/keyboard-compat.el
new file mode 100644
index 00000000..9b277ba8
--- /dev/null
+++ b/modules/keyboard-compat.el
@@ -0,0 +1,170 @@
+;;; keyboard-compat.el --- Keyboard compatibility for terminal and GUI -*- lexical-binding: t; coding: utf-8; -*-
+;; author: Craig Jennings <c@cjennings.net>
+
+;;; Commentary:
+
+;; This module fixes keyboard input differences between terminal and GUI Emacs.
+;;
+;; THE PROBLEM: Meta+Shift keybindings behave differently in terminal vs GUI
+;; =========================================================================
+;;
+;; In Emacs, there are two ways to express "Meta + Shift + o":
+;;
+;; 1. M-O (Meta + uppercase O) - key code 134217807
+;; 2. M-S-o (Meta + explicit Shift modifier + lowercase o) - key code 167772271
+;;
+;; These are NOT the same key in Emacs!
+;;
+;; GUI Emacs behavior:
+;; When you press Meta+Shift+o on your keyboard, GUI Emacs receives M-O
+;; (uppercase O). It does NOT receive M-S-o. This is because the keyboard
+;; sends Shift+o as uppercase 'O', not as a Shift modifier plus lowercase 'o'.
+;;
+;; Terminal Emacs behavior:
+;; Terminals send escape sequences for special keys. Arrow keys send:
+;; - Up: ESC O A
+;; - Down: ESC O B
+;; - Right: ESC O C
+;; - Left: ESC O D
+;;
+;; The problem: ESC O is interpreted as M-O by Emacs! So if you bind M-O
+;; to a function, pressing the up arrow sends "ESC O A", Emacs sees "M-O"
+;; and triggers your function instead of moving up. Arrow keys break.
+;;
+;; THE SOLUTION: Different handling for each display type
+;; ======================================================
+;;
+;; For terminal mode (handled by cj/keyboard-compat-terminal-setup):
+;; - Use input-decode-map to translate arrow escape sequences BEFORE
+;; any keybinding lookup. ESC O A becomes [up], not M-O followed by A.
+;; - Keybindings use M-S-o syntax (some terminals support explicit Shift)
+;; - Disable graphical icons that show as unicode artifacts
+;;
+;; For GUI mode (handled by cj/keyboard-compat-gui-setup):
+;; - Use key-translation-map to translate M-O to M-S-o BEFORE lookup
+;; - This way, pressing Meta+Shift+o (which sends M-O) gets translated
+;; to M-S-o, matching the keybinding definitions
+;; - All 18 Meta+Shift keybindings work correctly
+;;
+;; WHY NOT JUST USE M-O FOR KEYBINDINGS?
+;; =====================================
+;;
+;; We could bind to M-O directly, but:
+;; 1. Terminal arrow keys would break (ESC O prefix conflict)
+;; 2. We'd need to maintain two sets of bindings (M-O for GUI, something
+;; else for terminal)
+;;
+;; By using M-S-o syntax everywhere and translating M-O -> M-S-o in GUI mode,
+;; we have one consistent set of keybindings that work everywhere.
+;;
+;; KEYBINDINGS AFFECTED:
+;; ====================
+;;
+;; The following M-S- keybindings are translated from M-uppercase in GUI:
+;;
+;; M-O -> M-S-o cj/kill-other-window (undead-buffers.el)
+;; M-M -> M-S-m cj/kill-all-other-buffers-and-windows (undead-buffers.el)
+;; M-Y -> M-S-y yank-media (keybindings.el)
+;; M-F -> M-S-f fontaine-set-preset (font-config.el)
+;; M-W -> M-S-w wttrin (weather-config.el)
+;; M-E -> M-S-e eww (eww-config.el)
+;; M-L -> M-S-l cj/switch-themes (ui-theme.el)
+;; M-R -> M-S-r cj/elfeed-open (elfeed-config.el)
+;; M-V -> M-S-v cj/split-and-follow-right (ui-navigation.el)
+;; M-H -> M-S-h cj/split-and-follow-below (ui-navigation.el)
+;; M-T -> M-S-t toggle-window-split (ui-navigation.el)
+;; M-S -> M-S-s window-swap-states (ui-navigation.el)
+;; M-Z -> M-S-z cj/undo-kill-buffer (ui-navigation.el)
+;; M-U -> M-S-u winner-undo (ui-navigation.el)
+;; M-D -> M-S-d dwim-shell-commands-menu (dwim-shell-config.el)
+;; M-I -> M-S-i edit-indirect-region (text-config.el)
+;; M-C -> M-S-c time-zones (chrono-tools.el)
+;; M-B -> M-S-b calibredb (calibredb-epub-config.el)
+;; M-K -> M-S-k show-kill-ring (show-kill-ring.el)
+
+;;; Code:
+
+(require 'host-environment)
+
+;; =============================================================================
+;; Terminal-specific fixes
+;; =============================================================================
+
+(defun cj/keyboard-compat-terminal-setup ()
+ "Set up keyboard compatibility for terminal/console mode.
+This runs after init to override any package settings."
+ (when (env-terminal-p)
+ ;; Fix arrow key escape sequences for various terminal types
+ ;; These must be decoded BEFORE keybinding lookup to prevent
+ ;; M-O prefix from intercepting arrow keys
+ (define-key input-decode-map "\e[A" [up])
+ (define-key input-decode-map "\e[B" [down])
+ (define-key input-decode-map "\e[C" [right])
+ (define-key input-decode-map "\e[D" [left])
+
+ ;; Application mode arrows (sent by some terminals like xterm)
+ (define-key input-decode-map "\eOA" [up])
+ (define-key input-decode-map "\eOB" [down])
+ (define-key input-decode-map "\eOC" [right])
+ (define-key input-decode-map "\eOD" [left])))
+
+;; Run after init completes to override any package settings
+(add-hook 'emacs-startup-hook #'cj/keyboard-compat-terminal-setup)
+
+;; Icon disabling only in terminal mode (prevents unicode artifacts)
+(when (env-terminal-p)
+ ;; Disable nerd-icons display (shows as \uXXXX artifacts)
+ (with-eval-after-load 'nerd-icons
+ (defun nerd-icons-icon-for-file (&rest _) "")
+ (defun nerd-icons-icon-for-dir (&rest _) "")
+ (defun nerd-icons-icon-for-mode (&rest _) "")
+ (defun nerd-icons-icon-for-buffer (&rest _) ""))
+
+ ;; Disable dashboard icons
+ (with-eval-after-load 'dashboard
+ (setq dashboard-display-icons-p nil)
+ (setq dashboard-set-file-icons nil)
+ (setq dashboard-set-heading-icons nil))
+
+ ;; Disable all-the-icons
+ (with-eval-after-load 'all-the-icons
+ (defun all-the-icons-icon-for-file (&rest _) "")
+ (defun all-the-icons-icon-for-dir (&rest _) "")
+ (defun all-the-icons-icon-for-mode (&rest _) "")))
+
+;; =============================================================================
+;; GUI-specific fixes
+;; =============================================================================
+
+(defun cj/keyboard-compat-gui-setup ()
+ "Set up keyboard compatibility for GUI mode.
+Translates M-uppercase keys to M-S-lowercase so that pressing
+Meta+Shift+letter triggers M-S-letter keybindings."
+ (when (env-gui-p)
+ ;; Translate M-O (what keyboard sends) to M-S-o (what keybindings use)
+ ;; key-translation-map runs before keybinding lookup
+ (define-key key-translation-map (kbd "M-O") (kbd "M-S-o"))
+ (define-key key-translation-map (kbd "M-M") (kbd "M-S-m"))
+ (define-key key-translation-map (kbd "M-Y") (kbd "M-S-y"))
+ (define-key key-translation-map (kbd "M-F") (kbd "M-S-f"))
+ (define-key key-translation-map (kbd "M-W") (kbd "M-S-w"))
+ (define-key key-translation-map (kbd "M-E") (kbd "M-S-e"))
+ (define-key key-translation-map (kbd "M-L") (kbd "M-S-l"))
+ (define-key key-translation-map (kbd "M-R") (kbd "M-S-r"))
+ (define-key key-translation-map (kbd "M-V") (kbd "M-S-v"))
+ (define-key key-translation-map (kbd "M-H") (kbd "M-S-h"))
+ (define-key key-translation-map (kbd "M-T") (kbd "M-S-t"))
+ (define-key key-translation-map (kbd "M-S") (kbd "M-S-s"))
+ (define-key key-translation-map (kbd "M-Z") (kbd "M-S-z"))
+ (define-key key-translation-map (kbd "M-U") (kbd "M-S-u"))
+ (define-key key-translation-map (kbd "M-D") (kbd "M-S-d"))
+ (define-key key-translation-map (kbd "M-I") (kbd "M-S-i"))
+ (define-key key-translation-map (kbd "M-C") (kbd "M-S-c"))
+ (define-key key-translation-map (kbd "M-B") (kbd "M-S-b"))
+ (define-key key-translation-map (kbd "M-K") (kbd "M-S-k"))))
+
+;; Run early - key-translation-map should be set up before keybindings
+(add-hook 'emacs-startup-hook #'cj/keyboard-compat-gui-setup)
+
+(provide 'keyboard-compat)
+;;; keyboard-compat.el ends here
diff --git a/modules/terminal-compat.el b/modules/terminal-compat.el
deleted file mode 100644
index f959646d..00000000
--- a/modules/terminal-compat.el
+++ /dev/null
@@ -1,54 +0,0 @@
-;;; terminal-compat.el --- Terminal compatibility fixes -*- lexical-binding: t; coding: utf-8; -*-
-;; author: Craig Jennings <c@cjennings.net>
-
-;;; Commentary:
-
-;; Fixes for running Emacs in terminal/console mode, especially over mosh.
-;; - Arrow key escape sequence handling
-;; - Disable graphical icons that show as unicode artifacts
-
-;;; Code:
-
-(require 'host-environment)
-
-(defun cj/terminal-compat-setup ()
- "Set up terminal compatibility after init completes."
- (when (env-terminal-p)
- ;; Fix arrow key escape sequences for various terminal types
- (define-key input-decode-map "\e[A" [up])
- (define-key input-decode-map "\e[B" [down])
- (define-key input-decode-map "\e[C" [right])
- (define-key input-decode-map "\e[D" [left])
-
- ;; Application mode arrows (sent by some terminals)
- (define-key input-decode-map "\eOA" [up])
- (define-key input-decode-map "\eOB" [down])
- (define-key input-decode-map "\eOC" [right])
- (define-key input-decode-map "\eOD" [left])))
-
-;; Run after init completes to override any package settings
-(add-hook 'emacs-startup-hook #'cj/terminal-compat-setup)
-
-;; Icon disabling only in terminal mode
-(when (env-terminal-p)
- ;; Disable nerd-icons display (shows as \uXXXX artifacts)
- (with-eval-after-load 'nerd-icons
- (defun nerd-icons-icon-for-file (&rest _) "")
- (defun nerd-icons-icon-for-dir (&rest _) "")
- (defun nerd-icons-icon-for-mode (&rest _) "")
- (defun nerd-icons-icon-for-buffer (&rest _) ""))
-
- ;; Disable dashboard icons
- (with-eval-after-load 'dashboard
- (setq dashboard-display-icons-p nil)
- (setq dashboard-set-file-icons nil)
- (setq dashboard-set-heading-icons nil))
-
- ;; Disable all-the-icons
- (with-eval-after-load 'all-the-icons
- (defun all-the-icons-icon-for-file (&rest _) "")
- (defun all-the-icons-icon-for-dir (&rest _) "")
- (defun all-the-icons-icon-for-mode (&rest _) "")))
-
-(provide 'terminal-compat)
-;;; terminal-compat.el ends here
diff --git a/tests/test-calendar-sync--convert-tz-to-local.el b/tests/test-calendar-sync--convert-tz-to-local.el
new file mode 100644
index 00000000..cf45aa61
--- /dev/null
+++ b/tests/test-calendar-sync--convert-tz-to-local.el
@@ -0,0 +1,225 @@
+;;; test-calendar-sync--convert-tz-to-local.el --- Tests for timezone conversion -*- lexical-binding: t; -*-
+
+;;; Commentary:
+;; Unit tests for calendar-sync--convert-tz-to-local function.
+;; Tests conversion from named timezones to local time.
+;; Uses `date` command as reference implementation for verification.
+;; Covers Normal, Boundary, and Error cases.
+
+;;; Code:
+
+(require 'ert)
+(require 'calendar-sync)
+(require 'testutil-calendar-sync)
+
+;;; Normal Cases
+
+(ert-deftest test-calendar-sync--convert-tz-to-local-normal-lisbon-to-local ()
+ "Test converting Europe/Lisbon time to local.
+Europe/Lisbon is UTC+0 in winter, UTC+1 in summer.
+Uses date command as reference for expected result."
+ (let* ((source-tz "Europe/Lisbon")
+ (year 2026) (month 2) (day 2) (hour 19) (minute 0)
+ ;; Get expected result from date command
+ (expected (test-calendar-sync-convert-tz-via-date
+ year month day hour minute source-tz))
+ (result (calendar-sync--convert-tz-to-local
+ year month day hour minute source-tz)))
+ (should expected) ; Sanity check that date command worked
+ (should result)
+ (should (equal expected result))))
+
+(ert-deftest test-calendar-sync--convert-tz-to-local-normal-yerevan-to-local ()
+ "Test converting Asia/Yerevan time to local.
+Asia/Yerevan is UTC+4 year-round."
+ (let* ((source-tz "Asia/Yerevan")
+ (year 2026) (month 2) (day 2) (hour 20) (minute 0)
+ (expected (test-calendar-sync-convert-tz-via-date
+ year month day hour minute source-tz))
+ (result (calendar-sync--convert-tz-to-local
+ year month day hour minute source-tz)))
+ (should expected)
+ (should result)
+ (should (equal expected result))))
+
+(ert-deftest test-calendar-sync--convert-tz-to-local-normal-utc-to-local ()
+ "Test converting UTC time to local."
+ (let* ((source-tz "UTC")
+ (year 2026) (month 2) (day 2) (hour 19) (minute 0)
+ (expected (test-calendar-sync-convert-tz-via-date
+ year month day hour minute source-tz))
+ (result (calendar-sync--convert-tz-to-local
+ year month day hour minute source-tz)))
+ (should expected)
+ (should result)
+ (should (equal expected result))))
+
+(ert-deftest test-calendar-sync--convert-tz-to-local-normal-new-york-to-local ()
+ "Test converting America/New_York time to local."
+ (let* ((source-tz "America/New_York")
+ (year 2026) (month 2) (day 2) (hour 14) (minute 30)
+ (expected (test-calendar-sync-convert-tz-via-date
+ year month day hour minute source-tz))
+ (result (calendar-sync--convert-tz-to-local
+ year month day hour minute source-tz)))
+ (should expected)
+ (should result)
+ (should (equal expected result))))
+
+(ert-deftest test-calendar-sync--convert-tz-to-local-normal-tokyo-to-local ()
+ "Test converting Asia/Tokyo time to local.
+Asia/Tokyo is UTC+9 year-round (no DST)."
+ (let* ((source-tz "Asia/Tokyo")
+ (year 2026) (month 2) (day 2) (hour 10) (minute 0)
+ (expected (test-calendar-sync-convert-tz-via-date
+ year month day hour minute source-tz))
+ (result (calendar-sync--convert-tz-to-local
+ year month day hour minute source-tz)))
+ (should expected)
+ (should result)
+ (should (equal expected result))))
+
+;;; Boundary Cases
+
+(ert-deftest test-calendar-sync--convert-tz-to-local-boundary-crosses-date-forward ()
+ "Test conversion that crosses to next day.
+Late evening in Europe becomes next day morning in Americas."
+ (let* ((source-tz "Europe/London")
+ (year 2026) (month 2) (day 2) (hour 23) (minute 30)
+ (expected (test-calendar-sync-convert-tz-via-date
+ year month day hour minute source-tz))
+ (result (calendar-sync--convert-tz-to-local
+ year month day hour minute source-tz)))
+ (should expected)
+ (should result)
+ (should (equal expected result))))
+
+(ert-deftest test-calendar-sync--convert-tz-to-local-boundary-crosses-date-backward ()
+ "Test conversion that crosses to previous day.
+Early morning in Asia becomes previous day evening in Americas."
+ (let* ((source-tz "Asia/Tokyo")
+ (year 2026) (month 2) (day 3) (hour 2) (minute 0)
+ (expected (test-calendar-sync-convert-tz-via-date
+ year month day hour minute source-tz))
+ (result (calendar-sync--convert-tz-to-local
+ year month day hour minute source-tz)))
+ (should expected)
+ (should result)
+ (should (equal expected result))))
+
+(ert-deftest test-calendar-sync--convert-tz-to-local-boundary-midnight ()
+ "Test conversion of midnight (00:00) in source timezone."
+ (let* ((source-tz "Europe/Paris")
+ (year 2026) (month 2) (day 2) (hour 0) (minute 0)
+ (expected (test-calendar-sync-convert-tz-via-date
+ year month day hour minute source-tz))
+ (result (calendar-sync--convert-tz-to-local
+ year month day hour minute source-tz)))
+ (should expected)
+ (should result)
+ (should (equal expected result))))
+
+(ert-deftest test-calendar-sync--convert-tz-to-local-boundary-2359 ()
+ "Test conversion of 23:59 in source timezone."
+ (let* ((source-tz "Europe/Berlin")
+ (year 2026) (month 2) (day 2) (hour 23) (minute 59)
+ (expected (test-calendar-sync-convert-tz-via-date
+ year month day hour minute source-tz))
+ (result (calendar-sync--convert-tz-to-local
+ year month day hour minute source-tz)))
+ (should expected)
+ (should result)
+ (should (equal expected result))))
+
+(ert-deftest test-calendar-sync--convert-tz-to-local-boundary-dst-spring-forward ()
+ "Test conversion during US DST spring-forward transition.
+March 8, 2026 at 2:30 AM doesn't exist in America/Chicago (skipped)."
+ ;; Use a time AFTER the transition to avoid the gap
+ (let* ((source-tz "America/New_York")
+ (year 2026) (month 3) (day 8) (hour 15) (minute 0)
+ (expected (test-calendar-sync-convert-tz-via-date
+ year month day hour minute source-tz))
+ (result (calendar-sync--convert-tz-to-local
+ year month day hour minute source-tz)))
+ (should expected)
+ (should result)
+ (should (equal expected result))))
+
+(ert-deftest test-calendar-sync--convert-tz-to-local-boundary-dst-fall-back ()
+ "Test conversion during fall-back DST transition.
+November 1, 2026 at 1:30 AM exists twice in America/Chicago."
+ (let* ((source-tz "America/New_York")
+ (year 2026) (month 11) (day 1) (hour 14) (minute 0)
+ (expected (test-calendar-sync-convert-tz-via-date
+ year month day hour minute source-tz))
+ (result (calendar-sync--convert-tz-to-local
+ year month day hour minute source-tz)))
+ (should expected)
+ (should result)
+ (should (equal expected result))))
+
+(ert-deftest test-calendar-sync--convert-tz-to-local-boundary-etc-gmt-plus ()
+ "Test conversion from Etc/GMT+5 (note: Etc/GMT+N is UTC-N)."
+ (let* ((source-tz "Etc/GMT+5")
+ (year 2026) (month 2) (day 2) (hour 12) (minute 0)
+ (expected (test-calendar-sync-convert-tz-via-date
+ year month day hour minute source-tz))
+ (result (calendar-sync--convert-tz-to-local
+ year month day hour minute source-tz)))
+ (should expected)
+ (should result)
+ (should (equal expected result))))
+
+(ert-deftest test-calendar-sync--convert-tz-to-local-boundary-month-boundary ()
+ "Test conversion that crosses month boundary."
+ (let* ((source-tz "Pacific/Auckland") ; UTC+12/+13
+ (year 2026) (month 2) (day 1) (hour 5) (minute 0)
+ (expected (test-calendar-sync-convert-tz-via-date
+ year month day hour minute source-tz))
+ (result (calendar-sync--convert-tz-to-local
+ year month day hour minute source-tz)))
+ (should expected)
+ (should result)
+ (should (equal expected result))))
+
+(ert-deftest test-calendar-sync--convert-tz-to-local-boundary-year-boundary ()
+ "Test conversion that crosses year boundary."
+ (let* ((source-tz "Pacific/Auckland")
+ (year 2026) (month 1) (day 1) (hour 5) (minute 0)
+ (expected (test-calendar-sync-convert-tz-via-date
+ year month day hour minute source-tz))
+ (result (calendar-sync--convert-tz-to-local
+ year month day hour minute source-tz)))
+ (should expected)
+ (should result)
+ (should (equal expected result))))
+
+;;; Error Cases
+
+(ert-deftest test-calendar-sync--convert-tz-to-local-boundary-invalid-timezone-falls-back ()
+ "Test that invalid timezone falls back to treating time as local.
+The `date` command doesn't error on unrecognized timezones - it ignores
+the TZ specification and treats the input as local time. This is acceptable
+because calendar providers (Google, Proton) always use valid IANA timezones.
+This test documents the fallback behavior rather than testing for nil."
+ (let ((result (calendar-sync--convert-tz-to-local
+ 2026 2 2 19 0 "Invalid/Timezone")))
+ ;; Should return something (falls back to local interpretation)
+ (should result)
+ ;; The time values should be present (year month day hour minute)
+ (should (= 5 (length result)))))
+
+(ert-deftest test-calendar-sync--convert-tz-to-local-error-nil-timezone ()
+ "Test that nil timezone returns nil."
+ (let ((result (calendar-sync--convert-tz-to-local
+ 2026 2 2 19 0 nil)))
+ (should (null result))))
+
+(ert-deftest test-calendar-sync--convert-tz-to-local-error-empty-timezone ()
+ "Test that empty timezone string returns nil."
+ (let ((result (calendar-sync--convert-tz-to-local
+ 2026 2 2 19 0 "")))
+ (should (null result))))
+
+(provide 'test-calendar-sync--convert-tz-to-local)
+;;; test-calendar-sync--convert-tz-to-local.el ends here
diff --git a/tests/test-calendar-sync--expand-weekly.el b/tests/test-calendar-sync--expand-weekly.el
index d7b0eddc..fe333c98 100644
--- a/tests/test-calendar-sync--expand-weekly.el
+++ b/tests/test-calendar-sync--expand-weekly.el
@@ -24,9 +24,7 @@
;;; Normal Cases
(ert-deftest test-calendar-sync--expand-weekly-normal-saturday-returns-occurrences ()
- "Test expanding weekly event on Saturday (GTFO use case).
-Known issue: Timezone calculation may cause off-by-one day error."
- :expected-result :failed
+ "Test expanding weekly event on Saturday (GTFO use case)."
(test-calendar-sync--expand-weekly-setup)
(unwind-protect
(let* ((start-date (test-calendar-sync-time-days-from-now 1 10 30))
diff --git a/tests/test-calendar-sync--extract-tzid.el b/tests/test-calendar-sync--extract-tzid.el
new file mode 100644
index 00000000..d16aae40
--- /dev/null
+++ b/tests/test-calendar-sync--extract-tzid.el
@@ -0,0 +1,114 @@
+;;; test-calendar-sync--extract-tzid.el --- Tests for calendar-sync--extract-tzid -*- lexical-binding: t; -*-
+
+;;; Commentary:
+;; Unit tests for calendar-sync--extract-tzid function.
+;; Tests extraction of TZID parameter from iCal property lines.
+;; Covers Normal, Boundary, and Error cases.
+
+;;; Code:
+
+(require 'ert)
+(require 'calendar-sync)
+
+;;; Normal Cases
+
+(ert-deftest test-calendar-sync--extract-tzid-normal-europe-lisbon ()
+ "Test extracting TZID=Europe/Lisbon from standard DTSTART."
+ (let ((prop-line "DTSTART;TZID=Europe/Lisbon:20260202T190000"))
+ (should (string= "Europe/Lisbon"
+ (calendar-sync--extract-tzid prop-line)))))
+
+(ert-deftest test-calendar-sync--extract-tzid-normal-asia-yerevan ()
+ "Test extracting TZID=Asia/Yerevan from DTSTART."
+ (let ((prop-line "DTSTART;TZID=Asia/Yerevan:20230801T200000"))
+ (should (string= "Asia/Yerevan"
+ (calendar-sync--extract-tzid prop-line)))))
+
+(ert-deftest test-calendar-sync--extract-tzid-normal-america-chicago ()
+ "Test extracting TZID=America/Chicago from DTSTART."
+ (let ((prop-line "DTSTART;TZID=America/Chicago:20230721T160000"))
+ (should (string= "America/Chicago"
+ (calendar-sync--extract-tzid prop-line)))))
+
+(ert-deftest test-calendar-sync--extract-tzid-normal-dtend ()
+ "Test extracting TZID from DTEND property."
+ (let ((prop-line "DTEND;TZID=America/New_York:20260202T200000"))
+ (should (string= "America/New_York"
+ (calendar-sync--extract-tzid prop-line)))))
+
+(ert-deftest test-calendar-sync--extract-tzid-normal-multiple-params ()
+ "Test extracting TZID when other parameters present."
+ (let ((prop-line "DTSTART;VALUE=DATE-TIME;TZID=UTC:20260202T190000"))
+ (should (string= "UTC"
+ (calendar-sync--extract-tzid prop-line)))))
+
+;;; Boundary Cases
+
+(ert-deftest test-calendar-sync--extract-tzid-boundary-underscore-in-name ()
+ "Test extracting TZID with underscore (America/New_York)."
+ (let ((prop-line "DTSTART;TZID=America/New_York:20260202T190000"))
+ (should (string= "America/New_York"
+ (calendar-sync--extract-tzid prop-line)))))
+
+(ert-deftest test-calendar-sync--extract-tzid-boundary-etc-gmt-plus ()
+ "Test extracting TZID with plus sign (Etc/GMT+5)."
+ (let ((prop-line "DTSTART;TZID=Etc/GMT+5:20260202T190000"))
+ (should (string= "Etc/GMT+5"
+ (calendar-sync--extract-tzid prop-line)))))
+
+(ert-deftest test-calendar-sync--extract-tzid-boundary-etc-gmt-minus ()
+ "Test extracting TZID with minus sign (Etc/GMT-5)."
+ (let ((prop-line "DTSTART;TZID=Etc/GMT-5:20260202T190000"))
+ (should (string= "Etc/GMT-5"
+ (calendar-sync--extract-tzid prop-line)))))
+
+(ert-deftest test-calendar-sync--extract-tzid-boundary-tzid-after-other-params ()
+ "Test extracting TZID when it appears after other parameters."
+ (let ((prop-line "DTSTART;VALUE=DATE-TIME;TZID=Asia/Tokyo:20260202T190000"))
+ (should (string= "Asia/Tokyo"
+ (calendar-sync--extract-tzid prop-line)))))
+
+(ert-deftest test-calendar-sync--extract-tzid-boundary-tzid-before-other-params ()
+ "Test extracting TZID when it appears before other parameters."
+ (let ((prop-line "DTSTART;TZID=Pacific/Auckland;VALUE=DATE-TIME:20260202T190000"))
+ (should (string= "Pacific/Auckland"
+ (calendar-sync--extract-tzid prop-line)))))
+
+(ert-deftest test-calendar-sync--extract-tzid-boundary-long-timezone-name ()
+ "Test extracting TZID with long timezone name."
+ (let ((prop-line "DTSTART;TZID=America/Argentina/Buenos_Aires:20260202T190000"))
+ (should (string= "America/Argentina/Buenos_Aires"
+ (calendar-sync--extract-tzid prop-line)))))
+
+;;; Error Cases
+
+(ert-deftest test-calendar-sync--extract-tzid-error-no-tzid-utc ()
+ "Test that UTC timestamp (with Z) returns nil for TZID."
+ (let ((prop-line "DTSTART:20260202T190000Z"))
+ (should (null (calendar-sync--extract-tzid prop-line)))))
+
+(ert-deftest test-calendar-sync--extract-tzid-error-no-tzid-local ()
+ "Test that local timestamp (no Z, no TZID) returns nil."
+ (let ((prop-line "DTSTART:20260202T190000"))
+ (should (null (calendar-sync--extract-tzid prop-line)))))
+
+(ert-deftest test-calendar-sync--extract-tzid-error-empty-string ()
+ "Test that empty string returns nil."
+ (should (null (calendar-sync--extract-tzid ""))))
+
+(ert-deftest test-calendar-sync--extract-tzid-error-nil-input ()
+ "Test that nil input returns nil."
+ (should (null (calendar-sync--extract-tzid nil))))
+
+(ert-deftest test-calendar-sync--extract-tzid-error-value-date-only ()
+ "Test that VALUE=DATE (all-day event) returns nil."
+ (let ((prop-line "DTSTART;VALUE=DATE:20260202"))
+ (should (null (calendar-sync--extract-tzid prop-line)))))
+
+(ert-deftest test-calendar-sync--extract-tzid-error-malformed-no-colon ()
+ "Test that malformed line without colon returns nil."
+ (let ((prop-line "DTSTART;TZID=Europe/Lisbon"))
+ (should (null (calendar-sync--extract-tzid prop-line)))))
+
+(provide 'test-calendar-sync--extract-tzid)
+;;; test-calendar-sync--extract-tzid.el ends here
diff --git a/tests/test-flycheck-languagetool-setup.el b/tests/test-flycheck-languagetool-setup.el
index a719e822..aa71d4a7 100644
--- a/tests/test-flycheck-languagetool-setup.el
+++ b/tests/test-flycheck-languagetool-setup.el
@@ -29,12 +29,16 @@
(should (file-executable-p wrapper-path))))
(ert-deftest test-flycheck-languagetool-setup-normal-languagetool-installed ()
- "Test that languagetool command is available in PATH."
- (should (executable-find "languagetool")))
+ "Test that languagetool command is available in PATH.
+The test failure serves as a reminder to install the dependency."
+ (should (or (executable-find "languagetool")
+ (error "LanguageTool not installed. Install with: sudo pacman -S languagetool"))))
(ert-deftest test-flycheck-languagetool-setup-normal-python3-available ()
- "Test that python3 is available for wrapper script."
- (should (executable-find "python3")))
+ "Test that python3 is available for wrapper script.
+The test failure serves as a reminder to install the dependency."
+ (should (or (executable-find "python3")
+ (error "python3 not installed. Install with: sudo pacman -S python"))))
;; ----------------------------- Boundary Cases --------------------------------
diff --git a/tests/test-integration-calendar-sync-timezone.el b/tests/test-integration-calendar-sync-timezone.el
new file mode 100644
index 00000000..304d3233
--- /dev/null
+++ b/tests/test-integration-calendar-sync-timezone.el
@@ -0,0 +1,263 @@
+;;; test-integration-calendar-sync-timezone.el --- Integration tests for timezone handling -*- lexical-binding: t; -*-
+
+;;; Commentary:
+;; Integration tests for calendar-sync timezone conversion workflow.
+;; Tests the complete flow from ICS with TZID to correct local time in org output.
+;;
+;; Components integrated:
+;; - calendar-sync--extract-tzid (TZID extraction from property lines)
+;; - calendar-sync--convert-tz-to-local (timezone conversion)
+;; - calendar-sync--get-property (property extraction, now with TZID awareness)
+;; - calendar-sync--parse-timestamp (timestamp parsing with timezone)
+;; - calendar-sync--parse-event (full event parsing)
+;; - calendar-sync--event-to-org (org format output)
+;; - calendar-sync--parse-ics (full ICS parsing)
+;;
+;; Validates:
+;; - TZID is extracted from property lines and passed through workflow
+;; - Timezone conversion produces correct local times
+;; - Final org timestamps reflect local time, not source timezone
+;; - Multiple timezones in same ICS are handled independently
+
+;;; Code:
+
+(require 'ert)
+(require 'calendar-sync)
+(require 'testutil-calendar-sync)
+
+;;; Test Data
+
+(defun test-integration-tz--make-ics-with-tzid-event (summary start-time tzid)
+ "Create minimal ICS with single TZID-qualified event.
+START-TIME is (year month day hour minute).
+Returns complete ICS string."
+ (let* ((dtstart (format "%04d%02d%02dT%02d%02d00"
+ (nth 0 start-time) (nth 1 start-time) (nth 2 start-time)
+ (nth 3 start-time) (nth 4 start-time)))
+ (dtend (format "%04d%02d%02dT%02d%02d00"
+ (nth 0 start-time) (nth 1 start-time) (nth 2 start-time)
+ (1+ (nth 3 start-time)) (nth 4 start-time))))
+ (concat "BEGIN:VCALENDAR\n"
+ "VERSION:2.0\n"
+ "PRODID:-//Test//Test//EN\n"
+ "BEGIN:VEVENT\n"
+ "SUMMARY:" summary "\n"
+ "DTSTART;TZID=" tzid ":" dtstart "\n"
+ "DTEND;TZID=" tzid ":" dtend "\n"
+ "END:VEVENT\n"
+ "END:VCALENDAR")))
+
+(defun test-integration-tz--make-mixed-ics ()
+ "Create ICS with events in different timezone formats.
+Returns ICS with: UTC event, TZID event, and local event."
+ (let* ((time1 (test-calendar-sync-time-days-from-now 7 14 0))
+ (time2 (test-calendar-sync-time-days-from-now 7 19 0))
+ (time3 (test-calendar-sync-time-days-from-now 7 10 0)))
+ (concat "BEGIN:VCALENDAR\n"
+ "VERSION:2.0\n"
+ "PRODID:-//Test//Test//EN\n"
+ ;; Event 1: UTC (Z suffix)
+ "BEGIN:VEVENT\n"
+ "SUMMARY:UTC Event\n"
+ "DTSTART:" (test-calendar-sync-ics-datetime time1) "\n"
+ "DTEND:" (test-calendar-sync-ics-datetime
+ (list (nth 0 time1) (nth 1 time1) (nth 2 time1)
+ (1+ (nth 3 time1)) (nth 4 time1))) "\n"
+ "END:VEVENT\n"
+ ;; Event 2: TZID-qualified (Europe/Lisbon)
+ "BEGIN:VEVENT\n"
+ "SUMMARY:Lisbon Event\n"
+ "DTSTART;TZID=Europe/Lisbon:" (test-calendar-sync-ics-datetime-local time2) "\n"
+ "DTEND;TZID=Europe/Lisbon:" (test-calendar-sync-ics-datetime-local
+ (list (nth 0 time2) (nth 1 time2) (nth 2 time2)
+ (1+ (nth 3 time2)) (nth 4 time2))) "\n"
+ "END:VEVENT\n"
+ ;; Event 3: Local (no Z, no TZID)
+ "BEGIN:VEVENT\n"
+ "SUMMARY:Local Event\n"
+ "DTSTART:" (test-calendar-sync-ics-datetime-local time3) "\n"
+ "DTEND:" (test-calendar-sync-ics-datetime-local
+ (list (nth 0 time3) (nth 1 time3) (nth 2 time3)
+ (1+ (nth 3 time3)) (nth 4 time3))) "\n"
+ "END:VEVENT\n"
+ "END:VCALENDAR")))
+
+;;; Integration Tests - Full Workflow
+
+(ert-deftest test-integration-timezone-lisbon-event-converts-to-local ()
+ "Test that Europe/Lisbon event is converted to local time.
+
+When an event has DTSTART;TZID=Europe/Lisbon:20260202T190000, the parsed
+event should have local time (e.g., 13:00 CST), not the original 19:00.
+
+Components integrated:
+- calendar-sync--split-events (event extraction)
+- calendar-sync--get-property (property with TZID)
+- calendar-sync--extract-tzid (TZID parameter extraction)
+- calendar-sync--parse-timestamp (parsing with timezone conversion)
+- calendar-sync--convert-tz-to-local (actual timezone conversion)
+- calendar-sync--parse-event (full event plist)
+
+Validates:
+- TZID is detected and passed to conversion function
+- Conversion uses correct offset (Lisbon winter = UTC+0)
+- Result contains local hour, not source timezone hour"
+ (let* ((source-hour 19)
+ (source-time (list 2026 2 2 source-hour 0))
+ (ics (test-integration-tz--make-ics-with-tzid-event
+ "Lisbon Meeting" source-time "Europe/Lisbon"))
+ ;; Calculate expected local time
+ (expected-local (test-calendar-sync-convert-tz-via-date
+ 2026 2 2 source-hour 0 "Europe/Lisbon"))
+ (expected-local-hour (nth 3 expected-local)))
+ ;; Sanity check: local hour should differ from source
+ ;; (unless we happen to be in Lisbon, which is unlikely)
+ (should expected-local)
+ ;; Parse the ICS and check the event
+ (let* ((events (calendar-sync--split-events ics))
+ (event-str (car events))
+ (parsed (calendar-sync--parse-event event-str)))
+ (should parsed)
+ (should (string= "Lisbon Meeting" (plist-get parsed :summary)))
+ (let* ((start (plist-get parsed :start))
+ (result-hour (nth 3 start)))
+ ;; The hour should be the LOCAL hour, not the source hour
+ (should (= expected-local-hour result-hour))))))
+
+(ert-deftest test-integration-timezone-yerevan-event-converts-to-local ()
+ "Test that Asia/Yerevan event is converted to local time.
+
+Asia/Yerevan is UTC+4 year-round, so 20:00 Yerevan = 16:00 UTC.
+For US Central (UTC-6), that's 10:00 local.
+
+Components integrated:
+- calendar-sync--split-events
+- calendar-sync--get-property
+- calendar-sync--extract-tzid
+- calendar-sync--parse-timestamp
+- calendar-sync--convert-tz-to-local
+- calendar-sync--parse-event
+
+Validates:
+- Large timezone offset (10 hours from Yerevan to US Central) handled
+- Date may change during conversion (handled correctly)"
+ (let* ((source-hour 20)
+ (source-time (list 2026 2 2 source-hour 0))
+ (ics (test-integration-tz--make-ics-with-tzid-event
+ "Yerevan Call" source-time "Asia/Yerevan"))
+ (expected-local (test-calendar-sync-convert-tz-via-date
+ 2026 2 2 source-hour 0 "Asia/Yerevan"))
+ (expected-local-hour (nth 3 expected-local)))
+ (should expected-local)
+ (let* ((events (calendar-sync--split-events ics))
+ (parsed (calendar-sync--parse-event (car events))))
+ (should parsed)
+ (let* ((start (plist-get parsed :start))
+ (result-hour (nth 3 start)))
+ (should (= expected-local-hour result-hour))))))
+
+(ert-deftest test-integration-timezone-mixed-formats-all-convert ()
+ "Test ICS with UTC, TZID, and local timestamps all parse correctly.
+
+Components integrated:
+- calendar-sync--parse-ics (full ICS parsing)
+- All timestamp parsing and conversion functions
+
+Validates:
+- UTC events (Z suffix) convert to local
+- TZID events convert from source timezone to local
+- Local events (no Z, no TZID) remain unchanged
+- All three formats can coexist in same ICS"
+ (let* ((ics (test-integration-tz--make-mixed-ics))
+ (org-output (calendar-sync--parse-ics ics)))
+ (should org-output)
+ ;; Should contain all three events
+ (should (string-match-p "UTC Event" org-output))
+ (should (string-match-p "Lisbon Event" org-output))
+ (should (string-match-p "Local Event" org-output))
+ ;; Each should have valid org timestamps
+ (should (string-match-p "<[0-9]+-[0-9]+-[0-9]+ [A-Za-z]+" org-output))))
+
+(ert-deftest test-integration-timezone-org-timestamp-format-correct ()
+ "Test that final org output has correctly formatted local timestamp.
+
+Components integrated:
+- Full parsing pipeline through calendar-sync--event-to-org
+- calendar-sync--format-timestamp
+
+Validates:
+- Org timestamp format is correct (<YYYY-MM-DD Day HH:MM-HH:MM>)
+- Hour in timestamp is the converted local hour"
+ (let* ((source-time (list 2026 2 2 19 0))
+ (ics (test-integration-tz--make-ics-with-tzid-event
+ "Test Event" source-time "Europe/Lisbon"))
+ (expected-local (test-calendar-sync-convert-tz-via-date
+ 2026 2 2 19 0 "Europe/Lisbon"))
+ (expected-hour (nth 3 expected-local))
+ (org-output (calendar-sync--parse-ics ics)))
+ (should org-output)
+ (should (string-match-p "Test Event" org-output))
+ ;; Check that the timestamp contains the expected local hour
+ (let ((hour-pattern (format "%02d:" expected-hour)))
+ (should (string-match-p hour-pattern org-output)))))
+
+(ert-deftest test-integration-timezone-date-change-handled ()
+ "Test that timezone conversion crossing date boundary is handled.
+
+When converting late evening in Europe to US time, the date may change.
+e.g., 23:00 London on Feb 2 = 17:00 CST on Feb 2 (same day)
+but 02:00 Tokyo on Feb 3 = previous day in US
+
+Components integrated:
+- Full parsing pipeline
+- Date arithmetic in timezone conversion
+
+Validates:
+- Date changes during timezone conversion are reflected in output
+- Year/month boundaries are handled correctly"
+ (let* ((source-time (list 2026 2 3 2 0)) ; 2 AM Tokyo on Feb 3
+ (ics (test-integration-tz--make-ics-with-tzid-event
+ "Early Tokyo Meeting" source-time "Asia/Tokyo"))
+ (expected-local (test-calendar-sync-convert-tz-via-date
+ 2026 2 3 2 0 "Asia/Tokyo"))
+ (expected-day (nth 2 expected-local)))
+ (should expected-local)
+ (let* ((events (calendar-sync--split-events ics))
+ (parsed (calendar-sync--parse-event (car events))))
+ (should parsed)
+ (let* ((start (plist-get parsed :start))
+ (result-day (nth 2 start)))
+ ;; Day should match expected (may be Feb 2 instead of Feb 3)
+ (should (= expected-day result-day))))))
+
+(ert-deftest test-integration-timezone-utc-still-works ()
+ "Test that UTC timestamps (Z suffix) still convert correctly.
+
+Regression test to ensure TZID handling doesn't break existing UTC conversion.
+
+Components integrated:
+- calendar-sync--parse-timestamp (UTC path)
+- calendar-sync--convert-utc-to-local
+
+Validates:
+- Z suffix timestamps still trigger UTC-to-local conversion
+- Behavior unchanged from before TZID feature"
+ (let* ((utc-time (list 2026 2 2 19 0))
+ (event (test-calendar-sync-make-vevent
+ "UTC Meeting"
+ utc-time
+ (list 2026 2 2 20 0)))
+ (ics (test-calendar-sync-make-ics event))
+ ;; UTC conversion: 19:00 UTC to local
+ (utc-as-time (encode-time 0 0 19 2 2 2026 0))
+ (local-decoded (decode-time utc-as-time))
+ (expected-hour (nth 2 local-decoded)))
+ (let* ((events (calendar-sync--split-events ics))
+ (parsed (calendar-sync--parse-event (car events))))
+ (should parsed)
+ (let* ((start (plist-get parsed :start))
+ (result-hour (nth 3 start)))
+ (should (= expected-hour result-hour))))))
+
+(provide 'test-integration-calendar-sync-timezone)
+;;; test-integration-calendar-sync-timezone.el ends here
diff --git a/tests/testutil-calendar-sync.el b/tests/testutil-calendar-sync.el
index d1a94b01..2187c56c 100644
--- a/tests/testutil-calendar-sync.el
+++ b/tests/testutil-calendar-sync.el
@@ -194,5 +194,88 @@ DATE is (year month day) or (year month day hour minute)."
(minute (or (nth 4 date) 0)))
(encode-time 0 minute hour day month year)))
+;;; Timezone Test Helpers
+
+(defun test-calendar-sync-ics-datetime-local (time-list)
+ "Convert TIME-LIST to iCal DATETIME format WITHOUT Z suffix.
+TIME-LIST is (year month day hour minute).
+Returns string like '20251116T140000' (no timezone, treated as local)."
+ (format "%04d%02d%02dT%02d%02d00"
+ (nth 0 time-list)
+ (nth 1 time-list)
+ (nth 2 time-list)
+ (nth 3 time-list)
+ (nth 4 time-list)))
+
+(defun test-calendar-sync-ics-datetime-with-tzid (time-list tzid)
+ "Convert TIME-LIST to iCal DTSTART with TZID parameter.
+TIME-LIST is (year month day hour minute).
+TZID is timezone string like \"Europe/Lisbon\".
+Returns string like 'DTSTART;TZID=Europe/Lisbon:20260202T190000'."
+ (format "DTSTART;TZID=%s:%04d%02d%02dT%02d%02d00"
+ tzid
+ (nth 0 time-list)
+ (nth 1 time-list)
+ (nth 2 time-list)
+ (nth 3 time-list)
+ (nth 4 time-list)))
+
+(defun test-calendar-sync-make-vevent-with-tzid (summary start end tzid &optional description location)
+ "Create a VEVENT block with TZID-qualified timestamps.
+START and END are time lists (year month day hour minute).
+TZID is timezone string like \"Europe/Lisbon\".
+Returns .ics formatted VEVENT string."
+ (let* ((dtstart-val (format "%04d%02d%02dT%02d%02d00"
+ (nth 0 start) (nth 1 start) (nth 2 start)
+ (nth 3 start) (nth 4 start)))
+ (dtend-val (when end
+ (format "%04d%02d%02dT%02d%02d00"
+ (nth 0 end) (nth 1 end) (nth 2 end)
+ (nth 3 end) (nth 4 end)))))
+ (concat "BEGIN:VEVENT\n"
+ "SUMMARY:" summary "\n"
+ "DTSTART;TZID=" tzid ":" dtstart-val "\n"
+ (when dtend-val (concat "DTEND;TZID=" tzid ":" dtend-val "\n"))
+ (when description (concat "DESCRIPTION:" description "\n"))
+ (when location (concat "LOCATION:" location "\n"))
+ "END:VEVENT")))
+
+(defun test-calendar-sync-convert-tz-via-date (year month day hour minute source-tz)
+ "Convert datetime from SOURCE-TZ to local time using date command.
+Returns (year month day hour minute) in local timezone.
+This is the reference implementation for verifying our conversion function."
+ (let* ((date-input (format "%04d-%02d-%02d %02d:%02d" year month day hour minute))
+ ;; Don't set TZ= prefix - let date use system local timezone as output
+ ;; The TZ="source-tz" inside -d specifies the INPUT timezone
+ (cmd (format "date -d 'TZ=\"%s\" %s' '+%%Y %%m %%d %%H %%M' 2>/dev/null"
+ source-tz
+ date-input))
+ (result (string-trim (shell-command-to-string cmd)))
+ (parts (split-string result " ")))
+ (when (= 5 (length parts))
+ (list (string-to-number (nth 0 parts))
+ (string-to-number (nth 1 parts))
+ (string-to-number (nth 2 parts))
+ (string-to-number (nth 3 parts))
+ (string-to-number (nth 4 parts))))))
+
+(defun test-calendar-sync-local-tz-name ()
+ "Get the local timezone name (e.g., 'America/Chicago').
+Returns nil if unable to determine."
+ (let ((tz (getenv "TZ")))
+ (if (and tz (not (string-empty-p tz)))
+ tz
+ ;; Try to read from /etc/timezone or /etc/localtime
+ (cond
+ ((file-exists-p "/etc/timezone")
+ (string-trim (with-temp-buffer
+ (insert-file-contents "/etc/timezone")
+ (buffer-string))))
+ ((file-symlink-p "/etc/localtime")
+ (let ((target (file-truename "/etc/localtime")))
+ (when (string-match "/zoneinfo/\\(.+\\)$" target)
+ (match-string 1 target))))
+ (t nil)))))
+
(provide 'testutil-calendar-sync)
;;; testutil-calendar-sync.el ends here