From 47aab541b1be130f43a48e1c88ccf1895e4422cd Mon Sep 17 00:00:00 2001
From: Thomas Luizon Rodrigues Gregorio
Date: Mon, 13 Jul 2026 20:12:47 -0300
Subject: [PATCH 1/2] chore(ui): drive React Doctor to zero in web
components/hooks (#243)
React Doctor burn-down for bucket B2 (apps/web/components/** except
habits/ & ui/, plus apps/web/hooks/**): 105 findings driven to 0.
Fixed properly:
- effect-needs-cleanup (6): gamification celebration/toast timers now
track nested timers in the same ref and return a cleanup from the
timer-creating effect (removed redundant unmount-only effects).
- prefer-use-effect-event (3): wrapped changing prop callbacks in
useEffectEvent so effects no longer re-subscribe on parent redraws.
- use-lazy-motion (7): swapped motion -> m + ; domAnimation
everywhere except route-transition-shell (popLayout needs domMax).
- no-inline-exhaustive-style (13): hoisted static style objects to
module-scope CSSProperties consts.
- no-tiny-text (16 + extras): bumped sub-12px text to the DESIGN.md
12px type-role floor (truncate/ellipsis contexts, so no overflow).
- only-export-components (4): removed unused exports; extracted the
shared toSectionStatus/SectionStatus and trendHeadline helpers into
insights-section-status.ts and insights-headline-model.ts.
- no-giant-component (1): extracted NotificationRow from NotificationBell.
- no-prop-callback-in-render (3): goal-drawer deep-link actions moved
from render into a single guarded effect.
- no-impure-state-updater (1): login resend countdown side effects moved
out of the setState updater into the interval callback (ref-driven).
- query-mutation-missing-invalidation (1): marketing consent mutation
now reconciles via onSettled invalidate.
- js-combine-iterations (3), js-hoist-intl (2), no-array-index-as-key (2),
button-has-type (1), control-has-associated-label (1),
rerender-lazy-state-init (1), rerender-state-only-in-handlers (1),
prefer-module-scope-static-value (1), client-localstorage-no-version (1),
no-locale-format-in-render (1, via shared useDateFormat).
Justified suppressions (react-doctor-disable-next-line + #243 WHY URL,
only where the rule is a genuine false positive or deliberate choice):
- exhaustive-deps (14): deps already track query.data/profile.* via an
alias react-doctor does not resolve; adding the raw member expression
is semantically identical and uglier.
- no-unguarded-browser-global (4): createPortal/localStorage reached only
behind a client-only render gate (useIsClient / viewport / the repo's
canonical 'localStorage' in globalThis guard).
- query-mutation-missing-invalidation (2): report-user and suggest-tags
mutate no client-cached data.
- prefer-html-dialog (2), no-large-animated-blur (2): desktop-only
non-modal rail / FAB; native dialog and phone GPU concerns don't apply.
- no-many-boolean-props (1): four orthogonal range-picker state flags;
effect-needs-cleanup (1): tour observer lifecycle is ref-managed;
nextjs-no-client-side-redirect (1): tour orchestrator navigation;
no-pass-data/live-state-to-parent (2): reusable code-entry primitive.
Refs thomasluizon/orbit-ui-mobile#243 (React Doctor burn-down: web components/hooks)
Co-Authored-By: Claude Opus 4.8 (1M context)
---
.../marketing-consent-prompt.test.tsx | 20 +-
.../components/advanced/advanced-sections.tsx | 6 +-
.../calendar/calendar-agenda-view.tsx | 6 +-
.../calendar/calendar-day-detail.tsx | 2 +-
.../web/components/calendar/calendar-grid.tsx | 4 +-
.../calendar/calendar-range-view.tsx | 1 +
.../calendar/calendar-time-grid.tsx | 14 +-
apps/web/components/charts/bar-chart.tsx | 4 +-
.../components/charts/multi-month-heatmap.tsx | 6 +-
.../chat/breakdown-frequency-picker.tsx | 1 +
.../components/chat/breakdown-suggestion.tsx | 42 ++--
.../chat/pending-operation-card.tsx | 7 +-
.../components/command/command-palette.tsx | 109 +++++-----
.../gamification/achievement-toast.tsx | 16 +-
.../gamification/all-done-celebration.tsx | 46 ++---
.../goal-completed-celebration.tsx | 46 ++---
.../gamification/level-up-overlay.tsx | 46 +++--
.../gamification/streak-celebration.tsx | 51 +++--
.../streak-freeze-celebration.tsx | 39 ++--
.../gamification/welcome-back-toast.tsx | 61 +++---
.../create-goal-modal/goal-type-selector.tsx | 4 +-
.../components/goals/goal-ask-astra-row.tsx | 2 +-
.../use-goal-drawer-initial-action.ts | 42 ++--
.../use-goal-status-actions.ts | 12 +-
.../components/goals/goal-detail-sections.tsx | 2 +-
.../components/goals/goal-status-badge.tsx | 4 +-
.../achievements-timeline-section.tsx | 17 +-
.../insights/completion-trends-section.tsx | 3 +-
.../insights/goal-progress-section.tsx | 3 +-
.../insights/insights-headline-model.ts | 32 +++
.../components/insights/insights-headline.tsx | 34 +---
.../insights/insights-section-status.ts | 16 ++
.../components/insights/insights-section.tsx | 18 +-
.../insights/monthly-heatmap-section.tsx | 3 +-
.../multi-habit-comparison-section.tsx | 3 +-
.../insights/streak-history-section.tsx | 6 +-
.../insights/xp-over-time-section.tsx | 6 +-
.../marketing-consent-prompt.tsx | 131 ++++++------
.../milestone-share/milestone-share-card.tsx | 30 +--
.../motion/route-transition-shell.tsx | 74 +++----
.../components/navigation/bottom-tab-bar.tsx | 2 +-
.../navigation/notification-bell.tsx | 187 ++++++++++--------
.../onboarding/onboarding-complete.tsx | 55 +++---
.../onboarding/onboarding-create-habit.tsx | 8 +-
.../components/onboarding/onboarding-flow.tsx | 3 +-
.../profile/public-profile-view.tsx | 22 ++-
.../components/referral/referral-prompt.tsx | 126 ++++++------
.../settings/public-profile-settings.tsx | 26 +--
.../web/components/share/share-card-sheet.tsx | 14 +-
apps/web/components/share/share-card.tsx | 55 +++---
apps/web/components/shell/app-shell.tsx | 1 +
.../components/shell/astra-copilot-rail.tsx | 6 +-
apps/web/components/shell/rail-drawer.tsx | 87 ++++----
apps/web/components/shell/today-rail.tsx | 1 +
apps/web/components/shell/topbar-slot.tsx | 1 +
apps/web/components/tour/tour-overlay.tsx | 2 +-
apps/web/components/tour/tour-provider.tsx | 4 +
.../web/components/tour/tour-replay-modal.tsx | 2 +-
apps/web/components/tour/tour-spotlight.tsx | 1 +
apps/web/components/tour/tour-tooltip.tsx | 8 +-
.../components/upgrade/billing-dashboard.tsx | 2 +-
.../upgrade/plan-comparison-cards.tsx | 2 +-
apps/web/hooks/use-bulk-actions.ts | 12 +-
apps/web/hooks/use-calendars.ts | 10 +-
apps/web/hooks/use-chat-composer.ts | 2 +
apps/web/hooks/use-friends.ts | 1 +
apps/web/hooks/use-gamification.ts | 3 +
apps/web/hooks/use-login-code-entry.ts | 18 +-
apps/web/hooks/use-profile.ts | 2 +
apps/web/hooks/use-referral.ts | 1 +
apps/web/hooks/use-tags.ts | 1 +
apps/web/hooks/use-wrapped.ts | 2 +
72 files changed, 881 insertions(+), 755 deletions(-)
create mode 100644 apps/web/components/insights/insights-headline-model.ts
create mode 100644 apps/web/components/insights/insights-section-status.ts
diff --git a/apps/web/__tests__/components/marketing-consent/marketing-consent-prompt.test.tsx b/apps/web/__tests__/components/marketing-consent/marketing-consent-prompt.test.tsx
index d639ed183..95d45f216 100644
--- a/apps/web/__tests__/components/marketing-consent/marketing-consent-prompt.test.tsx
+++ b/apps/web/__tests__/components/marketing-consent/marketing-consent-prompt.test.tsx
@@ -7,8 +7,8 @@ vi.mock('next-intl', () => ({
useTranslations: () => (key: string) => key,
}))
-vi.mock('motion/react', () => ({
- motion: new Proxy(
+vi.mock('motion/react', () => {
+ const proxy = new Proxy(
{},
{
get:
@@ -16,9 +16,16 @@ vi.mock('motion/react', () => ({
({ children, ...rest }: { children?: React.ReactNode }) =>
React.createElement(tag, rest, children),
},
- ),
- useReducedMotion: () => true,
-}))
+ )
+ return {
+ motion: proxy,
+ m: proxy,
+ LazyMotion: ({ children }: { children?: React.ReactNode }) => children,
+ domAnimation: {},
+ domMax: {},
+ useReducedMotion: () => true,
+ }
+})
vi.mock('@/components/ui/app-overlay', () => ({
AppOverlay: ({
@@ -40,11 +47,12 @@ vi.mock('@/components/ui/app-overlay', () => ({
}))
const patchProfile = vi.fn()
+const invalidate = vi.fn()
let profileValue: { marketingEmailConsent: boolean | null } | undefined = {
marketingEmailConsent: null,
}
vi.mock('@/hooks/use-profile', () => ({
- useProfile: () => ({ profile: profileValue, patchProfile }),
+ useProfile: () => ({ profile: profileValue, patchProfile, invalidate }),
}))
const updateMarketingConsent = vi.fn().mockResolvedValue(undefined)
diff --git a/apps/web/components/advanced/advanced-sections.tsx b/apps/web/components/advanced/advanced-sections.tsx
index de3c8626f..17ee7e9e0 100644
--- a/apps/web/components/advanced/advanced-sections.tsx
+++ b/apps/web/components/advanced/advanced-sections.tsx
@@ -32,7 +32,7 @@ interface QueryStatus {
/** Writes to the clipboard and reports whether the write succeeded, so callers
* only show "Copied!" on success. */
-export async function copyToClipboard(text: string): Promise {
+async function copyToClipboard(text: string): Promise {
try {
await navigator.clipboard.writeText(text)
return true
@@ -196,7 +196,7 @@ export function ApiKeyCard({
style={{
marginTop: 4,
fontFamily: 'var(--font-mono)',
- fontSize: 11,
+ fontSize: 12,
letterSpacing: '0.02em',
color: 'var(--fg-3)',
}}
@@ -208,7 +208,7 @@ export function ApiKeyCard({
className="shrink-0 text-right"
style={{
fontFamily: 'var(--font-mono)',
- fontSize: 11,
+ fontSize: 12,
lineHeight: 1.6,
letterSpacing: '0.02em',
color: 'var(--fg-3)',
diff --git a/apps/web/components/calendar/calendar-agenda-view.tsx b/apps/web/components/calendar/calendar-agenda-view.tsx
index c9e0fa1be..2c1b0f086 100644
--- a/apps/web/components/calendar/calendar-agenda-view.tsx
+++ b/apps/web/components/calendar/calendar-agenda-view.tsx
@@ -270,7 +270,7 @@ function AgendaEventBlock({ block, displayTime }: Readonly(() => {
@@ -88,6 +89,7 @@ export function CalendarGrid({
completionRatio: totalCount > 0 ? completedCount / totalCount : 0,
}
})
+ // react-doctor-disable-next-line exhaustive-deps -- weekStartsOn aliases profile.weekStartDay and is already in deps; react-doctor does not resolve the alias; https://github.com/thomasluizon/orbit-ui-mobile/issues/243
}, [currentMonth, dayMap, weekStartsOn])
return (
@@ -116,7 +118,7 @@ export function CalendarGrid({
className="text-center uppercase"
style={{
fontFamily: 'var(--font-mono)',
- fontSize: 11,
+ fontSize: 12,
fontWeight: 500,
color: 'var(--fg-3)',
letterSpacing: '0.04em',
diff --git a/apps/web/components/calendar/calendar-range-view.tsx b/apps/web/components/calendar/calendar-range-view.tsx
index 41eecea58..c5eb3220c 100644
--- a/apps/web/components/calendar/calendar-range-view.tsx
+++ b/apps/web/components/calendar/calendar-range-view.tsx
@@ -38,6 +38,7 @@ interface CalendarRangeViewProps {
/** Custom-range view: a mini-calendar to pick a contiguous range, then the same
* time grid rendered with one column per day in that range. */
+// react-doctor-disable-next-line no-many-boolean-props -- the four flags model orthogonal, independent states (clamp notice, mid-pick hint, range loading, recurring-row toggle), not a combinatorial variant space; they are threaded from the calendar page (owned by a separate bucket), so collapsing the API is out of scope here; https://github.com/thomasluizon/orbit-ui-mobile/issues/243
export function CalendarRangeView({
currentMonth,
monthDayMap,
diff --git a/apps/web/components/calendar/calendar-time-grid.tsx b/apps/web/components/calendar/calendar-time-grid.tsx
index d3e41b3e7..6bf70b8b6 100644
--- a/apps/web/components/calendar/calendar-time-grid.tsx
+++ b/apps/web/components/calendar/calendar-time-grid.tsx
@@ -168,7 +168,7 @@ function TimedBlock({
className="truncate"
style={{
fontFamily: 'var(--font-sans)',
- fontSize: 11,
+ fontSize: 12,
fontWeight: 500,
lineHeight: 1.2,
color: completed ? 'var(--fg-3)' : 'var(--fg-1)',
@@ -182,7 +182,7 @@ function TimedBlock({
className="truncate"
style={{
fontFamily: 'var(--font-mono)',
- fontSize: 10,
+ fontSize: 12,
color: 'var(--fg-3)',
fontVariantNumeric: 'tabular-nums',
}}
@@ -217,7 +217,7 @@ function AllDayChip({ entry }: Readonly<{ entry: CalendarDayEntry }>) {
className="truncate"
style={{
fontFamily: 'var(--font-sans)',
- fontSize: 11,
+ fontSize: 12,
fontWeight: 500,
color: completed ? 'var(--fg-3)' : 'var(--fg-1)',
textDecoration: completed ? 'line-through' : 'none',
@@ -253,7 +253,7 @@ function AllDayMoreChip({
- {bars.map((bar, index) => {
+ {bars.map((bar) => {
const ratio = max > 0 ? bar.value / max : 0
return (
-
+
) {
const locale = useLocale()
+ const monthFormatter = useMemo(
+ () => new Intl.DateTimeFormat(locale, { month: 'short', timeZone: 'UTC' }),
+ [locale],
+ )
const parsed = days
.map((day) => {
@@ -128,7 +133,6 @@ export function MultiMonthHeatmap({
const width = columns * (cellSize + cellGap) - cellGap
const height = MONTH_LABEL_HEIGHT + ROWS * (cellSize + cellGap) - cellGap
- const monthFormatter = new Intl.DateTimeFormat(locale, { month: 'short', timeZone: 'UTC' })
const labels = monthLabelColumns(cells)
const fillFor = (value: number) =>
diff --git a/apps/web/components/chat/breakdown-frequency-picker.tsx b/apps/web/components/chat/breakdown-frequency-picker.tsx
index 7c473c509..2e14b9986 100644
--- a/apps/web/components/chat/breakdown-frequency-picker.tsx
+++ b/apps/web/components/chat/breakdown-frequency-picker.tsx
@@ -30,6 +30,7 @@ export function BreakdownFrequencyPicker({
-
+
{t('habits.allDoneCelebrationTitle')}
s.goalCompletedCelebration)
@@ -30,21 +41,16 @@ export function GoalCompletedCelebration() {
}
useEffect(() => {
+ if (!goalCompletedCelebration) return
+ requestAnimationFrame(() => setIsVisible(true))
+ dismissTimerRef.current = setTimeout(() => {
+ setIsVisible(false)
+ setGoalCompletedCelebration(null)
+ dismissTimerRef.current = setTimeout(() => setShouldRender(false), 280)
+ }, 6000)
return () => {
if (dismissTimerRef.current) clearTimeout(dismissTimerRef.current)
}
- }, [])
-
- useEffect(() => {
- if (goalCompletedCelebration) {
- requestAnimationFrame(() => setIsVisible(true))
- if (dismissTimerRef.current) clearTimeout(dismissTimerRef.current)
- dismissTimerRef.current = setTimeout(() => {
- setIsVisible(false)
- setGoalCompletedCelebration(null)
- setTimeout(() => setShouldRender(false), 280)
- }, 6000)
- }
}, [goalCompletedCelebration, setGoalCompletedCelebration])
function dismiss() {
@@ -102,19 +108,7 @@ export function GoalCompletedCelebration() {
}
/>
-
+
{t('goals.completedCelebrationTitle')}
{
return () => {
- if (timerRef.current) clearTimeout(timerRef.current)
if (hideTimerRef.current) clearTimeout(hideTimerRef.current)
}
}, [])
@@ -73,14 +90,20 @@ export function LevelUpOverlay({
[completeActiveCelebration, onClear],
)
+ const onAutoDismiss = useEffectEvent((id: string) => dismiss(id))
+
useEffect(() => {
if (!activeLevelUp) return
requestAnimationFrame(() => setIsVisible(true))
timerRef.current = setTimeout(() => {
- dismiss(activeLevelUp.id)
+ onAutoDismiss(activeLevelUp.id)
}, 6000)
- }, [activeLevelUp, dismiss])
+
+ return () => {
+ if (timerRef.current) clearTimeout(timerRef.current)
+ }
+ }, [activeLevelUp])
useOverlayEscape({
open: shouldRender,
@@ -112,20 +135,7 @@ export function LevelUpOverlay({
className="pointer-events-none relative z-[1] flex flex-1 flex-col items-center justify-center"
style={{ gap: 12, padding: '0 32px' }}
>
-
- {t('gamification.levelUp.title')}
-
+ {t('gamification.levelUp.title')}
s.streakCelebration)
@@ -33,21 +46,16 @@ export function StreakCelebration() {
}
useEffect(() => {
+ if (!streakCelebration) return
+ requestAnimationFrame(() => setIsVisible(true))
+ dismissTimerRef.current = setTimeout(() => {
+ setIsVisible(false)
+ setStreakCelebration(null)
+ dismissTimerRef.current = setTimeout(() => setShouldRender(false), 280)
+ }, 2500)
return () => {
if (dismissTimerRef.current) clearTimeout(dismissTimerRef.current)
}
- }, [])
-
- useEffect(() => {
- if (streakCelebration) {
- requestAnimationFrame(() => setIsVisible(true))
- if (dismissTimerRef.current) clearTimeout(dismissTimerRef.current)
- dismissTimerRef.current = setTimeout(() => {
- setIsVisible(false)
- setStreakCelebration(null)
- setTimeout(() => setShouldRender(false), 280)
- }, 2500)
- }
}, [streakCelebration, setStreakCelebration])
const isMilestone = useMemo(
@@ -118,22 +126,7 @@ export function StreakCelebration() {
}
/>
-
- {streakCount}
-
+ {streakCount}
void
}
@@ -107,22 +127,7 @@ export const StreakFreezeCelebration = forwardRef
}
/>
-
- {streak}
-
+ {streak}
>(undefined)
+ const settleTimerRef = useRef>(undefined)
const checkedRef = useRef(false)
useEffect(() => {
return () => {
if (dismissTimerRef.current) clearTimeout(dismissTimerRef.current)
+ if (settleTimerRef.current) clearTimeout(settleTimerRef.current)
}
}, [])
@@ -50,25 +64,28 @@ export function WelcomeBackToast() {
const referralApplied = localStorage.getItem('orbit_referral_applied')
if (referralApplied) {
localStorage.removeItem('orbit_referral_applied')
- setTimeout(() => {
+ settleTimerRef.current = setTimeout(() => {
showToast(t('referral.applied'), 'referral')
}, 800)
- return
- }
+ } else {
+ const now = Date.now()
+ const lastVisit = Number(localStorage.getItem('orbit_last_visit') ?? '0')
+ localStorage.setItem('orbit_last_visit', String(now))
- const now = Date.now()
- const lastVisit = Number(localStorage.getItem('orbit_last_visit') ?? '0')
- localStorage.setItem('orbit_last_visit', String(now))
+ const twentyFourHours = 24 * 60 * 60 * 1000
+ if (
+ lastVisit > 0 &&
+ now - lastVisit > twentyFourHours &&
+ profile.currentStreak > 0
+ ) {
+ settleTimerRef.current = setTimeout(() => {
+ showToast(t('welcome.backMessage', { streak: profile.currentStreak }), 'welcome')
+ }, 800)
+ }
+ }
- const twentyFourHours = 24 * 60 * 60 * 1000
- if (
- lastVisit > 0 &&
- now - lastVisit > twentyFourHours &&
- profile.currentStreak > 0
- ) {
- setTimeout(() => {
- showToast(t('welcome.backMessage', { streak: profile.currentStreak }), 'welcome')
- }, 800)
+ return () => {
+ if (settleTimerRef.current) clearTimeout(settleTimerRef.current)
}
}, [profile, t])
@@ -85,19 +102,11 @@ export function WelcomeBackToast() {
aria-label={message}
className="fixed left-1/2 appearance-none border-0 cursor-pointer text-left"
style={{
- top: 56,
- maxWidth: 380,
- width: 'calc(100% - 32px)',
- padding: '14px 16px',
- background: 'var(--bg-sheet)',
- borderRadius: 18,
- boxShadow: '0 14px 36px rgba(0, 0, 0, 0.5), inset 0 0 0 1px var(--hairline)',
- transition: 'opacity 280ms var(--ease-out), transform 280ms var(--ease-out)',
+ ...toastSurfaceStyle,
opacity: isVisible ? 1 : 0,
transform: isVisible
? 'translate(-50%, 0) scale(1)'
: 'translate(-50%, -20px) scale(0.95)',
- zIndex: 10000,
}}
onClick={dismiss}
>
@@ -122,7 +131,7 @@ export function WelcomeBackToast() {
{
if (!open) {
- completeActionFiredRef.current = false
+ actionFiredRef.current = false
return
}
- if (initialAction === 'complete' && !completeActionFiredRef.current) {
- completeActionFiredRef.current = true
+ if (actionFiredRef.current || !initialAction) return
+ actionFiredRef.current = true
+ if (initialAction === 'edit') {
+ openEditModal()
+ } else if (initialAction === 'delete') {
+ openDeleteConfirm()
+ } else if (initialAction === 'progress') {
+ openProgressForm()
+ } else {
void markCompleted()
}
- }, [open, initialAction, markCompleted])
+ }, [
+ open,
+ initialAction,
+ openEditModal,
+ openDeleteConfirm,
+ openProgressForm,
+ markCompleted,
+ ])
}
diff --git a/apps/web/components/goals/goal-detail-drawer/use-goal-status-actions.ts b/apps/web/components/goals/goal-detail-drawer/use-goal-status-actions.ts
index 8b5d5a279..e82d908aa 100644
--- a/apps/web/components/goals/goal-detail-drawer/use-goal-status-actions.ts
+++ b/apps/web/components/goals/goal-detail-drawer/use-goal-status-actions.ts
@@ -31,7 +31,7 @@ export function useGoalStatusActions({
const isUpdatingStatus = updateStatus.isPending
const markCompleted = useCallback(async () => {
- if (isUpdatingStatus) return
+ if (updateStatus.isPending) return
try {
await updateStatus.mutateAsync({
goalId,
@@ -42,10 +42,10 @@ export function useGoalStatusActions({
} catch (error: unknown) {
showError(getFriendlyErrorMessage(error, translate, 'goals.errors.update', 'goal'))
}
- }, [goalId, goalName, isUpdatingStatus, refetchDetail, showError, translate, updateStatus])
+ }, [goalId, goalName, refetchDetail, showError, translate, updateStatus])
const markAbandoned = useCallback(async () => {
- if (isUpdatingStatus) return
+ if (updateStatus.isPending) return
try {
await updateStatus.mutateAsync({
goalId,
@@ -56,10 +56,10 @@ export function useGoalStatusActions({
} catch (error: unknown) {
showError(getFriendlyErrorMessage(error, translate, 'goals.errors.update', 'goal'))
}
- }, [goalId, goalName, isUpdatingStatus, refetchDetail, showError, translate, updateStatus])
+ }, [goalId, goalName, refetchDetail, showError, translate, updateStatus])
const reactivate = useCallback(async () => {
- if (isUpdatingStatus) return
+ if (updateStatus.isPending) return
try {
await updateStatus.mutateAsync({
goalId,
@@ -70,7 +70,7 @@ export function useGoalStatusActions({
} catch (error: unknown) {
showError(getFriendlyErrorMessage(error, translate, 'goals.errors.update', 'goal'))
}
- }, [goalId, goalName, isUpdatingStatus, refetchDetail, showError, translate, updateStatus])
+ }, [goalId, goalName, refetchDetail, showError, translate, updateStatus])
return { markCompleted, markAbandoned, reactivate, isUpdatingStatus }
}
diff --git a/apps/web/components/goals/goal-detail-sections.tsx b/apps/web/components/goals/goal-detail-sections.tsx
index d28bb64d1..0ac605c22 100644
--- a/apps/web/components/goals/goal-detail-sections.tsx
+++ b/apps/web/components/goals/goal-detail-sections.tsx
@@ -64,7 +64,7 @@ export function GoalProgressHistorySection({
) {
return (
@@ -13,7 +13,7 @@ export function GoalStatusBadge({ text, color }: Readonly)
className="inline-flex shrink-0 items-center rounded-full uppercase"
style={{
fontFamily: 'var(--font-sans)',
- fontSize: 10.5,
+ fontSize: 12,
fontWeight: 600,
letterSpacing: '0.06em',
padding: '3px 9px',
diff --git a/apps/web/components/insights/achievements-timeline-section.tsx b/apps/web/components/insights/achievements-timeline-section.tsx
index 98eca2d62..05f3c6d52 100644
--- a/apps/web/components/insights/achievements-timeline-section.tsx
+++ b/apps/web/components/insights/achievements-timeline-section.tsx
@@ -1,9 +1,11 @@
'use client'
-import { useTranslations, useLocale } from 'next-intl'
+import { useTranslations } from 'next-intl'
import { ChartRing } from '@/components/charts/chart-ring'
+import { useDateFormat } from '@/hooks/use-date-format'
import { useGamificationProfile } from '@/hooks/use-gamification'
-import { InsightsSection, toSectionStatus } from './insights-section'
+import { InsightsSection } from './insights-section'
+import { toSectionStatus } from './insights-section-status'
const MAX_TIMELINE_ITEMS = 8
@@ -13,18 +15,13 @@ export function AchievementsTimelineSection({
className,
}: Readonly<{ divider?: boolean; className?: string }>) {
const t = useTranslations()
- const locale = useLocale()
+ const { displayDateMedium } = useDateFormat()
const { profile, earnedAchievements, isLoading, isError, refetch } = useGamificationProfile()
const recent = [...earnedAchievements]
.sort((a, b) => (b.earnedAtUtc ?? '').localeCompare(a.earnedAtUtc ?? ''))
.slice(0, MAX_TIMELINE_ITEMS)
- const dateFormatter = new Intl.DateTimeFormat(locale, {
- month: 'short',
- day: 'numeric',
- year: 'numeric',
- })
const title = t('insights.sections.achievementsTimeline')
return (
@@ -56,9 +53,7 @@ export function AchievementsTimelineSection({
{t(`gamification.achievements.${achievement.id}.name`)}
- {achievement.earnedAtUtc
- ? dateFormatter.format(new Date(achievement.earnedAtUtc))
- : ''}
+ {displayDateMedium(achievement.earnedAtUtc)}
))}
diff --git a/apps/web/components/insights/completion-trends-section.tsx b/apps/web/components/insights/completion-trends-section.tsx
index b946d260f..2d26a0b50 100644
--- a/apps/web/components/insights/completion-trends-section.tsx
+++ b/apps/web/components/insights/completion-trends-section.tsx
@@ -3,7 +3,8 @@
import { useTranslations } from 'next-intl'
import { TrendLine } from '@/components/charts/trend-line'
import { useHabitTrends } from '@/hooks/use-habit-trends'
-import { InsightsSection, toSectionStatus } from './insights-section'
+import { InsightsSection } from './insights-section'
+import { toSectionStatus } from './insights-section-status'
import { InsightsHeadline, useDateLabel } from './insights-headline'
import type { DateRange } from './range'
diff --git a/apps/web/components/insights/goal-progress-section.tsx b/apps/web/components/insights/goal-progress-section.tsx
index 97c140216..8846e6eb4 100644
--- a/apps/web/components/insights/goal-progress-section.tsx
+++ b/apps/web/components/insights/goal-progress-section.tsx
@@ -6,7 +6,8 @@ import { TrendLine } from '@/components/charts/trend-line'
import { AppSelect } from '@/components/ui/app-select'
import { useGoals } from '@/hooks/use-goals'
import { useGoalProgressHistory } from '@/hooks/use-goal-progress-history'
-import { InsightsSection, toSectionStatus, type SectionStatus } from './insights-section'
+import { InsightsSection } from './insights-section'
+import { toSectionStatus, type SectionStatus } from './insights-section-status'
import { useDateLabel } from './insights-headline'
import type { DateRange } from './range'
diff --git a/apps/web/components/insights/insights-headline-model.ts b/apps/web/components/insights/insights-headline-model.ts
new file mode 100644
index 000000000..7d5f0298f
--- /dev/null
+++ b/apps/web/components/insights/insights-headline-model.ts
@@ -0,0 +1,32 @@
+export interface InsightsHeadlineProps {
+ /** The latest value, already formatted with its unit. */
+ value: string
+ /** The change versus the start of the range, already formatted (absolute). */
+ delta?: string
+ direction?: 'up' | 'down'
+ /** Optional qualifier rendered as a meta line under the value (e.g. "avg"). */
+ caption?: string
+}
+
+/**
+ * Derives the latest value and the change versus the first point of a series,
+ * each pre-formatted, for an {@link InsightsHeadline}. Returns null for an empty
+ * series and omits the delta when it is flat or there is a single point.
+ */
+export function trendHeadline(
+ points: ReadonlyArray<{ value: number }>,
+ formatValue: (value: number) => string,
+): InsightsHeadlineProps | null {
+ const first = points[0]
+ const last = points.at(-1)
+ if (!first || !last) return null
+ const diff = last.value - first.value
+ if (points.length < 2 || diff === 0) {
+ return { value: formatValue(last.value) }
+ }
+ return {
+ value: formatValue(last.value),
+ delta: formatValue(Math.abs(diff)),
+ direction: diff > 0 ? 'up' : 'down',
+ }
+}
diff --git a/apps/web/components/insights/insights-headline.tsx b/apps/web/components/insights/insights-headline.tsx
index 15826de05..cca424946 100644
--- a/apps/web/components/insights/insights-headline.tsx
+++ b/apps/web/components/insights/insights-headline.tsx
@@ -4,16 +4,7 @@ import { useMemo } from 'react'
import { useLocale, useTranslations } from 'next-intl'
import { TrendingDown, TrendingUp } from 'lucide-react'
import { parseAPIDate } from '@orbit/shared/utils'
-
-interface InsightsHeadlineProps {
- /** The latest value, already formatted with its unit. */
- value: string
- /** The change versus the start of the range, already formatted (absolute). */
- delta?: string
- direction?: 'up' | 'down'
- /** Optional qualifier rendered as a meta line under the value (e.g. "avg"). */
- caption?: string
-}
+import type { InsightsHeadlineProps } from './insights-headline-model'
/**
* Compact "where you are now" stat for a trend section header: the latest value
@@ -54,29 +45,6 @@ export function InsightsHeadline({
)
}
-/**
- * Derives the latest value and the change versus the first point of a series,
- * each pre-formatted, for an {@link InsightsHeadline}. Returns null for an empty
- * series and omits the delta when it is flat or there is a single point.
- */
-export function trendHeadline(
- points: ReadonlyArray<{ value: number }>,
- formatValue: (value: number) => string,
-): InsightsHeadlineProps | null {
- const first = points[0]
- const last = points.at(-1)
- if (!first || !last) return null
- const diff = last.value - first.value
- if (points.length < 2 || diff === 0) {
- return { value: formatValue(last.value) }
- }
- return {
- value: formatValue(last.value),
- delta: formatValue(Math.abs(diff)),
- direction: diff > 0 ? 'up' : 'down',
- }
-}
-
/** Stable short month-day formatter for ISO API dates in the active locale. */
export function useDateLabel(): (isoDate: string) => string {
const locale = useLocale()
diff --git a/apps/web/components/insights/insights-section-status.ts b/apps/web/components/insights/insights-section-status.ts
new file mode 100644
index 000000000..0ad24164b
--- /dev/null
+++ b/apps/web/components/insights/insights-section-status.ts
@@ -0,0 +1,16 @@
+export type SectionStatus = 'loading' | 'error' | 'empty' | 'ready'
+
+/**
+ * Collapses react-query-style flags into a single render state, in priority
+ * order: loading, then error, then empty, then ready.
+ */
+export function toSectionStatus(flags: {
+ isLoading: boolean
+ isError: boolean
+ isEmpty: boolean
+}): SectionStatus {
+ if (flags.isLoading) return 'loading'
+ if (flags.isError) return 'error'
+ if (flags.isEmpty) return 'empty'
+ return 'ready'
+}
diff --git a/apps/web/components/insights/insights-section.tsx b/apps/web/components/insights/insights-section.tsx
index aec4af0b1..a42b02038 100644
--- a/apps/web/components/insights/insights-section.tsx
+++ b/apps/web/components/insights/insights-section.tsx
@@ -2,23 +2,7 @@
import type { ReactNode } from 'react'
import { useTranslations } from 'next-intl'
-
-export type SectionStatus = 'loading' | 'error' | 'empty' | 'ready'
-
-/**
- * Collapses react-query-style flags into a single render state, in priority
- * order: loading, then error, then empty, then ready.
- */
-export function toSectionStatus(flags: {
- isLoading: boolean
- isError: boolean
- isEmpty: boolean
-}): SectionStatus {
- if (flags.isLoading) return 'loading'
- if (flags.isError) return 'error'
- if (flags.isEmpty) return 'empty'
- return 'ready'
-}
+import type { SectionStatus } from './insights-section-status'
interface InsightsSectionProps {
title: string
diff --git a/apps/web/components/insights/monthly-heatmap-section.tsx b/apps/web/components/insights/monthly-heatmap-section.tsx
index 94b2a54ee..ab45c75f0 100644
--- a/apps/web/components/insights/monthly-heatmap-section.tsx
+++ b/apps/web/components/insights/monthly-heatmap-section.tsx
@@ -7,7 +7,8 @@ import { formatAPIDate, parseAPIDate } from '@orbit/shared/utils'
import { MultiMonthHeatmap } from '@/components/charts/multi-month-heatmap'
import { useCalendarRangeChunked } from '@/hooks/use-calendar-data'
import { useProfile } from '@/hooks/use-profile'
-import { InsightsSection, toSectionStatus } from './insights-section'
+import { InsightsSection } from './insights-section'
+import { toSectionStatus } from './insights-section-status'
import { useDateLabel } from './insights-headline'
import type { DateRange } from './range'
diff --git a/apps/web/components/insights/multi-habit-comparison-section.tsx b/apps/web/components/insights/multi-habit-comparison-section.tsx
index a76470b58..7016d7b3f 100644
--- a/apps/web/components/insights/multi-habit-comparison-section.tsx
+++ b/apps/web/components/insights/multi-habit-comparison-section.tsx
@@ -3,7 +3,8 @@
import { useTranslations } from 'next-intl'
import { BarChart } from '@/components/charts/bar-chart'
import { useHabits } from '@/hooks/use-habits'
-import { InsightsSection, toSectionStatus } from './insights-section'
+import { InsightsSection } from './insights-section'
+import { toSectionStatus } from './insights-section-status'
const MAX_BARS = 8
diff --git a/apps/web/components/insights/streak-history-section.tsx b/apps/web/components/insights/streak-history-section.tsx
index 3699e0664..d03513114 100644
--- a/apps/web/components/insights/streak-history-section.tsx
+++ b/apps/web/components/insights/streak-history-section.tsx
@@ -4,8 +4,10 @@ import { useTranslations } from 'next-intl'
import { TrendLine } from '@/components/charts/trend-line'
import { plural } from '@/lib/plural'
import { useStreakHistory } from '@/hooks/use-streak-history'
-import { InsightsSection, toSectionStatus } from './insights-section'
-import { InsightsHeadline, trendHeadline, useDateLabel } from './insights-headline'
+import { InsightsSection } from './insights-section'
+import { toSectionStatus } from './insights-section-status'
+import { InsightsHeadline, useDateLabel } from './insights-headline'
+import { trendHeadline } from './insights-headline-model'
import type { DateRange } from './range'
interface StreakHistorySectionProps {
diff --git a/apps/web/components/insights/xp-over-time-section.tsx b/apps/web/components/insights/xp-over-time-section.tsx
index 45bf6d9d5..030669ac8 100644
--- a/apps/web/components/insights/xp-over-time-section.tsx
+++ b/apps/web/components/insights/xp-over-time-section.tsx
@@ -3,8 +3,10 @@
import { useTranslations, useLocale } from 'next-intl'
import { TrendLine } from '@/components/charts/trend-line'
import { useXpHistory } from '@/hooks/use-xp-history'
-import { InsightsSection, toSectionStatus } from './insights-section'
-import { InsightsHeadline, trendHeadline, useDateLabel } from './insights-headline'
+import { InsightsSection } from './insights-section'
+import { toSectionStatus } from './insights-section-status'
+import { InsightsHeadline, useDateLabel } from './insights-headline'
+import { trendHeadline } from './insights-headline-model'
import type { DateRange } from './range'
interface XpOverTimeSectionProps {
diff --git a/apps/web/components/marketing-consent/marketing-consent-prompt.tsx b/apps/web/components/marketing-consent/marketing-consent-prompt.tsx
index 0b4bf6c74..a31170bee 100644
--- a/apps/web/components/marketing-consent/marketing-consent-prompt.tsx
+++ b/apps/web/components/marketing-consent/marketing-consent-prompt.tsx
@@ -4,7 +4,7 @@ import { useEffect, useRef, useState } from 'react'
import { useTranslations } from 'next-intl'
import { useMutation } from '@tanstack/react-query'
import { Mail } from 'lucide-react'
-import { motion, useReducedMotion } from 'motion/react'
+import { domAnimation, LazyMotion, m, useReducedMotion } from 'motion/react'
import { motionDurations, motionEasings } from '@orbit/shared/theme'
import { MARKETING_CONSENT_MILESTONE_KEY } from '@orbit/shared/stores'
import { AppOverlay } from '@/components/ui/app-overlay'
@@ -34,7 +34,7 @@ function enterTransition(delayMs: number) {
export function MarketingConsentPrompt() {
const t = useTranslations()
const prefersReducedMotion = useReducedMotion()
- const { profile, patchProfile } = useProfile()
+ const { profile, patchProfile, invalidate } = useProfile()
const armedPrompt = useReferralPromptStore((s) => s.armedPrompt)
const markEngagementPrompted = useReferralPromptStore(
(s) => s.markEngagementPrompted,
@@ -57,6 +57,9 @@ export function MarketingConsentPrompt() {
onError: (_error, _enabled, context) => {
patchProfile({ marketingEmailConsent: context?.previous ?? null })
},
+ onSettled: () => {
+ invalidate()
+ },
})
useEffect(() => {
@@ -88,72 +91,74 @@ export function MarketingConsentPrompt() {
}}
title={t('marketingConsent.prompt.title')}
>
-
-
- {t('marketingConsent.prompt.eyebrow')}
-
-
-
-
-
+
- {t('marketingConsent.prompt.body')}
-
-
- answer(true)}>
- {t('marketingConsent.prompt.accept')}
-
-
-
-
+ answer(true)}>
+ {t('marketingConsent.prompt.accept')}
+
+
+
+
+
)
}
diff --git a/apps/web/components/milestone-share/milestone-share-card.tsx b/apps/web/components/milestone-share/milestone-share-card.tsx
index 8fe47bc64..27e2cf401 100644
--- a/apps/web/components/milestone-share/milestone-share-card.tsx
+++ b/apps/web/components/milestone-share/milestone-share-card.tsx
@@ -1,10 +1,23 @@
'use client'
-import { forwardRef } from 'react'
+import { forwardRef, type CSSProperties } from 'react'
import { useTranslations } from 'next-intl'
import { achievementEmoji } from '@orbit/shared/utils'
import { ShareCardQr } from '@/components/share/share-card-qr'
+const rarityBadgeStyle: CSSProperties = {
+ display: 'inline-block',
+ marginTop: 8,
+ padding: '3px 10px',
+ borderRadius: 999,
+ fontSize: 12,
+ fontWeight: 600,
+ textTransform: 'uppercase',
+ letterSpacing: '0.05em',
+ color: 'var(--primary-soft)',
+ background: 'rgba(var(--primary-rgb), 0.16)',
+}
+
export type MilestoneShareVariant =
| { kind: 'streak'; streak: number }
| { kind: 'achievement'; achievementId: string; iconKey: string; rarity: string }
@@ -81,20 +94,7 @@ export const MilestoneShareCard = forwardRef
{t(`gamification.achievements.${variant.achievementId}.name`)}
-
+
{t(`milestoneShare.rarity.${variant.rarity}`)}
diff --git a/apps/web/components/motion/route-transition-shell.tsx b/apps/web/components/motion/route-transition-shell.tsx
index ed5d6afe9..7eb3a3f86 100644
--- a/apps/web/components/motion/route-transition-shell.tsx
+++ b/apps/web/components/motion/route-transition-shell.tsx
@@ -2,7 +2,13 @@
import { useEffect, useMemo, useRef, useState, type ReactNode } from 'react'
import { usePathname } from 'next/navigation'
-import { AnimatePresence, motion, useReducedMotion } from 'motion/react'
+import {
+ AnimatePresence,
+ domMax,
+ LazyMotion,
+ m,
+ useReducedMotion,
+} from 'motion/react'
import { resolveMotionPreset } from '@orbit/shared/theme'
import {
getRouteDirectionForIntent,
@@ -62,37 +68,39 @@ export function RouteTransitionShell({
}
return (
-
-
- {children}
-
-
+
+
+
+ {children}
+
+
+
)
}
diff --git a/apps/web/components/navigation/bottom-tab-bar.tsx b/apps/web/components/navigation/bottom-tab-bar.tsx
index a7be663fc..dc3d591de 100644
--- a/apps/web/components/navigation/bottom-tab-bar.tsx
+++ b/apps/web/components/navigation/bottom-tab-bar.tsx
@@ -154,7 +154,7 @@ function TabBtn({ tab, label, active, onClick, unread = false }: Readonly
diff --git a/apps/web/components/navigation/notification-bell.tsx b/apps/web/components/navigation/notification-bell.tsx
index 4f7dc8ffa..b4a45b2a8 100644
--- a/apps/web/components/navigation/notification-bell.tsx
+++ b/apps/web/components/navigation/notification-bell.tsx
@@ -1,7 +1,7 @@
'use client'
import { useCallback, useMemo, useState, useSyncExternalStore } from 'react'
-import { AnimatePresence, motion } from 'motion/react'
+import { AnimatePresence, domAnimation, LazyMotion, m } from 'motion/react'
import { Bell, CheckCheck, Flame, Heart, Sparkles, Trash2, Trophy, UserPlus, Users, X } from 'lucide-react'
import { useTranslations } from 'next-intl'
import {
@@ -66,6 +66,96 @@ function NotificationGlyphCircle({
)
}
+function NotificationRow({
+ item,
+ onOpen,
+ onDelete,
+}: Readonly<{
+ item: NotificationItem
+ onOpen: (notification: NotificationItem) => void
+ onDelete: (notification: NotificationItem) => void
+}>) {
+ const t = useTranslations()
+ return (
+
+
+
+
+ )
+}
+
export function NotificationBell() {
const t = useTranslations()
const { notifications, isLoading, isError, refetch } = useNotifications()
@@ -263,89 +353,18 @@ export function NotificationBell() {
)}
{visibleNotifications.length > 0 && (
-
- {visibleNotifications.map((item) => (
-
-
-
-
- ))}
-
+
+
+ {visibleNotifications.map((item) => (
+
+ ))}
+
+
)}
diff --git a/apps/web/components/onboarding/onboarding-complete.tsx b/apps/web/components/onboarding/onboarding-complete.tsx
index 23032949e..9a2c5a783 100644
--- a/apps/web/components/onboarding/onboarding-complete.tsx
+++ b/apps/web/components/onboarding/onboarding-complete.tsx
@@ -1,6 +1,6 @@
'use client'
-import { useMemo } from 'react'
+import { useMemo, type CSSProperties } from 'react'
import { parseISO } from 'date-fns'
import { Check } from 'lucide-react'
import { useTranslations } from 'next-intl'
@@ -11,6 +11,29 @@ import { InfoCard } from '@/components/ui/info-card'
import { PillButton } from '@/components/ui/pill-button'
import { VerifiedBadge } from '@/components/ui/verified-badge'
+const titleStyle: CSSProperties = {
+ fontFamily: 'var(--font-display)',
+ fontSize: 34,
+ fontWeight: 700,
+ letterSpacing: '-0.01em',
+ lineHeight: 1.15,
+ color: 'var(--fg-1)',
+ margin: '6px 0 0',
+ animation: 'slide-up-fade 0.28s var(--ease-out) backwards',
+ animationDelay: '180ms',
+}
+
+const subtitleStyle: CSSProperties = {
+ fontFamily: 'var(--font-sans)',
+ fontSize: 16,
+ color: 'var(--fg-2)',
+ lineHeight: 1.5,
+ maxWidth: 280,
+ margin: 0,
+ animation: 'slide-up-fade 0.28s var(--ease-out) backwards',
+ animationDelay: '240ms',
+}
+
interface OnboardingCompleteProps {
createdHabit: string
createdGoal: boolean
@@ -35,6 +58,7 @@ export function OnboardingComplete({
const formattedTrialEnd = useMemo(() => {
if (!trialEndsAt) return ''
return displayDate(parseISO(trialEndsAt))
+ // react-doctor-disable-next-line exhaustive-deps -- trialEndsAt already aliases profile.trialEndsAt in deps; react-doctor does not resolve the alias; https://github.com/thomasluizon/orbit-ui-mobile/issues/243
}, [trialEndsAt, displayDate])
const recapItems = useMemo(() => {
@@ -74,37 +98,12 @@ export function OnboardingComplete({
-
+
{isLive
? t('onboarding.flow.complete.title')
: t('onboarding.flow.saveYourPlan.title')}
-
+
{isLive
? t('onboarding.flow.complete.subtitle')
: t('onboarding.flow.saveYourPlan.subtitle')}
diff --git a/apps/web/components/onboarding/onboarding-create-habit.tsx b/apps/web/components/onboarding/onboarding-create-habit.tsx
index 5a1fa9cef..726ad385c 100644
--- a/apps/web/components/onboarding/onboarding-create-habit.tsx
+++ b/apps/web/components/onboarding/onboarding-create-habit.tsx
@@ -1,6 +1,6 @@
'use client'
-import { useState, useCallback, useEffect } from 'react'
+import { useState, useCallback, useEffect, useEffectEvent } from 'react'
import { Loader2, Check, Settings2 } from 'lucide-react'
import { useTranslations } from 'next-intl'
import { useAppToast } from '@/hooks/use-app-toast'
@@ -45,13 +45,15 @@ export function OnboardingCreateHabit({ onCreated }: Readonly {
if (!createdInfo) return
const timer = setTimeout(() => {
- onCreated(createdInfo.id, createdInfo.title)
+ onCreatedEvent(createdInfo.id, createdInfo.title)
}, 1500)
return () => clearTimeout(timer)
- }, [createdInfo, onCreated])
+ }, [createdInfo])
const activeFrequency = frequencyUnit ?? 'one-time'
diff --git a/apps/web/components/onboarding/onboarding-flow.tsx b/apps/web/components/onboarding/onboarding-flow.tsx
index 27c27a51c..3cacadf2b 100644
--- a/apps/web/components/onboarding/onboarding-flow.tsx
+++ b/apps/web/components/onboarding/onboarding-flow.tsx
@@ -230,6 +230,7 @@ export function OnboardingFlow() {
const isStarter = sharedStep === 0 && !viewingAstra && !astraStepShown
const overlay = (
+ // react-doctor-disable-next-line prefer-html-dialog -- full-screen onboarding takeover with its own focus/escape management via overlayRef; native