mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 13:01:00 +00:00
Settings now reads as nine groups: Account, Tracking, Arrangement, Notifications, Health profile, AI, Connections, Data and System. On a phone one button names the current section and opens the grouped list, replacing the strip of every section. What moved, every old address still working: - Sessions, trusted devices and security activity sit under Security; the privacy page anchors forward there. - Notifications holds every reminder kind: the Coach nudges and the checkup reminder switches joined, and a card names the reminders set where their subject lives (dose times, cycle reminders). - Arrangement holds order and view only. Mood tags, the labs biomarker catalog and on-device text recognition, and the injection sites live on module pages under /settings/modules/<id>, linked from each Modules row and from the module's own wrench. /settings/layout/mood redirects. - The timezone saves on change in the Display card, with a one-tap offer of the device zone. - Screening hints sit beside the life circumstances they come from. - System gains Preview features with the Topics switch. Duplicates: the Coach and MCP pages show their state and link to the one switch in Modules (turning MCP off keeps tokens), the Coach overlay shows the provider and model with a link to Settings > AI instead of a second set of selects, and the privacy page drops its copy of the export links. A guard walks every historical /settings address through the redirects and every forwarded anchor to a page that renders.
99 lines
3.6 KiB
TypeScript
99 lines
3.6 KiB
TypeScript
"use client";
|
|
|
|
import { useState } from "react";
|
|
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
|
|
|
import { toastWrittenOutcome } from "@/components/outcome/outcome-toast";
|
|
import { resolveInitialTimezone } from "@/components/settings/account-section/account-section-utils";
|
|
import { TimezonePicker } from "@/components/settings/timezone-picker";
|
|
import { InlineAction } from "@/components/ui/inline-action";
|
|
import { useAuth } from "@/hooks/use-auth";
|
|
import { useMounted } from "@/hooks/use-mounted";
|
|
import { apiFetchRaw } from "@/lib/api/api-fetch";
|
|
import { showErrorToast } from "@/lib/errors/error-toast";
|
|
import { useTranslations } from "@/lib/i18n/context";
|
|
import { queryKeys } from "@/lib/query-keys";
|
|
import { storeTimezone } from "@/lib/timezone-mirror";
|
|
import { DEFAULT_TIMEZONE, detectBrowserTimezone } from "@/lib/tz/format";
|
|
|
|
/**
|
|
* The profile timezone as a Display preference: it saves the moment it
|
|
* changes, like the language, units and formats beside it.
|
|
*
|
|
* Until v1.44 it sat in the profile form and waited for that form's Save,
|
|
* which made it the one display preference that did not take effect on
|
|
* change. Persistence is unchanged (`PUT /api/auth/me/timezone`, the route
|
|
* that owns the IANA validation). The local mirror is written on success so
|
|
* every rendered timestamp repaints in the same tab before the `/me`
|
|
* refetch confirms it (issue #490).
|
|
*
|
|
* The select shows the stored zone: a value on screen is a value that is
|
|
* saved. The form used to pre-fill the device's zone over the untouched
|
|
* default and save it with the next profile save; that suggestion stays,
|
|
* as a one-tap offer under the select (`resolveInitialTimezone` decides
|
|
* when), instead of an unsaved value dressed as the current one.
|
|
*/
|
|
export function TimezoneSelect({
|
|
isAuthenticated,
|
|
}: {
|
|
isAuthenticated: boolean;
|
|
}) {
|
|
const { t } = useTranslations();
|
|
const queryClient = useQueryClient();
|
|
const { user } = useAuth();
|
|
const [optimistic, setOptimistic] = useState<string | null>(null);
|
|
|
|
const value = optimistic ?? user?.timezone ?? DEFAULT_TIMEZONE;
|
|
|
|
const mutation = useMutation({
|
|
mutationFn: async (next: string) => {
|
|
const res = await apiFetchRaw("/api/auth/me/timezone", {
|
|
method: "PUT",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ timezone: next }),
|
|
});
|
|
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
|
return next;
|
|
},
|
|
onSuccess: (next) => {
|
|
storeTimezone(next);
|
|
toastWrittenOutcome("success", t("settings.timezoneSaved"));
|
|
void queryClient.invalidateQueries({ queryKey: queryKeys.authMe() });
|
|
setOptimistic(null);
|
|
},
|
|
onError: (err) => {
|
|
setOptimistic(null);
|
|
showErrorToast(t("settings.timezoneInvalid"), { cause: err });
|
|
},
|
|
});
|
|
|
|
function handleChange(next: string) {
|
|
if (next === value || mutation.isPending || !isAuthenticated) return;
|
|
setOptimistic(next);
|
|
mutation.mutate(next);
|
|
}
|
|
|
|
// The device zone is read after hydration so both renders agree.
|
|
const mounted = useMounted();
|
|
const suggestion = mounted
|
|
? resolveInitialTimezone(user?.timezone, detectBrowserTimezone())
|
|
: value;
|
|
|
|
return (
|
|
<div className="space-y-1">
|
|
<TimezonePicker value={value} onChange={handleChange} />
|
|
{suggestion !== value && optimistic === null ? (
|
|
<InlineAction
|
|
tone="primary"
|
|
size="xs"
|
|
data-slot="timezone-use-device"
|
|
disabled={mutation.isPending || !isAuthenticated}
|
|
onClick={() => handleChange(suggestion)}
|
|
>
|
|
{t("settings.timezoneUseDevice", { zone: suggestion })}
|
|
</InlineAction>
|
|
) : null}
|
|
</div>
|
|
);
|
|
}
|