fix: searchable timezone, flag dropdown for language

- Bring back the dropdown UX for the language picker (the v1.99 button
  grid was nicer at first but lost the at-a-glance affordance the user
  preferred). New SearchableDropdown component renders a real dropdown
  trigger + list, with each option carrying its inline-SVG flag.
- Timezone picker is now searchable. Match against IANA name, code
  (CET, ET…), city/country label, and offset in multiple notations
  ("UTC+1", "+01:00", "GMT+01:00"). Used both for the user's primary
  timezone and the additional-timezones add picker.

v1.101.1

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
mARTin-B78 2026-05-02 00:53:59 +02:00
parent d3a40e32a0
commit 8c0f7acfd8
4 changed files with 267 additions and 85 deletions

View File

@ -1,6 +1,6 @@
{
"name": "my-weekly-todo-list",
"version": "1.101.0",
"version": "1.101.1",
"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": {

View File

@ -0,0 +1,201 @@
"use client";
import React, { useEffect, useMemo, useRef, useState } from "react";
import { ChevronDown, Search, X } from "lucide-react";
// Generic select-like dropdown that supports arbitrary node rendering for
// each option (so we can show flags, offsets, etc.) and an optional search
// box that filters the list against `searchHaystack`.
export interface DropdownOption<T = string> {
value: T;
label: string;
searchHaystack?: string; // extra text to match search against (city, country, abbreviation…)
leading?: React.ReactNode;
secondary?: string; // dimmer text after the label
}
interface SearchableDropdownProps<T = string> {
value: T;
options: DropdownOption<T>[];
onChange: (value: T) => void;
placeholder?: string;
searchable?: boolean;
searchPlaceholder?: string;
emptyText?: string;
className?: string;
style?: React.CSSProperties;
width?: string | number;
darkMode?: boolean;
}
export default function SearchableDropdown<T extends string>({
value,
options,
onChange,
placeholder,
searchable = false,
searchPlaceholder = "Search…",
emptyText = "No matches",
className,
style,
width = "100%",
darkMode = false,
}: SearchableDropdownProps<T>) {
const [open, setOpen] = useState(false);
const [query, setQuery] = useState("");
const wrapperRef = useRef<HTMLDivElement>(null);
const searchRef = useRef<HTMLInputElement>(null);
useEffect(() => {
if (!open) return;
const onDoc = (e: MouseEvent) => {
if (wrapperRef.current && !wrapperRef.current.contains(e.target as Node)) {
setOpen(false);
setQuery("");
}
};
document.addEventListener("mousedown", onDoc);
return () => document.removeEventListener("mousedown", onDoc);
}, [open]);
useEffect(() => {
if (open && searchable) setTimeout(() => searchRef.current?.focus(), 30);
}, [open, searchable]);
const selected = options.find((o) => o.value === value);
const filtered = useMemo(() => {
if (!query.trim()) return options;
const q = query.toLowerCase();
return options.filter((o) => {
const hay = (o.searchHaystack || `${o.label} ${o.secondary || ""}`).toLowerCase();
return hay.includes(q);
});
}, [options, query]);
const bg = darkMode ? "#1f2937" : "#ffffff";
const border = darkMode ? "#374151" : "#d1d5db";
const text = darkMode ? "#e5e7eb" : "#111827";
const muted = darkMode ? "#9ca3af" : "#6b7280";
const hoverBg = darkMode ? "#374151" : "#f3f4f6";
const activeBg = darkMode ? "rgba(13,148,136,0.18)" : "rgba(13,148,136,0.08)";
return (
<div ref={wrapperRef} className={className} style={{ position: "relative", width, ...style }}>
<button
type="button"
onClick={() => setOpen((v) => !v)}
style={{
width: "100%",
display: "flex",
alignItems: "center",
gap: 8,
padding: "8px 10px",
borderRadius: 6,
border: `1px solid ${border}`,
background: bg,
color: text,
cursor: "pointer",
fontSize: "0.9rem",
textAlign: "left",
}}
>
{selected?.leading && <span style={{ display: "inline-flex" }}>{selected.leading}</span>}
<span style={{ flex: 1, whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }}>
{selected ? selected.label : (placeholder || "Select…")}
</span>
{selected?.secondary && (
<span style={{ color: muted, fontSize: "0.8rem", whiteSpace: "nowrap" }}>{selected.secondary}</span>
)}
<ChevronDown size={14} style={{ color: muted, flexShrink: 0, transform: open ? "rotate(180deg)" : "none", transition: "transform 0.15s" }} />
</button>
{open && (
<div
style={{
position: "absolute",
top: "calc(100% + 4px)",
left: 0,
right: 0,
zIndex: 1000,
background: bg,
border: `1px solid ${border}`,
borderRadius: 8,
boxShadow: "0 8px 24px rgba(0,0,0,0.18)",
overflow: "hidden",
display: "flex",
flexDirection: "column",
maxHeight: 320,
}}
>
{searchable && (
<div style={{ padding: 8, borderBottom: `1px solid ${border}`, display: "flex", alignItems: "center", gap: 6, background: bg }}>
<Search size={14} style={{ color: muted, flexShrink: 0 }} />
<input
ref={searchRef}
type="text"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder={searchPlaceholder}
style={{
flex: 1,
border: "none",
outline: "none",
background: "transparent",
color: text,
fontSize: "0.85rem",
}}
/>
{query && (
<button
onClick={() => setQuery("")}
style={{ background: "none", border: "none", cursor: "pointer", color: muted, padding: 2 }}
>
<X size={12} />
</button>
)}
</div>
)}
<div style={{ overflowY: "auto", flex: 1 }}>
{filtered.length === 0 ? (
<div style={{ padding: 16, color: muted, fontSize: "0.85rem", textAlign: "center" }}>{emptyText}</div>
) : filtered.map((opt) => {
const active = opt.value === value;
return (
<button
key={String(opt.value)}
type="button"
onClick={() => {
onChange(opt.value);
setOpen(false);
setQuery("");
}}
style={{
width: "100%",
display: "flex",
alignItems: "center",
gap: 8,
padding: "8px 10px",
border: "none",
background: active ? activeBg : "transparent",
color: text,
cursor: "pointer",
fontSize: "0.85rem",
textAlign: "left",
}}
onMouseEnter={(e) => { if (!active) (e.currentTarget as HTMLButtonElement).style.background = hoverBg; }}
onMouseLeave={(e) => { if (!active) (e.currentTarget as HTMLButtonElement).style.background = "transparent"; }}
>
{opt.leading && <span style={{ display: "inline-flex", flexShrink: 0 }}>{opt.leading}</span>}
<span style={{ flex: 1, whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }}>{opt.label}</span>
{opt.secondary && <span style={{ color: muted, fontSize: "0.78rem", whiteSpace: "nowrap" }}>{opt.secondary}</span>}
</button>
);
})}
</div>
</div>
)}
</div>
);
}

View File

@ -36,7 +36,8 @@ import IconPicker from "./IconPicker";
import { allIcons } from "./iconRegistry";
import Icon from "@mdi/react";
import FlagIcon from "./FlagIcon";
import { TIMEZONE_OPTIONS, formatTimezone } from "../lib/timezones";
import SearchableDropdown from "./SearchableDropdown";
import { TIMEZONE_OPTIONS, formatTimezone, getOffsetMinutes, formatOffset } 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.
@ -104,17 +105,33 @@ function ExtraTimezonesEditor({
</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>
<div style={{ flex: 1, minWidth: 0 }}>
<SearchableDropdown
value={picker}
onChange={(v) => setPicker(v)}
searchable
placeholder={de ? "Zeitzone auswählen…" : "Select a timezone…"}
searchPlaceholder={de ? "Stadt, Land, UTC, CET…" : "City, country, UTC, CET…"}
emptyText={de ? "Keine Treffer" : "No matches"}
options={TIMEZONE_OPTIONS
.filter((o) => !extras.includes(o.zone) && o.zone !== profile.timezone)
.sort((a, b) => {
const oa = getOffsetMinutes(a.zone);
const ob = getOffsetMinutes(b.zone);
if (oa !== ob) return oa - ob;
return a.code.localeCompare(b.code);
})
.map((opt) => {
const off = formatOffset(getOffsetMinutes(opt.zone));
return {
value: opt.zone,
label: `${opt.code}${opt.label}`,
secondary: `UTC${off}`,
searchHaystack: `${opt.zone} ${opt.code} ${opt.label} utc${off} utc${off.replace(":00", "")} gmt${off}`,
};
})}
/>
</div>
<button
onClick={() => add(picker)}
disabled={!picker}
@ -1690,51 +1707,17 @@ function SettingsSidebar({
>
{t.language}
</label>
<div
role="radiogroup"
style={{
display: "grid",
gridTemplateColumns: "repeat(auto-fill, minmax(120px, 1fr))",
gap: "6px",
}}
>
{[
{ 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>
<SearchableDropdown
value={profile.language || "de"}
onChange={(v) => saveField("language", v)}
options={[
{ value: "en", label: "English", leading: <FlagIcon code="en" width={20} height={14} /> },
{ value: "de", label: "Deutsch", leading: <FlagIcon code="de" width={20} height={14} /> },
{ value: "fr", label: "Français", leading: <FlagIcon code="fr" width={20} height={14} /> },
{ value: "es", label: "Español", leading: <FlagIcon code="es" width={20} height={14} /> },
{ value: "it", label: "Italiano", leading: <FlagIcon code="it" width={20} height={14} /> },
]}
/>
</div>
<div>
@ -1748,33 +1731,31 @@ function SettingsSidebar({
>
{t.timezone}
</label>
<select
<SearchableDropdown
value={profile.timezone || Intl.DateTimeFormat().resolvedOptions().timeZone}
onChange={(e) => saveField("timezone", e.target.value)}
className="weekly-input"
style={{
width: "100%",
padding: "8px",
border: "1px solid var(--weekly-settings-input-border, #ddd)",
borderRadius: "4px",
background: "var(--weekly-settings-input-bg, #fff)",
color: "var(--weekly-settings-text, #333)",
}}
>
{(() => {
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>
onChange={(v) => saveField("timezone", v)}
searchable
searchPlaceholder={profile.language === "de" ? "Stadt, Land, UTC, CET…" : "City, country, UTC, CET…"}
emptyText={profile.language === "de" ? "Keine Treffer" : "No matches"}
options={[...TIMEZONE_OPTIONS]
.sort((a, b) => {
const oa = getOffsetMinutes(a.zone);
const ob = getOffsetMinutes(b.zone);
if (oa !== ob) return oa - ob;
return a.code.localeCompare(b.code);
})
.map((opt) => {
const off = formatOffset(getOffsetMinutes(opt.zone));
return {
value: opt.zone,
label: `${opt.code}${opt.label}`,
secondary: `UTC${off}`,
// Match against IANA name (zone), code, label,
// and the offset in several styles.
searchHaystack: `${opt.zone} ${opt.code} ${opt.label} utc${off} utc${off.replace(":00", "")} gmt${off}`,
};
})}
/>
{/* Multi-timezone (Punkt 6) */}
<ExtraTimezonesEditor
profile={profile}

File diff suppressed because one or more lines are too long