diff --git a/package.json b/package.json index a266a46..55203b7 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "my-weekly-todo-list", - "version": "1.99.2", + "version": "1.100.0", "description": "A web-based weekly task management application that organizes to-dos and calendar events in a single, intuitive weekly view", "main": "index.js", "scripts": { diff --git a/src/components/FlagIcon.tsx b/src/components/FlagIcon.tsx new file mode 100644 index 0000000..6460a77 --- /dev/null +++ b/src/components/FlagIcon.tsx @@ -0,0 +1,94 @@ +"use client"; +import React from "react"; + +// Tiny inline SVG country flags. Used by the language pickers so flags +// render identically on every OS (Windows ships without the regional +// emoji glyphs that "🇬🇧" relies on). + +type FlagCode = "gb" | "de" | "fr" | "es" | "it"; + +const flags: Record = { + // United Kingdom + gb: ( + + + + + + + + + ), + // Germany + de: ( + + + + + + ), + // France + fr: ( + + + + + + ), + // Spain + es: ( + + + + + ), + // Italy + it: ( + + + + + + ), +}; + +// Map UI language codes to flag codes (English uses the British flag per user request). +const LANG_FLAG: Record = { + en: "gb", + de: "de", + fr: "fr", + es: "es", + it: "it", +}; + +interface FlagIconProps { + code: string; + width?: number; + height?: number; + className?: string; +} + +export default function FlagIcon({ code, width = 22, height = 16, className }: FlagIconProps) { + const c = (LANG_FLAG[code] || code) as FlagCode; + const flag = flags[c]; + if (!flag) return null; + return ( + + ); +} diff --git a/src/components/OnboardingWizard.tsx b/src/components/OnboardingWizard.tsx index fb8f0e2..2118b46 100644 --- a/src/components/OnboardingWizard.tsx +++ b/src/components/OnboardingWizard.tsx @@ -5,6 +5,7 @@ import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { faGoogle, faApple, faMicrosoft, faNotion } from "@fortawesome/free-brands-svg-icons"; import { faServer } from "@fortawesome/free-solid-svg-icons"; import FontPicker from "./FontPicker"; +import FlagIcon from "./FlagIcon"; interface OnboardingWizardProps { profile: any; @@ -755,7 +756,7 @@ export default function OnboardingWizard({ transition: "all 0.15s", }} > - {lang.flag} + {lang.label} {selectedLang === lang.code && } diff --git a/src/components/SettingsSidebar.tsx b/src/components/SettingsSidebar.tsx index 38def31..f0435c3 100644 --- a/src/components/SettingsSidebar.tsx +++ b/src/components/SettingsSidebar.tsx @@ -35,6 +35,98 @@ import { import IconPicker from "./IconPicker"; import { allIcons } from "./iconRegistry"; import Icon from "@mdi/react"; +import FlagIcon from "./FlagIcon"; +import { TIMEZONE_OPTIONS, formatTimezone } from "../lib/timezones"; + +// Punkt 6 — manage a user's extra timezones (for cross-team scheduling). +// Stored in user.viewSettings.extraTimezones to avoid an extra migration. +function ExtraTimezonesEditor({ + profile, + setProfile, + saveSetting, +}: { + profile: any; + setProfile: React.Dispatch>; + saveSetting: (key: string, value: any) => void; +}) { + const de = profile.language === "de"; + const extras: string[] = (profile.viewSettings && profile.viewSettings.extraTimezones) || []; + const [picker, setPicker] = useState(""); + + const persist = (next: string[]) => { + const newViewSettings = { ...(profile.viewSettings || {}), extraTimezones: next }; + setProfile((p: any) => ({ ...p, viewSettings: newViewSettings })); + saveSetting("viewSettings", newViewSettings); + }; + const add = (zone: string) => { + if (!zone || extras.includes(zone) || zone === profile.timezone) return; + persist([...extras, zone]); + setPicker(""); + }; + const remove = (zone: string) => { + persist(extras.filter((z) => z !== zone)); + }; + + return ( +
+ +

+ {de + ? "Hilfreich, wenn du mit Teammitgliedern in anderen Zeitzonen arbeitest. Zeiten werden im Tageskopf angezeigt." + : "Useful when collaborating with people in other timezones. Times are shown in the day header."} +

+ {extras.length > 0 && ( +
+ {extras.map((z) => { + const opt = TIMEZONE_OPTIONS.find((o) => o.zone === z); + return ( +
+ + {opt ? formatTimezone(opt) : z} + + +
+ ); + })} +
+ )} +
+ + +
+
+ ); +} // 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); + }); +}