mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 21:10:59 +00:00
Tidy the environment, invite and claim surfaces
Environment settings show the home date, travel ranges and latest air quality day as formatted dates instead of ISO keys, the backfill card ends in its action row with the status lines above it, and the attribution list sits on the page's reading edge. The sharing invite card keeps a one-sentence description and says once, under the field, that the person needs an account here. An expired claim link offers the sign-in page instead of a dead end.
This commit is contained in:
1 parent
0d574f2f2d
commit
75a0f00be9
4 files changed
+95
-39
No files matched your search
@@ -0,0 +1,40 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
|
||||
import { I18nProvider } from "@/lib/i18n/context";
|
||||
|
||||
/**
|
||||
* A claim link that no longer works still leaves the page with a way on:
|
||||
* the card offers the sign-in page instead of ending in a dead end.
|
||||
*/
|
||||
|
||||
vi.mock("next/navigation", () => ({
|
||||
useRouter: () => ({ replace: () => {}, refresh: () => {}, push: () => {} }),
|
||||
}));
|
||||
vi.mock("@/hooks/use-auth", () => ({
|
||||
useAuth: () => ({ user: null }),
|
||||
clearCachesForSessionEnd: () => {},
|
||||
}));
|
||||
vi.mock("@/lib/queries/use-profile-claim", () => ({
|
||||
useProfileClaimPreview: () => ({ data: undefined, error: null }),
|
||||
useClaimProfile: () => ({ mutate: () => {}, isPending: false }),
|
||||
}));
|
||||
|
||||
import { ClaimProfile } from "../claim-profile";
|
||||
|
||||
describe("ClaimProfile — invalid link", () => {
|
||||
it("offers the sign-in page", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<QueryClientProvider client={new QueryClient()}>
|
||||
<I18nProvider initialLocale="en">
|
||||
<ClaimProfile token={null} />
|
||||
</I18nProvider>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
expect(html).toContain('data-testid="claim-invalid"');
|
||||
expect(html).toMatch(
|
||||
/data-slot="claim-to-login"[^>]*href="\/auth\/login"|href="\/auth\/login"[^>]*data-slot="claim-to-login"/,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useId, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { KeyRound, Loader2, LogOut } from "lucide-react";
|
||||
@@ -124,6 +125,16 @@ export function ClaimProfile({ token }: { token: string | null }) {
|
||||
title={t("auth.claim.invalid")}
|
||||
description={t("auth.claim.invalidHint")}
|
||||
/>
|
||||
{/* A dead link is not a dead end: the way on is the sign-in page. */}
|
||||
<Button
|
||||
asChild
|
||||
variant="outline"
|
||||
size="lg"
|
||||
className="mt-8 min-h-11 w-full"
|
||||
data-slot="claim-to-login"
|
||||
>
|
||||
<Link href="/auth/login">{t("auth.login")}</Link>
|
||||
</Button>
|
||||
</ClaimCard>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -164,14 +164,7 @@ export function GrantInviteCard() {
|
||||
<SettingsCardHeader
|
||||
icon={UserPlus}
|
||||
title={t("recordSharing.invite.title")}
|
||||
// A sharing invitation carries no link and nothing is e-mailed: the
|
||||
// person accepts it from inside their own session. Said here, at the
|
||||
// top of the card, so an inviter never goes looking for a link to
|
||||
// send — the invite-link surface is Admin → Invites, and it is a
|
||||
// different thing entirely.
|
||||
description={`${t("recordSharing.invite.description")} ${t(
|
||||
"recordSharing.invite.needsAccount",
|
||||
)}`}
|
||||
description={t("recordSharing.invite.description")}
|
||||
/>
|
||||
<form onSubmit={(event) => void submit(event)} className="space-y-3">
|
||||
<div className="space-y-1.5">
|
||||
@@ -187,8 +180,14 @@ export function GrantInviteCard() {
|
||||
autoComplete="off"
|
||||
maxLength={255}
|
||||
/>
|
||||
{/* A sharing invitation carries no link and nothing is e-mailed:
|
||||
the person accepts it from inside their own session. Said right
|
||||
under the field that names them, so an inviter never goes
|
||||
looking for a link to send (the invite-link surface is Admin →
|
||||
Invites, a different thing entirely), and said once — the card
|
||||
description stays one sentence. */}
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t("recordSharing.invite.identifierHint")}
|
||||
{t("recordSharing.invite.needsAccount")}
|
||||
</p>
|
||||
</div>
|
||||
<fieldset className="space-y-1.5" data-slot="grant-invite-access">
|
||||
|
||||
@@ -21,6 +21,7 @@ import { toast } from "sonner";
|
||||
import { CloudSun, MapPin, Plane, Trash2, Wind } from "lucide-react";
|
||||
|
||||
import { SettingsCard } from "@/components/settings/settings-card";
|
||||
import { SettingsCardActions } from "@/components/settings/_card-actions";
|
||||
import { SettingsCardHeader } from "@/components/settings/_card-header";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { DateField } from "@/components/ui/date-field";
|
||||
@@ -30,7 +31,7 @@ import { Switch } from "@/components/ui/switch";
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { ConfirmButton } from "@/components/ui/confirm-button";
|
||||
import { formatDateTime } from "@/lib/format";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { useFormatters, useTranslations } from "@/lib/i18n/context";
|
||||
import {
|
||||
apiDelete,
|
||||
apiGet,
|
||||
@@ -122,6 +123,7 @@ function envValue(value: number | null, unit: string, digits = 0): string {
|
||||
|
||||
export function EnvironmentSection() {
|
||||
const { t, tCount } = useTranslations();
|
||||
const fmt = useFormatters();
|
||||
const { user } = useAuth();
|
||||
const queryClient = useQueryClient();
|
||||
// Date defaults are the user's own days. The UTC day was tomorrow for
|
||||
@@ -306,7 +308,7 @@ export function EnvironmentSection() {
|
||||
{home.since && (
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t("settings.sections.environment.home.since", {
|
||||
date: home.since.slice(0, 10),
|
||||
date: fmt.dateShortSmartCalendar(home.since.slice(0, 10)),
|
||||
})}
|
||||
</p>
|
||||
)}
|
||||
@@ -393,7 +395,8 @@ export function EnvironmentSection() {
|
||||
className="flex items-center justify-between gap-2 p-2"
|
||||
>
|
||||
<span className="text-sm">
|
||||
{tr.label} · {tr.startDate} – {tr.endDate}
|
||||
{tr.label} · {fmt.dateShortSmartCalendar(tr.startDate)} –{" "}
|
||||
{fmt.dateShortSmartCalendar(tr.endDate)}
|
||||
</span>
|
||||
<ConfirmButton
|
||||
slot="travel-remove"
|
||||
@@ -531,7 +534,7 @@ export function EnvironmentSection() {
|
||||
<p className="text-sm leading-relaxed">
|
||||
{t("settings.sections.environment.backfill.description")}
|
||||
</p>
|
||||
<div className="flex flex-wrap items-end gap-2">
|
||||
<div className="flex flex-wrap items-end gap-4">
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="env-backfill-start">
|
||||
{t("settings.sections.environment.startDate")}
|
||||
@@ -552,6 +555,32 @@ export function EnvironmentSection() {
|
||||
onChange={setBackfillEnd}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{data && (
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t("settings.sections.environment.storedDays", {
|
||||
count: data.context.days,
|
||||
})}
|
||||
</p>
|
||||
)}
|
||||
{data?.lastFetchFailure && (
|
||||
<p
|
||||
role="status"
|
||||
data-testid="environment-fetch-failed"
|
||||
className="text-destructive text-xs"
|
||||
>
|
||||
{tCount(
|
||||
"settings.sections.environment.fetchFailed",
|
||||
data.lastFetchFailure.failures,
|
||||
{
|
||||
count: data.lastFetchFailure.failures,
|
||||
when: formatDateTime(data.lastFetchFailure.lastFailedAt),
|
||||
},
|
||||
)}
|
||||
</p>
|
||||
)}
|
||||
{/* The action row closes the card; the status lines sit above it. */}
|
||||
<SettingsCardActions>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
@@ -566,30 +595,7 @@ export function EnvironmentSection() {
|
||||
>
|
||||
{t("settings.sections.environment.backfill.run")}
|
||||
</Button>
|
||||
</div>
|
||||
{data && (
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t("settings.sections.environment.storedDays", {
|
||||
count: data.context.days,
|
||||
})}
|
||||
</p>
|
||||
)}
|
||||
{data?.lastFetchFailure && (
|
||||
<p
|
||||
role="status"
|
||||
data-testid="environment-fetch-failed"
|
||||
className="text-destructive mt-1 text-xs"
|
||||
>
|
||||
{tCount(
|
||||
"settings.sections.environment.fetchFailed",
|
||||
data.lastFetchFailure.failures,
|
||||
{
|
||||
count: data.lastFetchFailure.failures,
|
||||
when: formatDateTime(data.lastFetchFailure.lastFailedAt),
|
||||
},
|
||||
)}
|
||||
</p>
|
||||
)}
|
||||
</SettingsCardActions>
|
||||
</SettingsCard>
|
||||
|
||||
{/* Air quality, pollen and UV (v1.42) */}
|
||||
@@ -636,7 +642,7 @@ export function EnvironmentSection() {
|
||||
<div className="space-y-2">
|
||||
<p className="text-sm font-medium">
|
||||
{t("settings.sections.environment.airQuality.latest", {
|
||||
date: data.latestDay.date,
|
||||
date: fmt.dateShortSmartCalendar(data.latestDay.date),
|
||||
})}
|
||||
</p>
|
||||
<dl className="grid grid-cols-2 gap-x-4 gap-y-1 text-sm">
|
||||
@@ -700,7 +706,7 @@ export function EnvironmentSection() {
|
||||
{/* Attribution (CC BY 4.0 for Open-Meteo; Copernicus notice for the
|
||||
air-quality values), one line per source. */}
|
||||
<ul
|
||||
className="text-muted-foreground space-y-0.5 pl-1 text-xs"
|
||||
className="text-muted-foreground space-y-0.5 text-xs"
|
||||
aria-label={t("settings.sections.environment.airQuality.attribution")}
|
||||
>
|
||||
{(
|
||||
|
||||
Reference in new issue
Block a user