mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-07 12:22:03 +00:00
feat(onboarding): auto-launch tour for new users on first dashboard load
v1.4.15 Phase B5 — third commit. Wires the spotlight tour
(`<OnboardingTour>` from previous commit) to auto-fire for new users
the first time they land on the dashboard, persists completion to
the DB, and exposes a window-event hook for replay.
Pieces:
- `<TourLauncher>` (`src/components/onboarding/tour-launcher.tsx`)
— gates and mounts the tour. Self-decides via:
* `user.onboardingTourCompleted` (DB flag from migration 0030)
* sessionStorage dismiss guard (defensive against in-flight
`/api/auth/me` refetches re-opening the tour after Skip)
* post-wizard 1500ms grace window (avoids the wizard's
`doneToast` and the spotlight stacking on the same z-layer)
* a `ready` prop the dashboard sets once analytics has
resolved (so the spotlight cutout doesn't snap to a 0×0
placeholder before tiles render)
Listens for `healthlog:tour-restart` to force-reopen.
- `POST /api/onboarding/tour` — single endpoint with body
`{ completed: boolean, outcome?: "completed" | "skipped" }`.
Flips `users.onboarding_tour_completed`. Dispatches a Wide
Event annotation with the outcome so Loki can split completion
from dismiss without a second column.
- `/api/auth/me` now returns `onboardingTourCompleted`. The
`AuthUser` type in `src/hooks/use-auth.ts` gains the field;
no consumer outside the launcher reads it today.
- `data-tour-id` attributes added to:
* dashboard tile-strip wrapper (`dashboard-tile-strip`)
* dashboard "Add" dropdown trigger (`dashboard-quick-add`)
* sidebar nav items (`nav-insights`, `nav-settings`,
`nav-achievements`, plus the rest for symmetry —
targeted attribute, not class names, so renames in
Tailwind classes don't silently break the spotlight)
- The `/onboarding` wizard's `persistAndExit()` writes
`sessionStorage["healthlog-tour-referrer"] = "1"` immediately
before navigating to `/`. The launcher consumes-and-clears that
on first mount via a `useState` lazy initialiser (presence-only
flag — no `Date.now()` so the render-phase decision stays pure
under `react-hooks/purity`).
ESLint compliance:
- `react-hooks/set-state-in-effect` — no synchronous setState in
a useEffect body. The launch decision happens in the render
phase using the React-recommended "previous input id" guard
(mirrors `account-section.tsx`'s `seededUserId`), and the
post-wizard timer's setState lives inside a setTimeout
callback (an event handler, not the effect body).
- `react-hooks/purity` — render-phase setState only branches on
prop values + sessionStorage-presence helpers. No `Date.now()`
/ `Math.random()` / fetch in render.
Tests: my 39 unit tests pass (was 27 — +12 across tour-state and
tour SSR shape). Pre-existing test failures in
`doctor-report-data.test.ts` are uncommitted B6 work in the
working tree, not B5. Lint clean for B5 files; one pre-existing
B6 lint error in `doctor-report-dialog.tsx` is out of scope.
Typecheck has the same pre-existing `dashboard-layout.test.ts`
errors documented in STATE.md (A4 / phase-D scope).
Restart UX (Settings → Account → "Restart tour") lands in the next
commit (B5-4) so this commit stays orthogonal.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
53561d3ad5
commit
8215e25876
7 files changed
+359
-8
No files matched your search
@@ -20,6 +20,7 @@ export const GET = apiHandler(async () => {
|
||||
gender: user.gender,
|
||||
timezone: user.timezone,
|
||||
onboardingCompletedAt: user.onboardingCompletedAt,
|
||||
onboardingTourCompleted: user.onboardingTourCompleted,
|
||||
gravatarUrl: user.email ? getGravatarUrl(user.email) : null,
|
||||
glucoseUnit: user.glucoseUnit ?? null,
|
||||
});
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
import { NextRequest } from "next/server";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import { prisma } from "@/lib/db";
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { apiSuccess, apiError, safeJson } from "@/lib/api-response";
|
||||
|
||||
/**
|
||||
* v1.4.15 Phase B5 — onboarding tour completion endpoint.
|
||||
*
|
||||
* Two operations on a single resource:
|
||||
*
|
||||
* POST { completed: true, outcome: "completed" | "skipped" }
|
||||
* → flips `users.onboarding_tour_completed` to true. Auto-fires
|
||||
* when the user reaches the end of the spotlight tour OR
|
||||
* explicitly dismisses it.
|
||||
*
|
||||
* POST { completed: false }
|
||||
* → resets the flag so the user can replay the tour from
|
||||
* Settings → Account → "Restart onboarding tour".
|
||||
*
|
||||
* The `outcome` field is informational — it lands in the structured
|
||||
* Wide Event annotation so analytics can tell completion from
|
||||
* dismiss without writing two columns. Same DB write either way.
|
||||
*/
|
||||
|
||||
const tourBodySchema = z.object({
|
||||
completed: z.boolean(),
|
||||
outcome: z.enum(["completed", "skipped"]).optional(),
|
||||
});
|
||||
|
||||
export const POST = apiHandler(async (request: NextRequest) => {
|
||||
const { user } = await requireAuth();
|
||||
|
||||
const { data: body, error: jsonError } = await safeJson(request);
|
||||
if (jsonError) return jsonError;
|
||||
|
||||
const parsed = z.safeParse(tourBodySchema, body);
|
||||
if (!parsed.success) {
|
||||
return apiError("Invalid input", 422);
|
||||
}
|
||||
|
||||
await prisma.user.update({
|
||||
where: { id: user.id },
|
||||
data: { onboardingTourCompleted: parsed.data.completed },
|
||||
});
|
||||
|
||||
annotate({
|
||||
action: { name: "onboarding.tour.update" },
|
||||
meta: {
|
||||
completed: parsed.data.completed,
|
||||
outcome: parsed.data.outcome ?? null,
|
||||
},
|
||||
});
|
||||
|
||||
return apiSuccess({ onboardingTourCompleted: parsed.data.completed });
|
||||
});
|
||||
@@ -121,6 +121,20 @@ export default function OnboardingPage() {
|
||||
await queryClient.invalidateQueries({ queryKey: ["auth"] });
|
||||
toast.success(t("onboarding.v2.doneToast"));
|
||||
|
||||
// v1.4.15 Phase B5: leave a presence flag so the dashboard's
|
||||
// tour launcher knows the user JUST came from the wizard and
|
||||
// delays the spotlight by ~1.5s. Avoids the doneToast and
|
||||
// tour overlay stacking on the same z-layer. We use a
|
||||
// presence-only `"1"` (not a timestamp) so the launcher's
|
||||
// render-phase decision can stay pure under
|
||||
// `react-hooks/purity` — the launcher consumes-and-clears the
|
||||
// key on first read.
|
||||
try {
|
||||
window.sessionStorage.setItem("healthlog-tour-referrer", "1");
|
||||
} catch {
|
||||
/* ignore — storage disabled or full */
|
||||
}
|
||||
|
||||
if (opts?.gotoSettingsHash) {
|
||||
router.replace(`/settings/notifications#${opts.gotoSettingsHash}`);
|
||||
} else {
|
||||
|
||||
+17
-1
@@ -42,6 +42,7 @@ import { TrendCard } from "@/components/charts/trend-card";
|
||||
import { TrendHint } from "@/components/charts/trend-hint";
|
||||
import { summaryToTrend7Delta } from "@/lib/analytics/trend-delta";
|
||||
import { GettingStartedChecklist } from "@/components/onboarding/getting-started-checklist";
|
||||
import { TourLauncher } from "@/components/onboarding/tour-launcher";
|
||||
import { RecentAchievementsCard } from "@/components/gamification/recent-achievements-card";
|
||||
|
||||
const HealthChart = dynamic(
|
||||
@@ -406,7 +407,11 @@ export default function DashboardPage() {
|
||||
`size="sm"` (h-8 = 32px) was below threshold; the explicit
|
||||
min-h-11 ensures we hit 44px on the Pixel 5 viewport while
|
||||
keeping the desktop visual unchanged. */}
|
||||
<Button size="sm" className="min-h-11">
|
||||
<Button
|
||||
size="sm"
|
||||
className="min-h-11"
|
||||
data-tour-id="dashboard-quick-add"
|
||||
>
|
||||
<Plus className="mr-1 h-4 w-4" />
|
||||
{t("common.add")}
|
||||
</Button>
|
||||
@@ -439,6 +444,16 @@ export default function DashboardPage() {
|
||||
* or fully complete. See B2 in the v1.4 discovery summary. */}
|
||||
<GettingStartedChecklist />
|
||||
|
||||
{/* v1.4.15 Phase B5 — spotlight tour for first-time users.
|
||||
* Self-gates on `user.onboardingTourCompleted` (DB flag) plus
|
||||
* a session-storage dismiss guard. We pass `ready=true` only
|
||||
* after analytics has resolved — the tour anchors to the tile
|
||||
* strip and we don't want the cutout snapping to a 0×0
|
||||
* placeholder before tiles render. The launcher mounts a no-op
|
||||
* `null` when the user has already seen the tour, so this
|
||||
* line is free for established users. */}
|
||||
<TourLauncher ready={data !== undefined} />
|
||||
|
||||
{/* Quick Entry Dialogs */}
|
||||
<Dialog
|
||||
open={quickEntryDialog === "measurement"}
|
||||
@@ -963,6 +978,7 @@ export default function DashboardPage() {
|
||||
// it preserves.
|
||||
className="grid auto-rows-fr [grid-template-columns:repeat(auto-fit,minmax(9rem,1fr))] gap-3 pb-2"
|
||||
data-slot="dashboard-tile-strip"
|
||||
data-tour-id="dashboard-tile-strip"
|
||||
data-tile-count={trendCards.length}
|
||||
>
|
||||
{trendCards.map((entry) => (
|
||||
|
||||
@@ -51,13 +51,36 @@ import {
|
||||
const STORAGE_KEY = "healthlog-sidebar-collapsed";
|
||||
|
||||
const navItems = [
|
||||
{ href: "/", tKey: "nav.dashboard", icon: Home },
|
||||
{ href: "/measurements", tKey: "nav.measurements", icon: Activity },
|
||||
{ href: "/mood", tKey: "nav.mood", icon: Waves },
|
||||
{ href: "/medications", tKey: "nav.medications", icon: Pill },
|
||||
{ href: "/insights", tKey: "nav.insights", icon: Lightbulb },
|
||||
{ href: "/targets", tKey: "nav.targets", icon: Target },
|
||||
{ href: "/achievements", tKey: "nav.achievements", icon: Trophy },
|
||||
{ href: "/", tKey: "nav.dashboard", icon: Home, tourId: "nav-dashboard" },
|
||||
{
|
||||
href: "/measurements",
|
||||
tKey: "nav.measurements",
|
||||
icon: Activity,
|
||||
tourId: "nav-measurements",
|
||||
},
|
||||
{ href: "/mood", tKey: "nav.mood", icon: Waves, tourId: "nav-mood" },
|
||||
{
|
||||
href: "/medications",
|
||||
tKey: "nav.medications",
|
||||
icon: Pill,
|
||||
tourId: "nav-medications",
|
||||
},
|
||||
// v1.4.15 Phase B5: `tourId` values match `data-tour-id` lookups
|
||||
// performed by the onboarding tour. Keep these stable — renaming
|
||||
// them silently breaks the spotlight cutout for that step.
|
||||
{
|
||||
href: "/insights",
|
||||
tKey: "nav.insights",
|
||||
icon: Lightbulb,
|
||||
tourId: "nav-insights",
|
||||
},
|
||||
{ href: "/targets", tKey: "nav.targets", icon: Target, tourId: "nav-targets" },
|
||||
{
|
||||
href: "/achievements",
|
||||
tKey: "nav.achievements",
|
||||
icon: Trophy,
|
||||
tourId: "nav-achievements",
|
||||
},
|
||||
];
|
||||
|
||||
function getInitials(name: string): string {
|
||||
@@ -335,6 +358,7 @@ export function SidebarNav() {
|
||||
<Link
|
||||
href={item.href}
|
||||
aria-current={isActive ? "page" : undefined}
|
||||
data-tour-id={item.tourId}
|
||||
className={cn(
|
||||
"flex items-center justify-center rounded-lg p-2.5 transition-colors",
|
||||
isActive
|
||||
@@ -357,6 +381,7 @@ export function SidebarNav() {
|
||||
key={item.href}
|
||||
href={item.href}
|
||||
aria-current={isActive ? "page" : undefined}
|
||||
data-tour-id={item.tourId}
|
||||
className={cn(
|
||||
"flex items-center gap-3 rounded-lg px-3 py-2.5 text-sm font-medium transition-colors",
|
||||
isActive
|
||||
@@ -427,6 +452,7 @@ export function SidebarNav() {
|
||||
aria-current={
|
||||
pathname.startsWith("/settings") ? "page" : undefined
|
||||
}
|
||||
data-tour-id="nav-settings"
|
||||
className={cn(
|
||||
"flex items-center justify-center rounded-lg p-2.5 transition-colors",
|
||||
pathname.startsWith("/settings")
|
||||
@@ -514,6 +540,7 @@ export function SidebarNav() {
|
||||
aria-current={
|
||||
pathname.startsWith("/settings") ? "page" : undefined
|
||||
}
|
||||
data-tour-id="nav-settings"
|
||||
className={cn(
|
||||
"flex items-center gap-3 rounded-lg px-3 py-2.5 text-sm font-medium transition-colors",
|
||||
pathname.startsWith("/settings")
|
||||
|
||||
@@ -0,0 +1,228 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* v1.4.15 Phase B5 — onboarding-tour launcher.
|
||||
*
|
||||
* Decides *when* to start the spotlight tour. Mounted on the
|
||||
* dashboard; opens the tour exactly once for new users and never
|
||||
* again unless they explicitly request a replay from
|
||||
* Settings → Account.
|
||||
*
|
||||
* Gating rules:
|
||||
*
|
||||
* 1. Wait until the auth payload (`/api/auth/me`) has resolved so
|
||||
* we know `onboardingTourCompleted`.
|
||||
* 2. Wait until the dashboard's analytics fetch has resolved.
|
||||
* The tour spotlights the tile strip — if we open before tiles
|
||||
* have rendered, the cutout snaps to a 0×0 invisible target
|
||||
* and the tooltip fallbacks to centre-screen, breaking the
|
||||
* first impression. The launcher subscribes to the same
|
||||
* `["analytics"]` query the dashboard uses; the data resolves
|
||||
* synchronously on subsequent visits via the React Query cache.
|
||||
* 3. Don't auto-launch within 1500ms of the user navigating away
|
||||
* from `/onboarding`. The wizard ends with a green-tick toast
|
||||
* that overlaps the same z-index range; if the tour pops up
|
||||
* mid-toast it reads as a double overlay.
|
||||
* 4. Honour a localStorage dismiss flag for the *current session*.
|
||||
* Mostly defensive — when the API call to flip the DB flag
|
||||
* races with an in-flight `/api/auth/me` refetch, the cached
|
||||
* response could re-open the tour. The localStorage guard
|
||||
* blocks that for the rest of the session.
|
||||
*
|
||||
* Replay flow: the Settings → Account "Restart" button posts
|
||||
* `{ completed: false }` to /api/onboarding/tour, then dispatches a
|
||||
* `healthlog:tour-restart` window event. This launcher listens for
|
||||
* that event and force-opens the tour without reading the flag —
|
||||
* convenient because invalidating the auth cache + waiting for the
|
||||
* round-trip would feel laggy.
|
||||
*/
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { OnboardingTour } from "./tour";
|
||||
|
||||
const SESSION_DISMISS_KEY = "healthlog-tour-session-dismissed";
|
||||
const POST_WIZARD_GRACE_MS = 1500;
|
||||
const REFERRER_KEY = "healthlog-tour-referrer";
|
||||
|
||||
/**
|
||||
* Whether the user just navigated here from `/onboarding`. Read
|
||||
* once at mount via a `useState` lazy initialiser (which runs in a
|
||||
* pure-by-contract slot, not the render body) — see the
|
||||
* `useState(() => …)` call in `<TourLauncher>`. The wizard's
|
||||
* `persistAndExit()` writes `"1"` into sessionStorage immediately
|
||||
* before navigating, and the launcher consumes-and-clears it on
|
||||
* first mount so a back-button to the wizard and forward again
|
||||
* doesn't double-defer.
|
||||
*/
|
||||
function readAndClearJustFromWizard(): boolean {
|
||||
if (typeof window === "undefined") return false;
|
||||
try {
|
||||
const value = window.sessionStorage.getItem(REFERRER_KEY);
|
||||
if (value !== "1") return false;
|
||||
window.sessionStorage.removeItem(REFERRER_KEY);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function readSessionDismissed(): boolean {
|
||||
if (typeof window === "undefined") return false;
|
||||
try {
|
||||
return window.sessionStorage.getItem(SESSION_DISMISS_KEY) === "1";
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function writeSessionDismissed() {
|
||||
if (typeof window === "undefined") return;
|
||||
try {
|
||||
window.sessionStorage.setItem(SESSION_DISMISS_KEY, "1");
|
||||
} catch {
|
||||
/* ignore — sandboxed iframes etc. */
|
||||
}
|
||||
}
|
||||
|
||||
function clearSessionDismissed() {
|
||||
if (typeof window === "undefined") return;
|
||||
try {
|
||||
window.sessionStorage.removeItem(SESSION_DISMISS_KEY);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
|
||||
interface TourLauncherProps {
|
||||
/**
|
||||
* The dashboard passes a sentinel — true once analytics has
|
||||
* resolved at least once. Without this gate the spotlight would
|
||||
* try to anchor to tiles that haven't rendered yet, and Recharts
|
||||
* widgets defer mounting too. This is a simple, race-free signal
|
||||
* the dashboard already computes.
|
||||
*/
|
||||
ready: boolean;
|
||||
}
|
||||
|
||||
export function TourLauncher({ ready }: TourLauncherProps) {
|
||||
const { user, isAuthenticated, isLoading } = useAuth();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
// `null` = decision not yet made; `true` = render the tour;
|
||||
// `false` = don't render. We only flip null→true ONCE per mount;
|
||||
// closing the tour transitions true→false and we never re-open
|
||||
// unless the restart event fires.
|
||||
const [showTour, setShowTour] = useState<boolean | null>(null);
|
||||
// Lazy initial check for "user just exited the wizard". The
|
||||
// initialiser runs once at mount — sessionStorage is pure-enough
|
||||
// there because React only invokes the initialiser one time per
|
||||
// component instance, and in dev StrictMode's double-call merely
|
||||
// returns the same `true` (the second call sees the cleared key
|
||||
// and returns `false`, but the state has already been seeded). We
|
||||
// accept that tiny behavioural quirk because it only affects dev
|
||||
// mode and the consequence is "tour doesn't auto-launch on first
|
||||
// dev mount" — never surfaces in production.
|
||||
const [justFromWizard] = useState<boolean>(() =>
|
||||
readAndClearJustFromWizard(),
|
||||
);
|
||||
// Track post-wizard deferral as a boolean so the render path stays
|
||||
// pure (no Date.now() comparisons). The effect below reads / clears it.
|
||||
const [deferredFromWizard, setDeferredFromWizard] = useState(false);
|
||||
// Tracks the (userId, flag) tuple the launch-decision evaluator
|
||||
// has already run for. The render-phase decision below uses this
|
||||
// as the React-recommended "previous input id" guard so the
|
||||
// setState block runs at most once per (user, flag) transition.
|
||||
const [decidedFor, setDecidedFor] = useState<{
|
||||
userId: string;
|
||||
flag: boolean;
|
||||
} | null>(null);
|
||||
|
||||
const inputsReady = !isLoading && isAuthenticated && !!user && ready;
|
||||
// The set-state-in-render pattern (mirrors `account-section.tsx`'s
|
||||
// `seededUserId` — React-recommended for "compute initial state
|
||||
// from props once they arrive"). Pure: branches on prop values
|
||||
// and constant-result helpers only.
|
||||
if (
|
||||
showTour === null &&
|
||||
inputsReady &&
|
||||
user &&
|
||||
(decidedFor === null ||
|
||||
decidedFor.userId !== user.id ||
|
||||
decidedFor.flag !== user.onboardingTourCompleted)
|
||||
) {
|
||||
setDecidedFor({ userId: user.id, flag: user.onboardingTourCompleted });
|
||||
if (user.onboardingTourCompleted) {
|
||||
setShowTour(false);
|
||||
} else if (readSessionDismissed()) {
|
||||
setShowTour(false);
|
||||
} else if (justFromWizard) {
|
||||
// Mark "deferred"; the effect below schedules a fixed-duration
|
||||
// timer + flips `showTour` from inside the timeout callback
|
||||
// (which the set-state-in-effect rule allows).
|
||||
setShowTour(false);
|
||||
setDeferredFromWizard(true);
|
||||
} else {
|
||||
setShowTour(true);
|
||||
}
|
||||
}
|
||||
|
||||
// Wait out the post-wizard grace window. Side-effect-only (timer
|
||||
// management); the inner setState lives inside a callback so the
|
||||
// set-state-in-effect rule is satisfied.
|
||||
useEffect(() => {
|
||||
if (!deferredFromWizard) return;
|
||||
const id = window.setTimeout(() => {
|
||||
if (!readSessionDismissed()) {
|
||||
setShowTour(true);
|
||||
}
|
||||
setDeferredFromWizard(false);
|
||||
}, POST_WIZARD_GRACE_MS);
|
||||
return () => window.clearTimeout(id);
|
||||
}, [deferredFromWizard]);
|
||||
|
||||
// Listen for explicit restart events from Settings → Account.
|
||||
// setState inside a window-event listener is fine under the
|
||||
// set-state-in-effect rule.
|
||||
useEffect(() => {
|
||||
function onRestart() {
|
||||
clearSessionDismissed();
|
||||
setShowTour(true);
|
||||
}
|
||||
window.addEventListener("healthlog:tour-restart", onRestart);
|
||||
return () => {
|
||||
window.removeEventListener("healthlog:tour-restart", onRestart);
|
||||
};
|
||||
}, []);
|
||||
|
||||
if (showTour !== true) return null;
|
||||
|
||||
return (
|
||||
<OnboardingTour
|
||||
onClose={async (outcome) => {
|
||||
// Optimistic: hide the overlay immediately, persist in the
|
||||
// background. The flag is idempotent so re-fires are safe;
|
||||
// a network error here is non-fatal because the
|
||||
// session-dismiss guard prevents the tour from re-launching
|
||||
// for the rest of the session, and the next refetch of
|
||||
// `/api/auth/me` (which the user gets on any nav) will
|
||||
// surface any divergence.
|
||||
setShowTour(false);
|
||||
writeSessionDismissed();
|
||||
try {
|
||||
await fetch("/api/onboarding/tour", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ completed: true, outcome }),
|
||||
});
|
||||
await queryClient.invalidateQueries({ queryKey: ["auth"] });
|
||||
} catch {
|
||||
// Swallow — the session-dismiss flag prevents a re-open.
|
||||
// Next visit will retry via the same code path.
|
||||
}
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -13,6 +13,13 @@ export interface AuthUser {
|
||||
gender: string | null;
|
||||
timezone: string;
|
||||
onboardingCompletedAt: string | null;
|
||||
/**
|
||||
* v1.4.15 Phase B5: whether the user has finished or dismissed
|
||||
* the spotlight tour overlaid on the dashboard. Distinct from
|
||||
* `onboardingCompletedAt` (the wizard at /onboarding) — see the
|
||||
* `<TourLauncher>` component for the gating logic.
|
||||
*/
|
||||
onboardingTourCompleted: boolean;
|
||||
gravatarUrl: string | null;
|
||||
glucoseUnit: string | null;
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user