mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-12 06:06:10 +00:00
fix(settings/environment): submit-on-Enter city search, zero-result feedback, validated timezone
Wrap the home and travel city-search inputs in a form so Enter runs the search instead of doing nothing, and show a 'no matching places' line when a geocode returns nothing rather than leaving the user without feedback. Validate the home timezone against the runtime IANA set (a bogus zone would silently mis-key every stored environment day). Grow the result-row and travel-remove controls to the 44px touch floor.
This commit is contained in:
1 parent
a3b32524f7
commit
e43089df39
2 files changed
+75
-15
No files matched your search
@@ -88,8 +88,12 @@ export function EnvironmentSection() {
|
||||
// ── geocoding search (shared by home + travel pickers) ──────────────────
|
||||
const [homeQuery, setHomeQuery] = useState("");
|
||||
const [homeResults, setHomeResults] = useState<GeocodeResult[]>([]);
|
||||
// Tracks whether a geocode has completed for the current query so a
|
||||
// zero-result search can surface a "no matches" line instead of silence.
|
||||
const [homeSearched, setHomeSearched] = useState(false);
|
||||
const [travelQuery, setTravelQuery] = useState("");
|
||||
const [travelResults, setTravelResults] = useState<GeocodeResult[]>([]);
|
||||
const [travelSearched, setTravelSearched] = useState(false);
|
||||
const [travelLoc, setTravelLoc] = useState<GeocodeResult | null>(null);
|
||||
const [travelStart, setTravelStart] = useState(todayKey());
|
||||
const [travelEnd, setTravelEnd] = useState(todayKey());
|
||||
@@ -104,14 +108,20 @@ export function EnvironmentSection() {
|
||||
const searchHome = useMutation({
|
||||
mutationKey: queryKeys.environmentGeocode("home"),
|
||||
mutationFn: () => geocode(homeQuery),
|
||||
onSuccess: (data) => setHomeResults(data.results),
|
||||
onSuccess: (data) => {
|
||||
setHomeResults(data.results);
|
||||
setHomeSearched(true);
|
||||
},
|
||||
onError: () => toast.error(t("settings.sections.environment.searchError")),
|
||||
});
|
||||
|
||||
const searchTravel = useMutation({
|
||||
mutationKey: queryKeys.environmentGeocode("travel"),
|
||||
mutationFn: () => geocode(travelQuery),
|
||||
onSuccess: (data) => setTravelResults(data.results),
|
||||
onSuccess: (data) => {
|
||||
setTravelResults(data.results);
|
||||
setTravelSearched(true);
|
||||
},
|
||||
onError: () => toast.error(t("settings.sections.environment.searchError")),
|
||||
});
|
||||
|
||||
@@ -127,6 +137,7 @@ export function EnvironmentSection() {
|
||||
onSuccess: () => {
|
||||
setHomeResults([]);
|
||||
setHomeQuery("");
|
||||
setHomeSearched(false);
|
||||
invalidate();
|
||||
toast.success(t("settings.sections.environment.homeSaved"));
|
||||
},
|
||||
@@ -147,6 +158,7 @@ export function EnvironmentSection() {
|
||||
setTravelLoc(null);
|
||||
setTravelResults([]);
|
||||
setTravelQuery("");
|
||||
setTravelSearched(false);
|
||||
invalidate();
|
||||
toast.success(t("settings.sections.environment.travelAdded"));
|
||||
},
|
||||
@@ -234,22 +246,39 @@ export function EnvironmentSection() {
|
||||
{t("settings.sections.environment.home.none")}
|
||||
</p>
|
||||
)}
|
||||
<div className="flex gap-2">
|
||||
<form
|
||||
className="flex gap-2"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
if (homeQuery.trim().length > 0 && !searchHome.isPending) {
|
||||
searchHome.mutate();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Input
|
||||
value={homeQuery}
|
||||
onChange={(e) => setHomeQuery(e.target.value)}
|
||||
onChange={(e) => {
|
||||
setHomeQuery(e.target.value);
|
||||
setHomeSearched(false);
|
||||
}}
|
||||
placeholder={t("settings.sections.environment.searchPlaceholder")}
|
||||
aria-label={t("settings.sections.environment.searchPlaceholder")}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
type="submit"
|
||||
variant="secondary"
|
||||
disabled={homeQuery.trim().length === 0 || searchHome.isPending}
|
||||
onClick={() => searchHome.mutate()}
|
||||
>
|
||||
{t("settings.sections.environment.search")}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
{homeSearched &&
|
||||
!searchHome.isPending &&
|
||||
homeResults.length === 0 && (
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{t("settings.sections.environment.noResults")}
|
||||
</p>
|
||||
)}
|
||||
{homeResults.length > 0 && (
|
||||
<ul className="divide-border divide-y rounded-md border">
|
||||
{homeResults.map((r) => (
|
||||
@@ -261,6 +290,7 @@ export function EnvironmentSection() {
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
className="min-h-11 sm:min-h-9"
|
||||
disabled={setHome.isPending}
|
||||
onClick={() => setHome.mutate(r)}
|
||||
>
|
||||
@@ -296,8 +326,9 @@ export function EnvironmentSection() {
|
||||
</span>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="min-h-11 min-w-11 sm:min-h-9 sm:min-w-9"
|
||||
aria-label={t(
|
||||
"settings.sections.environment.travel.remove",
|
||||
)}
|
||||
@@ -315,24 +346,41 @@ export function EnvironmentSection() {
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="flex gap-2">
|
||||
<form
|
||||
className="flex gap-2"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
if (travelQuery.trim().length > 0 && !searchTravel.isPending) {
|
||||
searchTravel.mutate();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Input
|
||||
value={travelQuery}
|
||||
onChange={(e) => setTravelQuery(e.target.value)}
|
||||
onChange={(e) => {
|
||||
setTravelQuery(e.target.value);
|
||||
setTravelSearched(false);
|
||||
}}
|
||||
placeholder={t("settings.sections.environment.searchPlaceholder")}
|
||||
aria-label={t("settings.sections.environment.searchPlaceholder")}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
type="submit"
|
||||
variant="secondary"
|
||||
disabled={
|
||||
travelQuery.trim().length === 0 || searchTravel.isPending
|
||||
}
|
||||
onClick={() => searchTravel.mutate()}
|
||||
>
|
||||
{t("settings.sections.environment.search")}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
{travelSearched &&
|
||||
!searchTravel.isPending &&
|
||||
travelResults.length === 0 && (
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{t("settings.sections.environment.noResults")}
|
||||
</p>
|
||||
)}
|
||||
{travelResults.length > 0 && (
|
||||
<ul className="divide-border divide-y rounded-md border">
|
||||
{travelResults.map((r) => (
|
||||
@@ -344,6 +392,7 @@ export function EnvironmentSection() {
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
className="min-h-11 sm:min-h-9"
|
||||
variant={
|
||||
travelLoc?.label === r.label ? "default" : "secondary"
|
||||
}
|
||||
|
||||
@@ -9,6 +9,8 @@
|
||||
*/
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import { isValidTimezone } from "@/lib/tz/format";
|
||||
|
||||
/** Strict YYYY-MM-DD calendar day. */
|
||||
export const dayStringSchema = z
|
||||
.string()
|
||||
@@ -24,8 +26,17 @@ export const homeLocationSchema = z
|
||||
lat: latSchema,
|
||||
lon: lonSchema,
|
||||
label: labelSchema,
|
||||
/** IANA timezone (anchors the stored day-key). */
|
||||
timezone: z.string().trim().min(1).max(64),
|
||||
/**
|
||||
* IANA timezone (anchors the stored day-key). Validated against the
|
||||
* runtime IANA set, not accepted as a free string — a bogus zone would
|
||||
* silently mis-key every stored environment day for the account.
|
||||
*/
|
||||
timezone: z
|
||||
.string()
|
||||
.trim()
|
||||
.min(1)
|
||||
.max(64)
|
||||
.refine(isValidTimezone, "Invalid IANA timezone"),
|
||||
})
|
||||
.strict();
|
||||
|
||||
|
||||
Reference in new issue
Block a user