mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-14 22:50:51 +00:00
feat(insights): cycle ring replaces strain tile, tidy overview spacing, nav order, password-reset CLI
When cycle tracking is on, the cycle ring takes the Strain slot in the health-scores strip rather than adding a sixth tile: the wellness strip gains a hideStrain flag the insights page sets from the cycle gate, so Strain is hidden for cycle accounts and shown unchanged for everyone else. Fix the oversized vertical gap on the insights overview. The cycle summary card set data-revealed on the same node that carried wellness-tile-rise, but the entrance keyframe selector is a descendant combinator, so the animation never matched and the card stayed at the rise rule's opacity: 0 — invisible yet still occupying its box. Wrap the card so data-revealed sits on an ancestor, restoring the entrance and removing the phantom gap. Move the gated cycle entry directly after Medications (before Insights) in the sidebar nav instead of appending it at the end, spliced by the Medications index so the position survives a list reorder. Add an operator-only password-reset CLI. It runs under plain node in the production standalone image (which strips tsx) via the in-image pg and @node-rs/argon2, reusing the app's Argon2id parameters through a shared .mjs module so a reset hash matches one the app would mint. It matches a single user by username or email, reads the new password without echoing, never prints it back, and exits non-zero on zero or multiple matches. Documented in docs/ops/password-reset.md.
This commit is contained in:
1 parent
e62bb9f96c
commit
fcf1366fea
11 files changed
+339
-64
No files matched your search
@@ -142,6 +142,16 @@ describe("<CycleInsightSummaryCard>", () => {
|
||||
expect(html).toContain('href="/cycle?tab=insights"');
|
||||
expect(html).toContain("View cycle insights");
|
||||
expect(html).toContain('data-phase="LUTEAL"');
|
||||
// Regression: the entrance-animation element (`.wellness-tile-rise`, which
|
||||
// is `opacity: 0` until revealed) must sit UNDER a `data-revealed` ancestor
|
||||
// — the CSS selector is a descendant combinator. With both on the same node
|
||||
// the card stayed permanently invisible but still occupied its box, which
|
||||
// read as an oversized gap on the overview. Assert the wrapper precedes the
|
||||
// rise element so the descendant selector matches.
|
||||
const revealedIdx = html.indexOf('data-revealed="true"');
|
||||
const riseIdx = html.indexOf("wellness-tile-rise");
|
||||
expect(revealedIdx).toBeGreaterThan(-1);
|
||||
expect(riseIdx).toBeGreaterThan(revealedIdx);
|
||||
});
|
||||
|
||||
it("shows the honest empty headline line when nothing cleared the FDR gate", () => {
|
||||
|
||||
@@ -77,65 +77,75 @@ export function CycleInsightSummaryCard() {
|
||||
: t("cycle.ring.ariaUnknown");
|
||||
|
||||
return (
|
||||
<section
|
||||
data-slot="cycle-insight-summary"
|
||||
data-revealed="true"
|
||||
data-phase={phase ?? "none"}
|
||||
aria-label={t("cycle.insightsSummary.title")}
|
||||
style={{ "--tile-hue": hue } as React.CSSProperties}
|
||||
className="wellness-tile wellness-tile-rise rounded-xl px-5 py-5"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
{/* Zone 1 — eyebrow + the current phase / cycle-day read. */}
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<Sparkles
|
||||
className="h-4 w-4 shrink-0"
|
||||
style={{ color: hue }}
|
||||
// `data-revealed` must sit on an ANCESTOR of the `.wellness-tile-rise`
|
||||
// element — the keyframe selector is `[data-revealed="true"]
|
||||
// .wellness-tile-rise` (descendant combinator). Putting both on the same
|
||||
// node never matched, so the tile stayed stuck at the rise rule's
|
||||
// `opacity: 0` — invisible but still occupying its box, which read as an
|
||||
// oversized gap on the overview. The wrapper restores the entrance.
|
||||
<div data-revealed="true" className="contents">
|
||||
<section
|
||||
data-slot="cycle-insight-summary"
|
||||
data-phase={phase ?? "none"}
|
||||
aria-label={t("cycle.insightsSummary.title")}
|
||||
style={{ "--tile-hue": hue } as React.CSSProperties}
|
||||
className="wellness-tile wellness-tile-rise rounded-xl px-5 py-5"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
{/* Zone 1 — eyebrow + the current phase / cycle-day read. */}
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<Sparkles
|
||||
className="h-4 w-4 shrink-0"
|
||||
style={{ color: hue }}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span className="text-muted-foreground text-xs font-medium tracking-wide uppercase">
|
||||
{t("cycle.insightsSummary.title")}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<h3
|
||||
className="text-foreground mt-2 flex items-center gap-2 text-base font-semibold"
|
||||
aria-label={ariaLabel}
|
||||
>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="inline-block h-2.5 w-2.5 shrink-0 rounded-full"
|
||||
style={{ backgroundColor: hue }}
|
||||
/>
|
||||
<span>{phaseName}</span>
|
||||
</h3>
|
||||
|
||||
<p
|
||||
className="text-muted-foreground mt-1 text-sm"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span className="text-muted-foreground text-xs font-medium tracking-wide uppercase">
|
||||
{t("cycle.insightsSummary.title")}
|
||||
</span>
|
||||
>
|
||||
{dayOfCycle != null
|
||||
? t("cycle.insightsSummary.currentDay", { day: dayOfCycle })
|
||||
: t("cycle.insightsSummary.noActiveCycle")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<h3
|
||||
className="text-foreground mt-2 flex items-center gap-2 text-base font-semibold"
|
||||
aria-label={ariaLabel}
|
||||
>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="inline-block h-2.5 w-2.5 shrink-0 rounded-full"
|
||||
style={{ backgroundColor: hue }}
|
||||
/>
|
||||
<span>{phaseName}</span>
|
||||
</h3>
|
||||
|
||||
<p className="text-muted-foreground mt-1 text-sm" aria-hidden="true">
|
||||
{dayOfCycle != null
|
||||
? t("cycle.insightsSummary.currentDay", { day: dayOfCycle })
|
||||
: t("cycle.insightsSummary.noActiveCycle")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Zone 2 — the one headline phase finding (shared component owns the
|
||||
{/* Zone 2 — the one headline phase finding (shared component owns the
|
||||
FDR-gated copy AND the honest "not enough cycles yet" empty line, so
|
||||
this can never show a broken state). */}
|
||||
<div className="mt-3">
|
||||
<CyclePhaseHeadline headline={insights.data?.headline ?? null} />
|
||||
</div>
|
||||
<div className="mt-3">
|
||||
<CyclePhaseHeadline headline={insights.data?.headline ?? null} />
|
||||
</div>
|
||||
|
||||
{/* Zone 3 — the deep-link into the single source of truth: the cycle
|
||||
{/* Zone 3 — the deep-link into the single source of truth: the cycle
|
||||
insights tab. The cycle page reads `?tab=insights` and opens that tab. */}
|
||||
<Link
|
||||
href="/cycle?tab=insights"
|
||||
data-slot="cycle-insight-summary-link"
|
||||
className="text-foreground bg-background/55 border-foreground/15 hover:bg-background/80 focus-visible:ring-ring/50 mt-4 inline-flex items-center gap-1.5 rounded-full border px-3 py-1.5 text-sm font-medium transition-colors focus:outline-none focus-visible:ring-[3px]"
|
||||
>
|
||||
{t("cycle.insightsSummary.viewDetails")}
|
||||
<ArrowRight className="h-3.5 w-3.5" aria-hidden="true" />
|
||||
</Link>
|
||||
</section>
|
||||
<Link
|
||||
href="/cycle?tab=insights"
|
||||
data-slot="cycle-insight-summary-link"
|
||||
className="text-foreground bg-background/55 border-foreground/15 hover:bg-background/80 focus-visible:ring-ring/50 mt-4 inline-flex items-center gap-1.5 rounded-full border px-3 py-1.5 text-sm font-medium transition-colors focus:outline-none focus-visible:ring-[3px]"
|
||||
>
|
||||
{t("cycle.insightsSummary.viewDetails")}
|
||||
<ArrowRight className="h-3.5 w-3.5" aria-hidden="true" />
|
||||
</Link>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -101,4 +101,23 @@ describe("<WellnessScores>", () => {
|
||||
expect(html).not.toContain('data-metric="STRESS_SCORE"');
|
||||
expect(html).not.toContain('data-metric="STRAIN_SCORE"');
|
||||
});
|
||||
|
||||
it("drops the Strain tile when hideStrain is set (cycle ring takes its slot)", () => {
|
||||
const scores = {
|
||||
READINESS: ok({ score: 80, band: "green" }),
|
||||
STRAIN_SCORE: ok({ score: 40, band: "yellow" }),
|
||||
};
|
||||
// Without the flag Strain renders.
|
||||
const withStrain = render(
|
||||
<WellnessScores read={readFrom(scores)} isLoading={false} />,
|
||||
);
|
||||
expect(withStrain).toContain('data-metric="STRAIN_SCORE"');
|
||||
|
||||
// With the flag Strain is suppressed while the other score stays.
|
||||
const hidden = render(
|
||||
<WellnessScores read={readFrom(scores)} isLoading={false} hideStrain />,
|
||||
);
|
||||
expect(hidden).not.toContain('data-metric="STRAIN_SCORE"');
|
||||
expect(hidden).toContain('data-metric="READINESS"');
|
||||
});
|
||||
});
|
||||
@@ -57,6 +57,13 @@ interface ScoreStripProps {
|
||||
* show, so the strip subtracts it from the count in that case.
|
||||
*/
|
||||
extraTile?: React.ReactNode;
|
||||
/**
|
||||
* Drop the Strain score tile from the rendered strip. Set when the cycle ring
|
||||
* takes Strain's slot (cycle-tracking accounts) so the row stays compact at
|
||||
* the same column count instead of growing a sixth tile. Strain stays
|
||||
* available for non-cycle accounts; it is only hidden, never removed.
|
||||
*/
|
||||
hideStrain?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
@@ -160,6 +167,7 @@ export function WellnessScores({
|
||||
isError = false,
|
||||
refetch,
|
||||
extraTile,
|
||||
hideStrain = false,
|
||||
className,
|
||||
}: ScoreStripProps) {
|
||||
const { t } = useTranslations();
|
||||
@@ -302,7 +310,10 @@ export function WellnessScores({
|
||||
/>,
|
||||
);
|
||||
}
|
||||
if (!isLoading && strain?.status === "ok" && strain.value) {
|
||||
// When the cycle ring takes the slot (cycle-tracking accounts), the Strain
|
||||
// tile is dropped so the row stays compact rather than growing a sixth tile.
|
||||
// Strain renders normally for everyone else.
|
||||
if (!hideStrain && !isLoading && strain?.status === "ok" && strain.value) {
|
||||
tiles.push(
|
||||
<RingTile
|
||||
key="STRAIN_SCORE"
|
||||
|
||||
@@ -98,6 +98,19 @@ describe("<SidebarNav> cycle entry gate (v1.15.0)", () => {
|
||||
const html = render({ cycleTrackingEnabled: false });
|
||||
expect(html).not.toContain('href="/cycle"');
|
||||
});
|
||||
|
||||
it("places the Cycle entry between Medications and Insights when enabled", () => {
|
||||
const html = render({ cycleTrackingEnabled: true });
|
||||
const med = html.indexOf('href="/medications"');
|
||||
const cycle = html.indexOf('href="/cycle"');
|
||||
const insights = html.indexOf('href="/insights"');
|
||||
expect(med).toBeGreaterThan(-1);
|
||||
expect(cycle).toBeGreaterThan(-1);
|
||||
expect(insights).toBeGreaterThan(-1);
|
||||
// Cycle sits after Medications and before Insights in document order.
|
||||
expect(med).toBeLessThan(cycle);
|
||||
expect(cycle).toBeLessThan(insights);
|
||||
});
|
||||
});
|
||||
|
||||
describe("<SidebarNav> targets deprecation (v1.8.6)", () => {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useMemo, useState } from "react";
|
||||
import {
|
||||
Activity,
|
||||
Bell,
|
||||
@@ -260,10 +260,21 @@ export function SidebarNav() {
|
||||
// with no sub-item expansion in the global sidebar — `<AdminShell>`
|
||||
// renders its own per-section nav inside the page itself.
|
||||
const onAdminPage = pathname === "/admin" || pathname.startsWith("/admin/");
|
||||
// v1.15.0 — append the cycle entry only when the gate resolves true.
|
||||
const visibleNavItems = user?.cycleTrackingEnabled
|
||||
? [...navItems, cycleNavItem]
|
||||
: navItems;
|
||||
// v1.15.5 — surface the cycle entry directly after Medications (before
|
||||
// Insights) when the gate resolves true, instead of tacking it on at the
|
||||
// end. Splice by the Medications index so the position survives a reorder
|
||||
// of the static list; fall back to append if the anchor ever moves.
|
||||
const visibleNavItems = useMemo(() => {
|
||||
if (!user?.cycleTrackingEnabled) return navItems;
|
||||
const afterMedications =
|
||||
navItems.findIndex((item) => item.href === "/medications") + 1;
|
||||
if (afterMedications <= 0) return [...navItems, cycleNavItem];
|
||||
return [
|
||||
...navItems.slice(0, afterMedications),
|
||||
cycleNavItem,
|
||||
...navItems.slice(afterMedications),
|
||||
];
|
||||
}, [user?.cycleTrackingEnabled]);
|
||||
const [collapsed, setCollapsed] = useState(() => {
|
||||
if (typeof window === "undefined") return false;
|
||||
try {
|
||||
|
||||
Reference in new issue
Block a user