From ffe2f46c6429f6f32b1d44bd79fb1b2f744a9333 Mon Sep 17 00:00:00 2001 From: mARTin-B78 Date: Fri, 1 May 2026 20:00:53 +0200 Subject: [PATCH] feat: flag icons, real timezone picker, multi-timezone display MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wave-2 user feedback (points 4–6): - Language picker: replace emoji flags (which don't render reliably on Windows) with inline-SVG flag components for GB/DE/FR/ES/IT. Used in both the Settings localisation tab and the onboarding wizard. - Timezone: replace the read-only Intl.DateTimeFormat display with a real setPicker(e.target.value)} + className="weekly-input" + style={{ flex: 1, padding: "6px 8px", border: "1px solid var(--weekly-border, #e5e7eb)", borderRadius: "6px", fontSize: "0.85rem" }} + > + + {TIMEZONE_OPTIONS.filter((o) => !extras.includes(o.zone) && o.zone !== profile.timezone).map((opt) => ( + + ))} + + + + + ); +} // Minimal ProjectIcon — resolves an icon name from the unified registry. function ProjectIcon({ icon, size = 18, color }: { icon?: string | null; size?: number; color?: string }) { @@ -1598,23 +1690,51 @@ function SettingsSidebar({ > {t.language} - + {[ + { code: "en", label: "English" }, + { code: "de", label: "Deutsch" }, + { code: "fr", label: "Français" }, + { code: "es", label: "Español" }, + { code: "it", label: "Italiano" }, + ].map((lang) => { + const active = (profile.language || "de") === lang.code; + return ( + + ); + })} +
@@ -1628,19 +1748,39 @@ function SettingsSidebar({ > {t.timezone} -
saveField("timezone", e.target.value)} + className="weekly-input" style={{ + width: "100%", padding: "8px", - fontSize: "0.9rem", - border: "1px solid var(--weekly-settings-input-border)", + border: "1px solid var(--weekly-settings-input-border, #ddd)", borderRadius: "4px", - background: "var(--weekly-settings-input-bg)", - color: "var(--weekly-settings-text)", - opacity: 0.8, + background: "var(--weekly-settings-input-bg, #fff)", + color: "var(--weekly-settings-text, #333)", }} > - {Intl.DateTimeFormat().resolvedOptions().timeZone} -
+ {(() => { + const sorted = [...TIMEZONE_OPTIONS].sort((a, b) => { + const oa = new Date().toLocaleString("en-US", { timeZone: a.zone, timeZoneName: "shortOffset" }); + const ob = new Date().toLocaleString("en-US", { timeZone: b.zone, timeZoneName: "shortOffset" }); + return oa.localeCompare(ob); + }); + // Use formatTimezone for label so each option shows offset + code + label + return sorted.map((opt) => ( + + )); + })()} + + {/* Multi-timezone (Punkt 6) */} +
diff --git a/src/components/WeeklyView.tsx b/src/components/WeeklyView.tsx index de45017..ea0702b 100644 --- a/src/components/WeeklyView.tsx +++ b/src/components/WeeklyView.tsx @@ -107,6 +107,7 @@ import CalendarSyncRulesPanel from "./CalendarSyncRulesPanel"; import { getRandomLocalQuote } from "@/lib/quotes"; import { AVAILABLE_FONTS, isCustomFont } from "../lib/fontConstants"; import { translations } from "../lib/weeklyViewTranslations"; +import { TIMEZONE_OPTIONS as TIMEZONE_OPTIONS_LOCAL } from "../lib/timezones"; import { WeatherDisplayKey, WEATHER_DISPLAY_DEFAULTS } from "../lib/weeklyViewConstants"; const SettingsSidebar = dynamic(() => import("./SettingsSidebar"), { ssr: false }); const RichTextEditor = dynamic(() => import("./RichTextEditor"), { ssr: false }); @@ -6951,6 +6952,30 @@ export default function WeeklyView() { style={{ height: `${getSlotHeight(effectiveCellDuration)}px` }} /> ); + // Punkt 6: render extra timezone hours alongside the local label. + const extras: string[] = (profile.viewSettings?.extraTimezones as string[]) || []; + const localLabel = formatHour(hour, parseInt(minutes), (isHourStart ? effectiveHourLabelFormat : 'full') as "short" | "full", profile.timeFormat); + let extraLabels: string[] = []; + if (isHourStart && extras.length > 0) { + const userZone = profile.timezone || Intl.DateTimeFormat().resolvedOptions().timeZone; + const today = new Date(); + today.setHours(hour, parseInt(minutes), 0, 0); + extraLabels = extras.map((zone) => { + try { + const opt = TIMEZONE_OPTIONS_LOCAL.find((o) => o.zone === zone); + const code = opt?.code || zone.split("/").pop() || zone; + const time = new Intl.DateTimeFormat("en-GB", { + timeZone: zone, + hour: "2-digit", + minute: "2-digit", + hour12: profile.timeFormat === "12h", + }).format(today); + return `${code} ${time}`; + } catch { + return ""; + } + }).filter(Boolean); + } return (
{(isHourStart || effectiveShowSubHourSlots) && ( - {formatHour(hour, parseInt(minutes), (isHourStart ? effectiveHourLabelFormat : 'full') as "short" | "full", profile.timeFormat)} + + {localLabel} + {extraLabels.map((lbl, i) => ( + {lbl} + ))} + )}
); diff --git a/src/lib/timezones.ts b/src/lib/timezones.ts new file mode 100644 index 0000000..ce051f7 --- /dev/null +++ b/src/lib/timezones.ts @@ -0,0 +1,97 @@ +// Curated list of common IANA timezones with widely recognised abbreviations. +// `code` is the abbreviation users expect to see (CET, ET, JST...). The IANA +// name (`zone`) is what we actually persist and pass to Intl APIs. +export interface TimezoneOption { + zone: string; // IANA name, e.g. "Europe/Berlin" + code: string; // colloquial abbreviation, e.g. "CET" + label: string; // human-friendly city/region name +} + +export const TIMEZONE_OPTIONS: TimezoneOption[] = [ + { zone: "Pacific/Midway", code: "SST", label: "Samoa" }, + { zone: "Pacific/Honolulu", code: "HST", label: "Hawaii" }, + { zone: "America/Anchorage", code: "AKT", label: "Alaska" }, + { zone: "America/Los_Angeles", code: "PT", label: "Los Angeles, San Francisco" }, + { zone: "America/Denver", code: "MT", label: "Denver, Phoenix" }, + { zone: "America/Chicago", code: "CT", label: "Chicago, Mexico City" }, + { zone: "America/New_York", code: "ET", label: "New York, Toronto" }, + { zone: "America/Halifax", code: "AT", label: "Halifax" }, + { zone: "America/Sao_Paulo", code: "BRT", label: "São Paulo" }, + { zone: "America/Argentina/Buenos_Aires", code: "ART", label: "Buenos Aires" }, + { zone: "Atlantic/Azores", code: "AZOT", label: "Azores" }, + { zone: "Europe/London", code: "GMT", label: "London, Dublin, Lisbon" }, + { zone: "Europe/Berlin", code: "CET", label: "Berlin, Paris, Madrid, Rome" }, + { zone: "Europe/Athens", code: "EET", label: "Athens, Helsinki, Bucharest" }, + { zone: "Europe/Moscow", code: "MSK", label: "Moscow, Istanbul" }, + { zone: "Asia/Dubai", code: "GST", label: "Dubai, Abu Dhabi" }, + { zone: "Asia/Karachi", code: "PKT", label: "Karachi, Tashkent" }, + { zone: "Asia/Kolkata", code: "IST", label: "Mumbai, Delhi, Bengaluru" }, + { zone: "Asia/Dhaka", code: "BST", label: "Dhaka" }, + { zone: "Asia/Bangkok", code: "ICT", label: "Bangkok, Jakarta" }, + { zone: "Asia/Singapore", code: "SGT", label: "Singapore, Kuala Lumpur" }, + { zone: "Asia/Shanghai", code: "CST", label: "Beijing, Shanghai, Hong Kong" }, + { zone: "Asia/Tokyo", code: "JST", label: "Tokyo, Seoul" }, + { zone: "Australia/Perth", code: "AWST", label: "Perth" }, + { zone: "Australia/Adelaide", code: "ACT", label: "Adelaide" }, + { zone: "Australia/Sydney", code: "AEST", label: "Sydney, Melbourne" }, + { zone: "Pacific/Auckland", code: "NZT", label: "Auckland" }, + { zone: "UTC", code: "UTC", label: "Coordinated Universal Time" }, +]; + +// Compute the current offset (minutes) for an IANA zone using Intl. +// Positive means ahead of UTC. +export function getOffsetMinutes(zone: string, at: Date = new Date()): number { + try { + const dtf = new Intl.DateTimeFormat("en-US", { + timeZone: zone, + timeZoneName: "shortOffset", + }); + const parts = dtf.formatToParts(at); + const tzPart = parts.find((p) => p.type === "timeZoneName")?.value || ""; + // shortOffset returns like "GMT+01:00" or "GMT-5". Parse accordingly. + const m = tzPart.match(/GMT([+-])(\d{1,2})(?::?(\d{2}))?/); + if (!m) return 0; + const sign = m[1] === "-" ? -1 : 1; + const hours = parseInt(m[2] || "0", 10); + const mins = parseInt(m[3] || "0", 10); + return sign * (hours * 60 + mins); + } catch { + return 0; + } +} + +// Format minutes-offset as "+02:00" / "-05:30". +export function formatOffset(minutes: number): string { + const sign = minutes >= 0 ? "+" : "-"; + const abs = Math.abs(minutes); + const h = Math.floor(abs / 60).toString().padStart(2, "0"); + const m = (abs % 60).toString().padStart(2, "0"); + return `${sign}${h}:${m}`; +} + +// Build a one-line label like "(UTC+01:00) CET — Berlin, Paris, …". +export function formatTimezone(opt: TimezoneOption, at: Date = new Date()): string { + const off = formatOffset(getOffsetMinutes(opt.zone, at)); + return `(UTC${off}) ${opt.code} — ${opt.label}`; +} + +// Get current time in a zone formatted as "HH:mm" (24h) or "h:mm a" (12h). +export function formatTimeInZone(zone: string, format: "12h" | "24h" = "24h", at: Date = new Date()): string { + return new Intl.DateTimeFormat("en-GB", { + timeZone: zone, + hour: "2-digit", + minute: "2-digit", + hour12: format === "12h", + }).format(at); +} + +// Sort by offset asc, then by code/IANA name. Returns a fresh array. +export function sortedTimezones(at: Date = new Date()): TimezoneOption[] { + return [...TIMEZONE_OPTIONS].sort((a, b) => { + const oa = getOffsetMinutes(a.zone, at); + const ob = getOffsetMinutes(b.zone, at); + if (oa !== ob) return oa - ob; + if (a.code !== b.code) return a.code.localeCompare(b.code); + return a.zone.localeCompare(b.zone); + }); +}