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:
mARTin-B78 2026-05-01 20:00:53 +02:00
parent ab70733c24
commit ffe2f46c64
6 changed files with 387 additions and 25 deletions

View File

@ -1,6 +1,6 @@
{ {
"name": "my-weekly-todo-list", "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", "description": "A web-based weekly task management application that organizes to-dos and calendar events in a single, intuitive weekly view",
"main": "index.js", "main": "index.js",
"scripts": { "scripts": {

View 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>
);
}

View File

@ -5,6 +5,7 @@ import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faGoogle, faApple, faMicrosoft, faNotion } from "@fortawesome/free-brands-svg-icons"; import { faGoogle, faApple, faMicrosoft, faNotion } from "@fortawesome/free-brands-svg-icons";
import { faServer } from "@fortawesome/free-solid-svg-icons"; import { faServer } from "@fortawesome/free-solid-svg-icons";
import FontPicker from "./FontPicker"; import FontPicker from "./FontPicker";
import FlagIcon from "./FlagIcon";
interface OnboardingWizardProps { interface OnboardingWizardProps {
profile: any; profile: any;
@ -755,7 +756,7 @@ export default function OnboardingWizard({
transition: "all 0.15s", 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} {lang.label}
{selectedLang === lang.code && <Check size={16} style={{ marginLeft: "auto", color: accentColor }} />} {selectedLang === lang.code && <Check size={16} style={{ marginLeft: "auto", color: accentColor }} />}
</button> </button>

View File

@ -35,6 +35,98 @@ import {
import IconPicker from "./IconPicker"; import IconPicker from "./IconPicker";
import { allIcons } from "./iconRegistry"; import { allIcons } from "./iconRegistry";
import Icon from "@mdi/react"; 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. // Minimal ProjectIcon — resolves an icon name from the unified registry.
function ProjectIcon({ icon, size = 18, color }: { icon?: string | null; size?: number; color?: string }) { function ProjectIcon({ icon, size = 18, color }: { icon?: string | null; size?: number; color?: string }) {
@ -1598,23 +1690,51 @@ function SettingsSidebar({
> >
{t.language} {t.language}
</label> </label>
<select <div
value={profile.language || "de"} role="radiogroup"
onChange={(e) => saveField("language", e.target.value)}
className="weekly-input"
style={{ style={{
width: "100%", display: "grid",
padding: "8px", gridTemplateColumns: "repeat(auto-fill, minmax(120px, 1fr))",
border: "1px solid #ddd", gap: "6px",
borderRadius: "4px",
}} }}
> >
<option value="en">🇬🇧 English</option> {[
<option value="de">🇩🇪 Deutsch</option> { code: "en", label: "English" },
<option value="fr">🇫🇷 Français</option> { code: "de", label: "Deutsch" },
<option value="es">🇪🇸 Español</option> { code: "fr", label: "Français" },
<option value="it">🇮🇹 Italiano</option> { code: "es", label: "Español" },
</select> { 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>
<div> <div>
@ -1628,19 +1748,39 @@ function SettingsSidebar({
> >
{t.timezone} {t.timezone}
</label> </label>
<div <select
value={profile.timezone || Intl.DateTimeFormat().resolvedOptions().timeZone}
onChange={(e) => saveField("timezone", e.target.value)}
className="weekly-input"
style={{ style={{
width: "100%",
padding: "8px", padding: "8px",
fontSize: "0.9rem", border: "1px solid var(--weekly-settings-input-border, #ddd)",
border: "1px solid var(--weekly-settings-input-border)",
borderRadius: "4px", borderRadius: "4px",
background: "var(--weekly-settings-input-bg)", background: "var(--weekly-settings-input-bg, #fff)",
color: "var(--weekly-settings-text)", color: "var(--weekly-settings-text, #333)",
opacity: 0.8,
}} }}
> >
{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>
<div> <div>

View File

@ -107,6 +107,7 @@ import CalendarSyncRulesPanel from "./CalendarSyncRulesPanel";
import { getRandomLocalQuote } from "@/lib/quotes"; import { getRandomLocalQuote } from "@/lib/quotes";
import { AVAILABLE_FONTS, isCustomFont } from "../lib/fontConstants"; import { AVAILABLE_FONTS, isCustomFont } from "../lib/fontConstants";
import { translations } from "../lib/weeklyViewTranslations"; import { translations } from "../lib/weeklyViewTranslations";
import { TIMEZONE_OPTIONS as TIMEZONE_OPTIONS_LOCAL } from "../lib/timezones";
import { WeatherDisplayKey, WEATHER_DISPLAY_DEFAULTS } from "../lib/weeklyViewConstants"; import { WeatherDisplayKey, WEATHER_DISPLAY_DEFAULTS } from "../lib/weeklyViewConstants";
const SettingsSidebar = dynamic(() => import("./SettingsSidebar"), { ssr: false }); const SettingsSidebar = dynamic(() => import("./SettingsSidebar"), { ssr: false });
const RichTextEditor = dynamic(() => import("./RichTextEditor"), { ssr: false }); const RichTextEditor = dynamic(() => import("./RichTextEditor"), { ssr: false });
@ -6951,6 +6952,30 @@ export default function WeeklyView() {
style={{ height: `${getSlotHeight(effectiveCellDuration)}px` }} 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 ( return (
<div <div
key={slot} key={slot}
@ -6960,7 +6985,12 @@ export default function WeeklyView() {
title={isHourStart ? `Jump to ${hour}:00` : undefined} title={isHourStart ? `Jump to ${hour}:00` : undefined}
> >
{(isHourStart || effectiveShowSubHourSlots) && ( {(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> </div>
); );

97
src/lib/timezones.ts Normal file
View 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);
});
}