Files
HealthLog/src/components/layout/bottom-nav.tsx
T
Marc-André Bombeck de33f1ad4c chore: remove the bug-report integration
Drop the in-app bug-report surface end to end: the user submission page,
the feedback intake and admin inbox, the optional GitHub-issue
escalation, the operator config, and the dedicated achievement badges.

- delete the /bugreport page and the bugreport / feedback / admin-feedback API routes
- drop the Feedback model, its enums, and the AppSettings GitHub-issue and enable columns (migration 0205)
- remove the admin Feedback section, the Bug Report integration card, and the system-status row
- drop the bug-report nav entry and the app-settings provider that only carried its flag
- remove the bug-hunter achievement badges and their metric
- prune the related i18n keys across every locale and the encrypted-column and key-rotation registries
2026-06-27 20:37:35 +02:00

282 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
import {
Home,
Lightbulb,
MoreHorizontal,
Pill,
Plus,
type LucideIcon,
} from "lucide-react";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { useQueryClient } from "@tanstack/react-query";
import { medicationsPrefetchIntentProps } from "@/lib/queries/prefetch-medications";
import {
isNavDestinationActive,
mobileMoreHubDestinations,
} from "@/components/layout/nav-model";
import type { ModuleKey } from "@/lib/modules/registry";
import { useMemo, useState } from "react";
import { useAuth } from "@/hooks/use-auth";
import {
Sheet,
SheetContent,
SheetDescription,
SheetHeader,
SheetTitle,
} from "@/components/ui/sheet";
import { CapturePicker } from "@/components/layout/capture-picker";
import { cn } from "@/lib/utils";
import { useTranslations } from "@/lib/i18n/context";
interface NavLink {
href: string;
tKey: string;
icon: LucideIcon;
}
// v1.12.x — iOS-parity bottom bar (additive middle-path).
//
// The mobile bar is a 5-slot shape: Home · Meds · Log(center) ·
// Insights · More — mirroring the native client. The center slot is a
// capture ACTION (not a destination): it opens a picker that routes to
// the existing measurement / medication / mood quick-entry surfaces
// (see `<CapturePicker>`).
//
// v1.17.1 (F-1) — the two always-visible flanking anchors and the "More"
// hub are now BOTH derived from the one shared destination model
// (`nav-model.ts`), the same ordered list the desktop sidebar renders.
// The bar keeps its ergonomic 5-slot shape, but the hub is computed by the
// model's `mobileMoreHubDestinations()` (feature list minus the primary
// slots, plus the shared utility tail) — so the two surfaces tell one story
// instead of two hand-curated ones that drift, and the headline invariant
// is a tested model function rather than inline bar logic.
const PRIMARY_LEFT: ReadonlyArray<NavLink> = [
{ href: "/", tKey: "nav.dashboard", icon: Home },
{ href: "/medications", tKey: "nav.medications", icon: Pill },
];
// v1.18.0 — the Insights primary slot is module-gated. Insights is now a
// toggleable module (`requiresModule: "insights"` in the shared nav model),
// so the fixed slot must respect the same per-user map the More hub already
// honours rather than pinning a destination the account turned off. When
// insights is disabled the slot is dropped (it is excluded from the More hub
// by `BOTTOM_NAV_PRIMARY_SLOT_HREFS`, so a disabled module is hidden
// everywhere, not relocated). Fail-open: a missing key / unloaded map keeps
// the slot, mirroring the gate's default-on contract.
const PRIMARY_RIGHT: ReadonlyArray<NavLink & { requiresModule?: ModuleKey }> = [
{
href: "/insights",
tKey: "nav.insights",
icon: Lightbulb,
requiresModule: "insights",
},
];
export function BottomNav() {
const pathname = usePathname();
const { t } = useTranslations();
const { user } = useAuth();
const queryClient = useQueryClient();
// v1.16.7 — touch intent on the medications tab starts the list
// request before the navigation commits (see sidebar-nav).
const medsIntent = medicationsPrefetchIntentProps(queryClient);
const [moreOpen, setMoreOpen] = useState(false);
const [captureOpen, setCaptureOpen] = useState(false);
// v1.17.1 (F-1) — the More hub is the model-computed hub: every visible
// feature destination that isn't a primary slot, plus the shared utility
// tail. Cycle is gated by the same flag the sidebar uses, so the two
// surfaces gate identically and cannot drift.
const moreHub = useMemo<ReadonlyArray<NavLink>>(
() =>
mobileMoreHubDestinations({
modules: user?.modules,
}),
[user?.modules],
);
// v1.18.0 — drop a module-gated primary slot (Insights) when the account
// has that module disabled. Fail-open: a missing key / unloaded map keeps
// the slot, mirroring the gate + More-hub default-on contract.
const primaryRight = useMemo(
() =>
PRIMARY_RIGHT.filter(
(item) =>
!item.requiresModule ||
user?.modules?.[item.requiresModule] !== false,
),
[user?.modules],
);
function isActiveLink(href: string) {
return isNavDestinationActive(href, pathname);
}
// The "More" entry is treated as active when any of its children
// is the current page — gives the user a clue that they've drilled
// into a sub-route via the hub.
const moreActive = moreHub.some((item) => isActiveLink(item.href));
function renderPrimary(item: NavLink) {
const isActive = isActiveLink(item.href);
return (
<Link
key={item.href}
href={item.href}
aria-label={t(item.tKey)}
aria-current={isActive ? "page" : undefined}
{...(item.href === "/medications" ? medsIntent : {})}
// Touch target sized to WCAG 2.5.5 (44×44 CSS px). The
// outer min-h-11 min-w-11 is the actual hit area; the icon
// stays visually centered at 20px so the design doesn't shift.
className={cn(
"focus-visible:ring-ring/50 relative flex min-h-11 min-w-11 flex-1 items-center justify-center rounded-lg transition-colors focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none",
isActive
? "text-primary"
: "text-muted-foreground hover:text-foreground",
)}
>
<item.icon className="h-5 w-5" />
{isActive && (
<span className="bg-primary absolute bottom-1.5 h-1 w-1 rounded-full" />
)}
</Link>
);
}
return (
<>
{/* v1.4.33 F14 — bar height and safe-area defence so the bar can
never overlap the last viewport line of scrollable page content.
`min-h-16` floors the bar at 64px; `pb-[env(safe-area-inset-bottom)]`
reserves the home-indicator inset on iOS / iPadOS PWA installs;
`bg-card` + `border-t` give the bar a clean visual edge so page
content that scrolls under the bar is occluded, not bleeding
through. */}
<nav
aria-label={t("nav.mobileNavigation")}
className="bg-card border-border fixed bottom-0 left-0 z-50 min-h-16 w-full border-t pb-[env(safe-area-inset-bottom)] backdrop-blur-md md:hidden"
>
<div className="mx-auto flex h-16 max-w-lg items-stretch justify-around px-1">
{PRIMARY_LEFT.map(renderPrimary)}
{/* Center capture action — a labeled button (NOT a link) that
opens the capture picker. Elevated as a Floating-Action-
Button: it sits larger than the flanking tabs (h-14 = 56px,
well above the 44px tap-target floor), lifts above the bar
with a negative top margin, and carries a filled-primary
collar (a `bg-card` ring that punches it out of the bar) plus
a stronger shadow so it reads as the bar's primary CTA rather
than a fifth flush tab. */}
<div className="flex flex-1 items-center justify-center">
<button
type="button"
aria-label={t("nav.capture.title")}
aria-haspopup="dialog"
aria-expanded={captureOpen}
onClick={() => setCaptureOpen(true)}
data-testid="bottom-nav-capture"
className="bg-primary text-primary-foreground hover:bg-primary/90 focus-visible:ring-ring/70 ring-card -mt-5 flex h-14 w-14 items-center justify-center rounded-full shadow-lg ring-4 transition-colors focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none"
>
<Plus className="h-7 w-7" />
</button>
</div>
{primaryRight.map(renderPrimary)}
<button
type="button"
aria-label={t("nav.more")}
aria-haspopup="dialog"
aria-expanded={moreOpen}
aria-current={moreActive ? "page" : undefined}
onClick={() => setMoreOpen(true)}
className={cn(
"focus-visible:ring-ring/50 relative flex min-h-11 min-w-11 flex-1 items-center justify-center rounded-lg transition-colors focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none",
moreActive
? "text-primary"
: "text-muted-foreground hover:text-foreground",
)}
data-testid="bottom-nav-more"
>
<MoreHorizontal className="h-5 w-5" />
{moreActive && (
<span className="bg-primary absolute bottom-1.5 h-1 w-1 rounded-full" />
)}
</button>
</div>
</nav>
<CapturePicker open={captureOpen} onOpenChange={setCaptureOpen} />
<Sheet open={moreOpen} onOpenChange={setMoreOpen}>
<SheetContent
side="bottom"
// The sheet auto-sizes to its content; padding-bottom respects
// the iOS safe area so the inner buttons stay reachable above
// the home-indicator on iPhone-X-class devices.
className="mx-auto max-w-md rounded-t-xl pb-[calc(env(safe-area-inset-bottom)+1rem)] md:hidden"
data-testid="bottom-nav-more-sheet"
>
<SheetHeader>
<SheetTitle>{t("nav.moreSheetTitle")}</SheetTitle>
<SheetDescription>{t("nav.moreSheetDescription")}</SheetDescription>
</SheetHeader>
{/* v1.22.1 — tighten the grid gutters (`px-3`) so each tile claims a
little more width on a 320 px phone; the long single-word labels
(de "Benachrichtigungen") then have room to break onto a clean
second line instead of clipping the last character at the tile
edge. */}
<div className="grid grid-cols-2 gap-2 px-3 pb-4">
{moreHub.map((item) => {
const isActive = isActiveLink(item.href);
return (
<Link
key={item.href}
href={item.href}
onClick={() => setMoreOpen(false)}
aria-current={isActive ? "page" : undefined}
// v1.12 — `min-h-14` matches the capture-picker tiles so
// the equivalent tappable rows share one height at this
// tier (and the larger row is a more comfortable target).
// v1.22.1 — trim the inner padding/gap (`px-3`, `gap-2.5`)
// to hand the label more horizontal room without changing
// the shared tile height.
className={cn(
"border-border flex min-h-14 items-center gap-2.5 rounded-lg border px-3 py-3 transition-colors",
isActive
? "text-primary bg-primary/5"
: "text-foreground hover:bg-accent/40",
)}
>
<item.icon className="h-5 w-5 shrink-0" aria-hidden="true" />
{/* v1.18.10 (W10) — show the FULL label, never an ellipsis
that hides meaning. The W7 `truncate` clipped long words
("Benachrichtigungen" → "Benachrichtigun…"); the goal is a
readable label, not a balanced tile. Shrink the type to
`text-xs` so long single-word locales (de/es/fr/it) fit on
one line where they can, and allow a 2-line `line-clamp-2`
fallback (with `[overflow-wrap:anywhere]` so a long word
breaks rather than overflowing) for the rare label that
still cannot fit one line. `min-w-0` lets the flex child
shrink below its content width. v1.22.1 — with the wider
text area above, "Benachrichtigungen" wraps fully inside
two lines down to a 320 px viewport. */}
<span
className="line-clamp-2 min-w-0 text-xs leading-tight font-medium [overflow-wrap:anywhere]"
title={t(item.tKey)}
>
{t(item.tKey)}
</span>
</Link>
);
})}
</div>
</SheetContent>
</Sheet>
</>
);
}