feat: flag icons, real timezone picker, multi-timezone display
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 <select> over a curated list of major IANA zones. Each option shows "(UTC±hh:mm) CODE — Region" and the list is sorted by current offset, then code, then IANA name. - Multi-timezone: new ExtraTimezonesEditor in the localisation tab lets users pin additional zones (stored in user.viewSettings.extraTimezones to avoid a migration). The time-grid axis now renders the equivalent hour for each pinned zone next to the local label, helpful for scheduling across regions. v1.100.0 Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
parent
ab70733c24
commit
ffe2f46c64
@ -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": {
|
||||
|
||||
94
src/components/FlagIcon.tsx
Normal file
94
src/components/FlagIcon.tsx
Normal file
@ -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<FlagCode, React.ReactNode> = {
|
||||
// United Kingdom
|
||||
gb: (
|
||||
<svg viewBox="0 0 60 30" width="100%" height="100%">
|
||||
<clipPath id="fl_gb_t"><path d="M30,15h30v15z v15h-30z h-30v-15z v-15h30z"/></clipPath>
|
||||
<path d="M0,0v30h60v-30z" fill="#012169"/>
|
||||
<path d="M0,0 60,30 M60,0 0,30" stroke="#fff" strokeWidth="6"/>
|
||||
<path d="M0,0 60,30 M60,0 0,30" clipPath="url(#fl_gb_t)" stroke="#C8102E" strokeWidth="4"/>
|
||||
<path d="M30,0v30 M0,15h60" stroke="#fff" strokeWidth="10"/>
|
||||
<path d="M30,0v30 M0,15h60" stroke="#C8102E" strokeWidth="6"/>
|
||||
</svg>
|
||||
),
|
||||
// Germany
|
||||
de: (
|
||||
<svg viewBox="0 0 5 3" width="100%" height="100%" preserveAspectRatio="none">
|
||||
<rect width="5" height="3" fill="#000"/>
|
||||
<rect width="5" height="2" y="1" fill="#D00"/>
|
||||
<rect width="5" height="1" y="2" fill="#FFCE00"/>
|
||||
</svg>
|
||||
),
|
||||
// France
|
||||
fr: (
|
||||
<svg viewBox="0 0 3 2" width="100%" height="100%" preserveAspectRatio="none">
|
||||
<rect width="1" height="2" x="0" fill="#0055A4"/>
|
||||
<rect width="1" height="2" x="1" fill="#fff"/>
|
||||
<rect width="1" height="2" x="2" fill="#EF4135"/>
|
||||
</svg>
|
||||
),
|
||||
// Spain
|
||||
es: (
|
||||
<svg viewBox="0 0 5 3" width="100%" height="100%" preserveAspectRatio="none">
|
||||
<rect width="5" height="3" fill="#AA151B"/>
|
||||
<rect width="5" height="1.5" y="0.75" fill="#F1BF00"/>
|
||||
</svg>
|
||||
),
|
||||
// Italy
|
||||
it: (
|
||||
<svg viewBox="0 0 3 2" width="100%" height="100%" preserveAspectRatio="none">
|
||||
<rect width="1" height="2" x="0" fill="#009246"/>
|
||||
<rect width="1" height="2" x="1" fill="#fff"/>
|
||||
<rect width="1" height="2" x="2" fill="#CE2B37"/>
|
||||
</svg>
|
||||
),
|
||||
};
|
||||
|
||||
// Map UI language codes to flag codes (English uses the British flag per user request).
|
||||
const LANG_FLAG: Record<string, FlagCode> = {
|
||||
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 (
|
||||
<span
|
||||
className={className}
|
||||
style={{
|
||||
display: "inline-block",
|
||||
width,
|
||||
height,
|
||||
lineHeight: 0,
|
||||
borderRadius: 2,
|
||||
overflow: "hidden",
|
||||
boxShadow: "0 0 0 1px rgba(0,0,0,0.06)",
|
||||
flexShrink: 0,
|
||||
verticalAlign: "middle",
|
||||
}}
|
||||
aria-hidden="true"
|
||||
>
|
||||
{flag}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@ -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",
|
||||
}}
|
||||
>
|
||||
<span style={{ fontWeight: 700, fontSize: "0.75rem", background: darkMode ? "#374151" : "#e5e7eb", padding: "2px 6px", borderRadius: "4px" }}>{lang.flag}</span>
|
||||
<FlagIcon code={lang.code} width={22} height={16} />
|
||||
{lang.label}
|
||||
{selectedLang === lang.code && <Check size={16} style={{ marginLeft: "auto", color: accentColor }} />}
|
||||
</button>
|
||||
|
||||
@ -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<React.SetStateAction<any>>;
|
||||
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 (
|
||||
<div style={{ marginTop: "10px" }}>
|
||||
<label style={{ display: "block", fontSize: "0.8rem", fontWeight: 600, color: "var(--weekly-settings-label)", marginBottom: "4px" }}>
|
||||
{de ? "Zusätzliche Zeitzonen" : "Additional Timezones"}
|
||||
</label>
|
||||
<p style={{ fontSize: "0.72rem", color: "var(--weekly-settings-label)", margin: "0 0 6px", opacity: 0.8 }}>
|
||||
{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."}
|
||||
</p>
|
||||
{extras.length > 0 && (
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: "4px", marginBottom: "6px" }}>
|
||||
{extras.map((z) => {
|
||||
const opt = TIMEZONE_OPTIONS.find((o) => o.zone === z);
|
||||
return (
|
||||
<div key={z} style={{
|
||||
display: "flex", alignItems: "center", gap: "8px",
|
||||
padding: "4px 8px", borderRadius: "6px",
|
||||
background: "var(--weekly-bg-soft, #f9fafb)",
|
||||
border: "1px solid var(--weekly-border, #e5e7eb)",
|
||||
}}>
|
||||
<span style={{ fontSize: "0.8rem", flex: 1 }}>
|
||||
{opt ? formatTimezone(opt) : z}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => remove(z)}
|
||||
title={de ? "Entfernen" : "Remove"}
|
||||
style={{ background: "none", border: "none", cursor: "pointer", color: "#ef4444", padding: "2px" }}
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
<div style={{ display: "flex", gap: "6px" }}>
|
||||
<select
|
||||
value={picker}
|
||||
onChange={(e) => 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" }}
|
||||
>
|
||||
<option value="">{de ? "Zeitzone auswählen…" : "Select a timezone…"}</option>
|
||||
{TIMEZONE_OPTIONS.filter((o) => !extras.includes(o.zone) && o.zone !== profile.timezone).map((opt) => (
|
||||
<option key={opt.zone} value={opt.zone}>{formatTimezone(opt)}</option>
|
||||
))}
|
||||
</select>
|
||||
<button
|
||||
onClick={() => add(picker)}
|
||||
disabled={!picker}
|
||||
className="weekly-btn-primary"
|
||||
style={{ padding: "6px 10px", fontSize: "0.85rem", display: "inline-flex", alignItems: "center", gap: "4px", opacity: picker ? 1 : 0.5 }}
|
||||
>
|
||||
<Plus size={14} /> {de ? "Hinzufügen" : "Add"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 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}
|
||||
</label>
|
||||
<select
|
||||
value={profile.language || "de"}
|
||||
onChange={(e) => saveField("language", e.target.value)}
|
||||
className="weekly-input"
|
||||
<div
|
||||
role="radiogroup"
|
||||
style={{
|
||||
width: "100%",
|
||||
padding: "8px",
|
||||
border: "1px solid #ddd",
|
||||
borderRadius: "4px",
|
||||
display: "grid",
|
||||
gridTemplateColumns: "repeat(auto-fill, minmax(120px, 1fr))",
|
||||
gap: "6px",
|
||||
}}
|
||||
>
|
||||
<option value="en">🇬🇧 English</option>
|
||||
<option value="de">🇩🇪 Deutsch</option>
|
||||
<option value="fr">🇫🇷 Français</option>
|
||||
<option value="es">🇪🇸 Español</option>
|
||||
<option value="it">🇮🇹 Italiano</option>
|
||||
</select>
|
||||
{[
|
||||
{ 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 (
|
||||
<button
|
||||
key={lang.code}
|
||||
role="radio"
|
||||
aria-checked={active}
|
||||
onClick={() => saveField("language", lang.code)}
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "8px",
|
||||
padding: "8px 10px",
|
||||
borderRadius: "6px",
|
||||
border: active
|
||||
? "2px solid var(--weekly-teal, #009a9a)"
|
||||
: "1px solid var(--weekly-border, #e5e7eb)",
|
||||
background: active ? "rgba(13,148,136,0.08)" : "var(--weekly-bg, #fff)",
|
||||
color: "var(--weekly-text, #333)",
|
||||
cursor: "pointer",
|
||||
fontSize: "0.9rem",
|
||||
fontWeight: active ? 600 : 400,
|
||||
transition: "all 0.15s",
|
||||
}}
|
||||
>
|
||||
<FlagIcon code={lang.code} width={20} height={14} />
|
||||
<span>{lang.label}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
@ -1628,19 +1748,39 @@ function SettingsSidebar({
|
||||
>
|
||||
{t.timezone}
|
||||
</label>
|
||||
<div
|
||||
<select
|
||||
value={profile.timezone || Intl.DateTimeFormat().resolvedOptions().timeZone}
|
||||
onChange={(e) => 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}
|
||||
</div>
|
||||
{(() => {
|
||||
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) => (
|
||||
<option key={opt.zone} value={opt.zone}>
|
||||
{formatTimezone(opt)}
|
||||
</option>
|
||||
));
|
||||
})()}
|
||||
</select>
|
||||
{/* Multi-timezone (Punkt 6) */}
|
||||
<ExtraTimezonesEditor
|
||||
profile={profile}
|
||||
setProfile={setProfile}
|
||||
saveSetting={saveSetting}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
|
||||
@ -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 (
|
||||
<div
|
||||
key={slot}
|
||||
@ -6960,7 +6985,12 @@ export default function WeeklyView() {
|
||||
title={isHourStart ? `Jump to ${hour}:00` : undefined}
|
||||
>
|
||||
{(isHourStart || effectiveShowSubHourSlots) && (
|
||||
<span>{formatHour(hour, parseInt(minutes), (isHourStart ? effectiveHourLabelFormat : 'full') as "short" | "full", profile.timeFormat)}</span>
|
||||
<span style={{ display: "inline-flex", flexDirection: "column", alignItems: "flex-end", lineHeight: 1.05 }}>
|
||||
<span>{localLabel}</span>
|
||||
{extraLabels.map((lbl, i) => (
|
||||
<span key={i} style={{ fontSize: "0.55em", opacity: 0.65 }}>{lbl}</span>
|
||||
))}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
97
src/lib/timezones.ts
Normal file
97
src/lib/timezones.ts
Normal file
@ -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);
|
||||
});
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user