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:
Marc-André Bombeck committed 2026-10-08 16:21:12 +02:00
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"/,
);
});
});
+11
View File
@@ -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">
+36 -30
View File
@@ -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")}
>
{(