225 lines
6.5 KiB
Python
225 lines
6.5 KiB
Python
"""
|
|
Timezone utilities untuk dashboard sentimen.
|
|
Mendukung 3 zona waktu Indonesia: WIB, WITA, WIT
|
|
"""
|
|
import os
|
|
from datetime import datetime
|
|
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
|
|
|
|
import pandas as pd
|
|
|
|
# Mapping timezone Indonesia
|
|
INDONESIA_TIMEZONES = {
|
|
"WIB (UTC+7)": "Asia/Jakarta", # Western Indonesia (Barat)
|
|
"WITA (UTC+8)": "Asia/Makassar", # Central Indonesia (Tengah)
|
|
"WIT (UTC+9)": "Asia/Jayapura", # Eastern Indonesia (Timur)
|
|
}
|
|
|
|
BROWSER_TIMEZONE_TO_CHOICE = {
|
|
"Asia/Jakarta": "WIB (UTC+7)",
|
|
"Asia/Pontianak": "WIB (UTC+7)",
|
|
"Asia/Makassar": "WITA (UTC+8)",
|
|
"Asia/Ujung_Pandang": "WITA (UTC+8)",
|
|
"Asia/Jayapura": "WIT (UTC+9)",
|
|
"Asia/Singapore": "WITA (UTC+8)",
|
|
"Asia/Kuala_Lumpur": "WITA (UTC+8)",
|
|
"Asia/Brunei": "WITA (UTC+8)",
|
|
}
|
|
|
|
TIMEZONE_NAME_TO_CHOICE = {
|
|
value: key for key, value in INDONESIA_TIMEZONES.items()
|
|
}
|
|
TIMEZONE_NAME_TO_CHOICE.update(BROWSER_TIMEZONE_TO_CHOICE)
|
|
|
|
BROWSER_OFFSET_TO_CHOICE = {
|
|
420: "WIB (UTC+7)",
|
|
480: "WITA (UTC+8)",
|
|
540: "WIT (UTC+9)",
|
|
}
|
|
|
|
TIMEZONE_DISPLAY = {
|
|
"Asia/Jakarta": "WIB",
|
|
"Asia/Makassar": "WITA",
|
|
"Asia/Jayapura": "WIT",
|
|
}
|
|
|
|
|
|
def get_default_timezone():
|
|
"""Get default timezone from APP_TIMEZONE, falling back to WITA."""
|
|
return TIMEZONE_NAME_TO_CHOICE.get(
|
|
os.getenv("APP_TIMEZONE", "Asia/Makassar"),
|
|
"WITA (UTC+8)"
|
|
)
|
|
|
|
|
|
def get_timezone_label(timezone_choice="WIB (UTC+7)"):
|
|
"""Return short timezone label for a user timezone choice."""
|
|
target_tz = get_timezone_name(timezone_choice)
|
|
|
|
if target_tz in TIMEZONE_DISPLAY:
|
|
return TIMEZONE_DISPLAY[target_tz]
|
|
|
|
try:
|
|
return datetime.now(ZoneInfo(target_tz)).strftime("%Z") or target_tz
|
|
except Exception:
|
|
return target_tz
|
|
|
|
|
|
def get_timezone_name(timezone_choice="WIB (UTC+7)"):
|
|
"""Return IANA timezone name for a user timezone choice."""
|
|
if timezone_choice in INDONESIA_TIMEZONES:
|
|
return INDONESIA_TIMEZONES[timezone_choice]
|
|
|
|
if is_valid_timezone_name(timezone_choice):
|
|
return timezone_choice
|
|
|
|
return INDONESIA_TIMEZONES.get(get_default_timezone(), "Asia/Makassar")
|
|
|
|
|
|
def is_valid_timezone_name(timezone_name):
|
|
"""Return True if value is a valid IANA timezone name."""
|
|
if not timezone_name or not isinstance(timezone_name, str):
|
|
return False
|
|
|
|
try:
|
|
ZoneInfo(timezone_name)
|
|
return True
|
|
except ZoneInfoNotFoundError:
|
|
return False
|
|
|
|
|
|
def browser_timezone_to_choice(browser_timezone):
|
|
"""Map browser IANA timezone to an Indonesian choice or the original IANA name."""
|
|
if browser_timezone in BROWSER_TIMEZONE_TO_CHOICE:
|
|
return BROWSER_TIMEZONE_TO_CHOICE[browser_timezone]
|
|
|
|
if is_valid_timezone_name(browser_timezone):
|
|
return browser_timezone
|
|
|
|
return None
|
|
|
|
|
|
def browser_offset_to_choice(offset_minutes):
|
|
"""Map browser UTC offset in minutes to one of the Indonesian choices."""
|
|
try:
|
|
offset = int(offset_minutes)
|
|
except (TypeError, ValueError):
|
|
return None
|
|
|
|
return BROWSER_OFFSET_TO_CHOICE.get(offset)
|
|
|
|
|
|
def parse_dt_with_tz(series, timezone_choice="WIB (UTC+7)"):
|
|
"""
|
|
Parse datetime series dan konversi ke timezone pilihan user.
|
|
|
|
Args:
|
|
series: pandas Series dengan datetime values
|
|
timezone_choice: User's timezone choice (format: "WIB (UTC+7)")
|
|
|
|
Returns:
|
|
pandas Series dengan datetime yang sudah dikonversi ke timezone lokal
|
|
"""
|
|
return parse_dt_with_source_tz(series, timezone_choice, "UTC")
|
|
|
|
|
|
def parse_dt_with_source_tz(series, timezone_choice="WIB (UTC+7)", naive_source_tz="UTC"):
|
|
"""
|
|
Parse datetime series and convert to user's timezone.
|
|
|
|
Datetime values with explicit timezone keep their own timezone. Naive values
|
|
are interpreted as `naive_source_tz` before conversion.
|
|
"""
|
|
target_tz = get_timezone_name(timezone_choice)
|
|
|
|
def convert_one(value):
|
|
if value is None or pd.isna(value):
|
|
return pd.NaT
|
|
|
|
try:
|
|
parsed = pd.to_datetime(value, errors="coerce", format="mixed")
|
|
except Exception:
|
|
return pd.NaT
|
|
|
|
if pd.isna(parsed):
|
|
return pd.NaT
|
|
|
|
try:
|
|
if parsed.tzinfo is None:
|
|
parsed = parsed.tz_localize(naive_source_tz)
|
|
|
|
return parsed.tz_convert(target_tz).tz_localize(None)
|
|
except Exception:
|
|
return pd.NaT
|
|
|
|
return pd.Series(series).apply(convert_one)
|
|
|
|
|
|
def format_datetime(dt_value, timezone_choice="WIB (UTC+7)", format_str="%d/%m/%Y %H:%M:%S"):
|
|
"""
|
|
Format datetime value dengan timezone dan menampilkan label zona.
|
|
|
|
Args:
|
|
dt_value: datetime value
|
|
timezone_choice: User's timezone choice
|
|
format_str: Format string untuk strftime
|
|
|
|
Returns:
|
|
Formatted string dengan timezone label
|
|
"""
|
|
if pd.isna(dt_value):
|
|
return "N/A"
|
|
|
|
try:
|
|
# Parse to UTC first if needed
|
|
if isinstance(dt_value, str):
|
|
dt_value = pd.to_datetime(dt_value, errors="coerce", utc=True, format="mixed")
|
|
|
|
# Convert to target timezone
|
|
target_tz = get_timezone_name(timezone_choice)
|
|
|
|
if pd.isna(dt_value):
|
|
return "N/A"
|
|
|
|
# If no timezone info, assume UTC
|
|
if dt_value.tz is None:
|
|
dt_value = pd.Timestamp(dt_value, tz="UTC")
|
|
|
|
# Convert to target timezone
|
|
converted = dt_value.tz_convert(target_tz)
|
|
tz_label = get_timezone_label(timezone_choice)
|
|
|
|
return f"{converted.strftime(format_str)} {tz_label}"
|
|
except Exception as e:
|
|
return str(dt_value)
|
|
|
|
|
|
def format_datetime_short(dt_value, timezone_choice="WIB (UTC+7)"):
|
|
"""Format datetime in short format: HH:MM:SS TIMEZONE"""
|
|
return format_datetime(dt_value, timezone_choice, "%H:%M:%S")
|
|
|
|
|
|
def format_date_and_time(dt_value, timezone_choice="WIB (UTC+7)"):
|
|
"""Format datetime in date and time separately"""
|
|
if pd.isna(dt_value):
|
|
return "N/A", "N/A"
|
|
|
|
try:
|
|
if isinstance(dt_value, str):
|
|
dt_value = pd.to_datetime(dt_value, errors="coerce", utc=True, format="mixed")
|
|
|
|
target_tz = get_timezone_name(timezone_choice)
|
|
tz_label = get_timezone_label(timezone_choice)
|
|
|
|
if dt_value.tz is None:
|
|
dt_value = pd.Timestamp(dt_value, tz="UTC")
|
|
|
|
converted = dt_value.tz_convert(target_tz)
|
|
|
|
date_str = converted.strftime("%d/%m/%Y")
|
|
time_str = f"{converted.strftime('%H:%M:%S')} {tz_label}"
|
|
|
|
return date_str, time_str
|
|
except Exception:
|
|
return "N/A", "N/A"
|