mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-12 14:16:12 +00:00
fix(insights): take weight and BMI bands from the record on screen
Inside a shared or managed record the weight page shaded the band for the viewer's own height, and the record's own target never arrived because `/api/user/thresholds` refuses under a switch: a parent saw their own healthy range behind a child's weights. The BMI page decided from the viewer's height whether there was anything to show, and the target gear opened an editor that could neither load nor save there. The weight page now reads the record's target and height from `/api/insights/targets`, the payload the reference panel under the chart already uses, and paints the same wings as before. The BMI page reads the record's height there inside somebody else's record. The gear stays hidden in another person's record, as the BMI chart already draws nothing there rather than a wrong figure.
This commit is contained in:
1 parent
2c7a0dc3c2
commit
4fa5e6381e
10 files changed
+350
-43
No files matched your search
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$comment": "Comment lines that open with a release version, per file under src/. Maintained by src/__tests__/version-stamp-ratchet.test.ts.",
|
||||
"count": 2892,
|
||||
"total": 9216,
|
||||
"total": 9213,
|
||||
"entries": {
|
||||
"__tests__/account-payload-consumer-guard.test.ts": 2,
|
||||
"__tests__/acting-account-boundary-guard.test.ts": 7,
|
||||
@@ -643,7 +643,7 @@
|
||||
"app/insights/walking-heart-rate/page.tsx": 1,
|
||||
"app/insights/walking-speed/page.tsx": 1,
|
||||
"app/insights/walking-steadiness/page.tsx": 1,
|
||||
"app/insights/weight/page.tsx": 8,
|
||||
"app/insights/weight/page.tsx": 5,
|
||||
"app/insights/workouts/page-client.tsx": 2,
|
||||
"app/insights/wrist-temperature/page.tsx": 1,
|
||||
"app/invite/[token]/__tests__/page.test.tsx": 1,
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
/**
|
||||
* `/insights/bmi` decides from a height whether it can show anything. It used
|
||||
* the height of the person at the keyboard, so inside somebody else's record
|
||||
* a viewer without a height of their own was asked to set it, and a viewer
|
||||
* with one hid that the record has none.
|
||||
*/
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
|
||||
const shellProps = vi.fn();
|
||||
vi.mock("@/components/insights/sub-page-shell", () => ({
|
||||
SubPageShell: (props: Record<string, unknown>) => {
|
||||
shellProps(props);
|
||||
return null;
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/i18n/context", () => ({
|
||||
useTranslations: () => ({ t: (key: string) => key, locale: "en" }),
|
||||
}));
|
||||
|
||||
const state = {
|
||||
viewerHeight: null as number | null,
|
||||
inSharedRecord: true,
|
||||
recordHeight: 118 as number | null,
|
||||
};
|
||||
vi.mock("@/hooks/use-auth", () => ({
|
||||
useAuth: () => ({
|
||||
isAuthenticated: true,
|
||||
user: { heightCm: state.viewerHeight, timezone: "UTC" },
|
||||
}),
|
||||
}));
|
||||
vi.mock("@/hooks/use-record-capabilities", () => ({
|
||||
useRecordCapabilities: () => ({ inSharedRecord: state.inSharedRecord }),
|
||||
}));
|
||||
vi.mock("@/hooks/use-insights-analytics", () => ({
|
||||
useInsightsAnalytics: () => ({ isEmpty: false }),
|
||||
}));
|
||||
vi.mock("@tanstack/react-query", () => ({
|
||||
useQuery: ({ enabled }: { enabled?: boolean }) => ({
|
||||
data:
|
||||
enabled === false
|
||||
? undefined
|
||||
: { profile: { heightCm: state.recordHeight } },
|
||||
}),
|
||||
}));
|
||||
|
||||
import InsightsBmiPage from "../page";
|
||||
|
||||
/** True when the page rendered its "set your height" state. */
|
||||
function asksForHeight(): boolean {
|
||||
renderToStaticMarkup(<InsightsBmiPage />);
|
||||
const props = shellProps.mock.calls.at(-1)![0] as { coachLaunch?: boolean };
|
||||
// The chart branch launches the Coach from the shell; the empty states do not.
|
||||
return props.coachLaunch !== true;
|
||||
}
|
||||
|
||||
describe("/insights/bmi height gate", () => {
|
||||
beforeEach(() => {
|
||||
shellProps.mockClear();
|
||||
state.viewerHeight = null;
|
||||
state.inSharedRecord = true;
|
||||
state.recordHeight = 118;
|
||||
});
|
||||
|
||||
it("reads the record's height inside somebody else's record", () => {
|
||||
expect(asksForHeight()).toBe(false);
|
||||
state.viewerHeight = 180;
|
||||
state.recordHeight = null;
|
||||
expect(asksForHeight()).toBe(true);
|
||||
});
|
||||
|
||||
it("reads the account's own height in one's own record", () => {
|
||||
state.inSharedRecord = false;
|
||||
state.viewerHeight = 180;
|
||||
expect(asksForHeight()).toBe(false);
|
||||
state.viewerHeight = null;
|
||||
expect(asksForHeight()).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -1,9 +1,13 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Ruler } from "lucide-react";
|
||||
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { useRecordCapabilities } from "@/hooks/use-record-capabilities";
|
||||
import { apiGet } from "@/lib/api/api-fetch";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import { useInsightsAnalytics } from "@/hooks/use-insights-analytics";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { useInsightsLayoutPrefs } from "@/hooks/use-insights-layout-prefs";
|
||||
@@ -45,6 +49,23 @@ export default function InsightsBmiPage() {
|
||||
|
||||
const { isEmpty } = useInsightsAnalytics("BMI");
|
||||
|
||||
// `useAuth()` is the person at the keyboard. Inside somebody else's record
|
||||
// the height that decides is the record's, from the route that knows which
|
||||
// record is on screen (the reference panel below reads the same cell).
|
||||
const { inSharedRecord } = useRecordCapabilities();
|
||||
const { data: targets } = useQuery({
|
||||
queryKey: queryKeys.insightsTargets(),
|
||||
queryFn: () =>
|
||||
apiGet<{ profile?: { heightCm: number | null } }>(
|
||||
"/api/insights/targets",
|
||||
),
|
||||
enabled: isAuthenticated && inSharedRecord,
|
||||
});
|
||||
const heightCm = inSharedRecord
|
||||
? (targets?.profile?.heightCm ?? null)
|
||||
: (user?.heightCm ?? null);
|
||||
const heightKnown = !inSharedRecord || targets !== undefined;
|
||||
|
||||
// v1.4.27 — BMI is derived from WEIGHT. When no weight readings
|
||||
// exist yet, the existing "set your height" branch can never compute
|
||||
// anything useful either; surface the empty-state CTA pointing at
|
||||
@@ -75,7 +96,7 @@ export default function InsightsBmiPage() {
|
||||
);
|
||||
}
|
||||
|
||||
if (!user?.heightCm) {
|
||||
if (heightKnown && !heightCm) {
|
||||
return (
|
||||
<SubPageShell
|
||||
title={t("insights.bmiSectionTitle")}
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
/**
|
||||
* `/insights/weight` shades the band of the record on screen. It used to take
|
||||
* the height of the person at the keyboard (`useAuth()`) and their own target
|
||||
* (`/api/user/thresholds`, refused under a switch), so a parent of 180 cm on
|
||||
* a child's record saw the parent's healthy band behind the child's weights.
|
||||
*
|
||||
* `SubPageShell` is stubbed to a probe so the test reads the band the page
|
||||
* hands the chart, not rendered pixels.
|
||||
*/
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
|
||||
const shellProps = vi.fn();
|
||||
vi.mock("@/components/insights/sub-page-shell", () => ({
|
||||
SubPageShell: (props: Record<string, unknown>) => {
|
||||
shellProps(props);
|
||||
return null;
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/i18n/context", () => ({
|
||||
useTranslations: () => ({ t: (key: string) => key, locale: "en" }),
|
||||
}));
|
||||
|
||||
// The viewer: 180 cm, signed in, looking at somebody else's record.
|
||||
vi.mock("@/hooks/use-auth", () => ({
|
||||
useAuth: () => ({
|
||||
isAuthenticated: true,
|
||||
user: { heightCm: 180, timezone: "UTC", unitPreference: "metric" },
|
||||
}),
|
||||
useAccountOnceMounted: () => null,
|
||||
}));
|
||||
|
||||
vi.mock("@/hooks/use-insights-analytics", () => ({
|
||||
useInsightsAnalytics: () => ({
|
||||
data: { summaries: { WEIGHT: { count: 10 } } },
|
||||
isLoading: false,
|
||||
isEmpty: false,
|
||||
}),
|
||||
}));
|
||||
|
||||
// The record on screen: 118 cm, no target of its own.
|
||||
const targets = {
|
||||
current: {
|
||||
targets: [
|
||||
{ type: "WEIGHT", range: { min: 25.8, max: 34.7 }, source: "WHO BMI" },
|
||||
],
|
||||
profile: { heightCm: 118 },
|
||||
} as unknown,
|
||||
};
|
||||
vi.mock("@tanstack/react-query", () => ({
|
||||
useQuery: ({ queryKey }: { queryKey: readonly unknown[] }) => ({
|
||||
data: JSON.stringify(queryKey).includes("targets")
|
||||
? targets.current
|
||||
: undefined,
|
||||
isLoading: false,
|
||||
error: null,
|
||||
}),
|
||||
}));
|
||||
|
||||
import InsightsGewichtPage from "../page";
|
||||
import { buildWeightRangeFromHeight } from "@/lib/analytics/value-bands";
|
||||
|
||||
function chartBands(): Array<{ min: number; max: number }> | undefined {
|
||||
renderToStaticMarkup(<InsightsGewichtPage />);
|
||||
const props = shellProps.mock.calls.at(-1)![0] as { children: unknown };
|
||||
const children = Array.isArray(props.children)
|
||||
? props.children
|
||||
: [props.children];
|
||||
const chart = children.find(
|
||||
(el) =>
|
||||
el &&
|
||||
typeof el === "object" &&
|
||||
Array.isArray((el as { props?: { types?: unknown } }).props?.types),
|
||||
) as { props: { valueBands?: Array<{ min: number; max: number }> } };
|
||||
return chart.props.valueBands;
|
||||
}
|
||||
|
||||
describe("/insights/weight band", () => {
|
||||
beforeEach(() => shellProps.mockClear());
|
||||
|
||||
it("shades the WHO band for the record's height, not the viewer's", () => {
|
||||
const bands = chartBands();
|
||||
const record = buildWeightRangeFromHeight(118);
|
||||
const viewer = buildWeightRangeFromHeight(180);
|
||||
expect(bands).toContainEqual(
|
||||
expect.objectContaining({ min: record.greenMin, max: record.greenMax }),
|
||||
);
|
||||
expect(bands).not.toContainEqual(
|
||||
expect.objectContaining({ min: viewer.greenMin, max: viewer.greenMax }),
|
||||
);
|
||||
});
|
||||
|
||||
it("shades no band when the record's targets are not readable", () => {
|
||||
targets.current = undefined;
|
||||
try {
|
||||
expect(chartBands()).toBeUndefined();
|
||||
} finally {
|
||||
targets.current = {
|
||||
targets: [],
|
||||
profile: { heightCm: 118 },
|
||||
};
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,46 @@
|
||||
/**
|
||||
* The weight band belongs to the record on screen. It used to be built from
|
||||
* the signed-in person's height, so a parent of 180 cm looking at a child's
|
||||
* record saw a healthy band of about 60 to 81 kg behind the child's weights.
|
||||
*/
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { buildWeightRangeFromHeight } from "@/lib/analytics/value-bands";
|
||||
import { selectWeightBandRange } from "../weight-band";
|
||||
|
||||
describe("selectWeightBandRange", () => {
|
||||
it("builds the WHO band from the record's height", () => {
|
||||
const range = selectWeightBandRange({
|
||||
targets: [
|
||||
{ type: "WEIGHT", range: { min: 25.8, max: 34.7 }, source: "WHO BMI" },
|
||||
],
|
||||
profile: { heightCm: 118 },
|
||||
});
|
||||
expect(range).toEqual(buildWeightRangeFromHeight(118));
|
||||
});
|
||||
|
||||
it("paints the record's own target with the override wings", () => {
|
||||
const range = selectWeightBandRange({
|
||||
targets: [
|
||||
{ type: "WEIGHT", range: { min: 70, max: 80 }, source: "Custom" },
|
||||
],
|
||||
profile: { heightCm: 180 },
|
||||
});
|
||||
expect(range).toEqual({
|
||||
greenMin: 70,
|
||||
greenMax: 80,
|
||||
orangeMin: 68.5,
|
||||
orangeMax: 81.5,
|
||||
});
|
||||
});
|
||||
|
||||
it("paints nothing without a target or a height, or before the read lands", () => {
|
||||
expect(
|
||||
selectWeightBandRange({
|
||||
targets: [{ type: "WEIGHT", range: null, source: "WHO BMI" }],
|
||||
profile: { heightCm: null },
|
||||
}),
|
||||
).toBeNull();
|
||||
expect(selectWeightBandRange(null)).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -23,11 +23,8 @@ import { MetricCorrelationCard } from "@/components/insights/metric-correlation-
|
||||
import { MeasurementDiversityNudge } from "@/components/insights/measurement-diversity-nudge";
|
||||
import { MetricTargetSummary } from "@/components/insights/metric-target-summary";
|
||||
import { SubPageShell } from "@/components/insights/sub-page-shell";
|
||||
import {
|
||||
buildBandsFromTrafficRange,
|
||||
buildWeightBandsFromHeight,
|
||||
type TrafficRange,
|
||||
} from "@/lib/analytics/value-bands";
|
||||
import { buildBandsFromTrafficRange } from "@/lib/analytics/value-bands";
|
||||
import { selectWeightBandRange, type WeightTargetsSlice } from "./weight-band";
|
||||
|
||||
/**
|
||||
* v1.4.25 — `/insights/weight`.
|
||||
@@ -37,8 +34,8 @@ import {
|
||||
* (`chartKey="weight"`) lets the user toggle trend lines + comparison
|
||||
* overlay independently from the dashboard weight card.
|
||||
*
|
||||
* v1.34 — the bands follow the user's own weight target when one is set,
|
||||
* and fall back to the height-derived WHO band only when it is not.
|
||||
* The bands follow the record's own weight target when one is set, and fall
|
||||
* back to the WHO band for the record's height only when it is not.
|
||||
*
|
||||
* v1.4.28 — analytics fetch + empty-state
|
||||
* branch now consume `useInsightsAnalytics()` + `<MetricEmptyState>`.
|
||||
@@ -56,19 +53,13 @@ export default function InsightsGewichtPage() {
|
||||
} = useInsightsAnalytics("WEIGHT");
|
||||
const weightSummary = analytics?.summaries?.WEIGHT ?? null;
|
||||
|
||||
// v1.34 — the user's own weight target, read through the shared
|
||||
// `queryKeys.userThresholds()` cell the targets editor also subscribes to, so
|
||||
// editing the target repaints this chart's band without a reload.
|
||||
const { data: thresholds } = useQuery({
|
||||
queryKey: queryKeys.userThresholds(),
|
||||
queryFn: async () => {
|
||||
return apiGet<{
|
||||
effective: Record<
|
||||
string,
|
||||
{ range: TrafficRange | null; isOverride: boolean }
|
||||
>;
|
||||
}>("/api/user/thresholds");
|
||||
},
|
||||
// The record's own target and height, from the route that knows which
|
||||
// record is on screen (see `selectWeightBandRange`). Shares the
|
||||
// `insightsTargets()` cell with the reference panel below; the targets
|
||||
// editor invalidates it on save, so the band repaints without a reload.
|
||||
const { data: targets } = useQuery({
|
||||
queryKey: queryKeys.insightsTargets(),
|
||||
queryFn: () => apiGet<WeightTargetsSlice>("/api/insights/targets"),
|
||||
enabled: isAuthenticated,
|
||||
});
|
||||
|
||||
@@ -136,27 +127,14 @@ export default function InsightsGewichtPage() {
|
||||
);
|
||||
}
|
||||
|
||||
// v1.34 — the shaded zone follows the user's OWN target when they set one.
|
||||
// The page used to shade the height-derived WHO band unconditionally, so
|
||||
// someone who had entered a target on `/targets` kept reading a chart that
|
||||
// ignored the answer. `/api/user/thresholds` already resolves the override
|
||||
// (with its orange wings) through `getEffectiveRange`, so the page reads the
|
||||
// resolved band rather than re-deriving one. No target and no height → no
|
||||
// bands, unchanged.
|
||||
const overrideRange = thresholds?.effective?.WEIGHT?.isOverride
|
||||
? (thresholds.effective.WEIGHT.range ?? null)
|
||||
: null;
|
||||
const weightBands = overrideRange
|
||||
? buildBandsFromTrafficRange(overrideRange, {
|
||||
// No target and no height → no bands.
|
||||
const weightRange = selectWeightBandRange(targets ?? null);
|
||||
const weightBands = weightRange
|
||||
? buildBandsFromTrafficRange(weightRange, {
|
||||
lowerBound: 30,
|
||||
upperBound: 250,
|
||||
})
|
||||
: user?.heightCm
|
||||
? buildWeightBandsFromHeight(user.heightCm, {
|
||||
lowerBound: 30,
|
||||
upperBound: 250,
|
||||
})
|
||||
: undefined;
|
||||
: undefined;
|
||||
// The bands are kg bounds → scale into the display unit so the shaded zone
|
||||
// tracks the converted line (factor-only; weight carries no offset).
|
||||
const displayBands =
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import { getEffectiveRange } from "@/lib/analytics/effective-range";
|
||||
import {
|
||||
buildWeightRangeFromHeight,
|
||||
type TrafficRange,
|
||||
} from "@/lib/analytics/value-bands";
|
||||
|
||||
/** The slice of `GET /api/insights/targets` the weight band needs. */
|
||||
export interface WeightTargetsSlice {
|
||||
targets: ReadonlyArray<{
|
||||
type: string;
|
||||
range: { min: number; max: number } | null;
|
||||
source: string;
|
||||
}>;
|
||||
profile?: { heightCm: number | null };
|
||||
}
|
||||
|
||||
/**
|
||||
* The weight band for the record on screen, as the server resolved it.
|
||||
*
|
||||
* The page used to read the person at the keyboard: their height from
|
||||
* `useAuth()` and their own target from `/api/user/thresholds`, which
|
||||
* refuses inside somebody else's record. A parent on a child's record saw a
|
||||
* band built from the parent's height. `/api/insights/targets` resolves the
|
||||
* record's own target and height, and the reference panel under the chart
|
||||
* reads the same payload. The record's own target (`source: "Custom"`) gets
|
||||
* the wings `getEffectiveRange` gives every override; otherwise the band is
|
||||
* the height-derived WHO one, and with neither there is none.
|
||||
*/
|
||||
export function selectWeightBandRange(
|
||||
data: WeightTargetsSlice | null,
|
||||
): TrafficRange | null {
|
||||
if (!data) return null;
|
||||
const weight = data.targets.find((entry) => entry.type === "WEIGHT");
|
||||
if (weight?.source === "Custom" && weight.range) {
|
||||
return getEffectiveRange(
|
||||
"WEIGHT",
|
||||
{ heightCm: null, dateOfBirth: null, gender: null },
|
||||
{ WEIGHT: weight.range },
|
||||
).range;
|
||||
}
|
||||
const heightCm = data.profile?.heightCm ?? null;
|
||||
return heightCm ? buildWeightRangeFromHeight(heightCm) : null;
|
||||
}
|
||||
@@ -10,6 +10,12 @@ vi.mock("next/navigation", () => ({
|
||||
usePathname: () => "/insights/weight",
|
||||
}));
|
||||
|
||||
// The target gear asks whether this is somebody else's record; the shell
|
||||
// renders here without the session it would read that from.
|
||||
vi.mock("@/hooks/use-record-capabilities", () => ({
|
||||
useRecordCapabilities: () => ({ inSharedRecord: false }),
|
||||
}));
|
||||
|
||||
/**
|
||||
* v1.4.25 — `<SubPageShell>` unit tests.
|
||||
*
|
||||
|
||||
@@ -28,6 +28,11 @@ vi.mock("@/lib/insights/target-adjust-context", () => ({
|
||||
useTargetAdjust: () => useTargetAdjustMock(),
|
||||
}));
|
||||
|
||||
const capabilities = { inSharedRecord: false };
|
||||
vi.mock("@/hooks/use-record-capabilities", () => ({
|
||||
useRecordCapabilities: () => capabilities,
|
||||
}));
|
||||
|
||||
const { TargetAdjustButton } = await import("../target-adjust-button");
|
||||
|
||||
function render(node: React.ReactNode, locale: "en" | "de" = "en") {
|
||||
@@ -53,6 +58,22 @@ describe("<TargetAdjustButton>", () => {
|
||||
expect(html).toBe("");
|
||||
});
|
||||
|
||||
it("renders nothing inside somebody else's record", () => {
|
||||
// The editor reads and writes the signed-in account's own targets, which
|
||||
// the server refuses under a switch: the gear would open a dead sheet.
|
||||
useTargetAdjustMock.mockReturnValue({
|
||||
canAdjust: true,
|
||||
requestAdjust: vi.fn(),
|
||||
register: vi.fn(),
|
||||
});
|
||||
capabilities.inSharedRecord = true;
|
||||
try {
|
||||
expect(render(<TargetAdjustButton />)).toBe("");
|
||||
} finally {
|
||||
capabilities.inSharedRecord = false;
|
||||
}
|
||||
});
|
||||
|
||||
it("renders the gear once an editable target is registered", () => {
|
||||
useTargetAdjustMock.mockReturnValue({
|
||||
canAdjust: true,
|
||||
|
||||
@@ -6,6 +6,7 @@ import { Button } from "@/components/ui/button";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { useTargetAdjust } from "@/lib/insights/target-adjust-context";
|
||||
import { useRecordCapabilities } from "@/hooks/use-record-capabilities";
|
||||
|
||||
/**
|
||||
* Header gear that opens the per-metric target editor.
|
||||
@@ -20,6 +21,10 @@ import { useTargetAdjust } from "@/lib/insights/target-adjust-context";
|
||||
* has registered for the page (e.g. a metric without a numeric
|
||||
* target band, where the card paints nothing), so the header never
|
||||
* shows a dead control;
|
||||
* - renders nothing inside somebody else's record: the editor loads and
|
||||
* saves the signed-in account's own targets (`/api/user/thresholds`),
|
||||
* which refuses under a switch, so the gear would open a sheet that
|
||||
* can do neither;
|
||||
* - opens the primary registered target's `<TargetEditSheet>` on click.
|
||||
*
|
||||
* Sized to match `<CoachLaunchButton variant="icon">` so the gear + the
|
||||
@@ -29,9 +34,11 @@ import { useTargetAdjust } from "@/lib/insights/target-adjust-context";
|
||||
export function TargetAdjustButton({ className }: { className?: string }) {
|
||||
const { t } = useTranslations();
|
||||
const adjust = useTargetAdjust();
|
||||
const { inSharedRecord } = useRecordCapabilities();
|
||||
|
||||
// No provider, or no editable target registered for this page.
|
||||
if (!adjust || !adjust.canAdjust) return null;
|
||||
// No provider, no editable target registered for this page, or a record
|
||||
// whose targets this editor cannot reach.
|
||||
if (!adjust || !adjust.canAdjust || inSharedRecord) return null;
|
||||
|
||||
// Reuse the action's existing copy ("Adjust target range" /
|
||||
// "Zielbereich anpassen") — the gear performs exactly what the old
|
||||
|
||||
Reference in new issue
Block a user