Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
6a49c40
feat(web,mobile): Linear-tactical visual polish craft-lift (#117)
thomasluizon Jun 9, 2026
7aaa1df
refactor(web,mobile,shared): purge dead celebration glows + live ambi…
thomasluizon Jun 9, 2026
b993787
feat(web,mobile): completion ring-sweep when logging a habit on Today
thomasluizon Jun 9, 2026
5688733
refactor(web,mobile): single-gesture radial fill for the completion s…
thomasluizon Jun 9, 2026
cc85aef
fix(mobile): respect reduce-motion for the completion sweep
thomasluizon Jun 9, 2026
025b9ba
fix(web,mobile): 44px StatusDot hit target + delete dead hardcoded-rg…
thomasluizon Jun 9, 2026
f3d4e30
feat(web,mobile,shared): habit-drawer mono summary strip via InfoRow
thomasluizon Jun 9, 2026
05b825d
refactor(mobile): delete legacy dead HabitDetailStatsGrid
thomasluizon Jun 9, 2026
01f4034
feat(web,mobile): goal-card progress via ParentRing
thomasluizon Jun 9, 2026
6cee063
fix(web,mobile,shared): truthful calendar legend + month summary parity
thomasluizon Jun 9, 2026
09efd6d
refactor(mobile,shared): goal-metrics-panel to web hairline-row parity
thomasluizon Jun 9, 2026
de45ee9
refactor(web,mobile): consolidate ConfirmDialogV2 into the canonical …
thomasluizon Jun 9, 2026
25d8e2e
refactor(web,mobile): static chat empty-state rings + wire goals Empt…
thomasluizon Jun 9, 2026
fcab73f
feat(mobile): wire SettingsDescription + add missing settings descrip…
thomasluizon Jun 9, 2026
3669dd2
refactor(mobile): normalize Animated easing to spec ease-out-quart
thomasluizon Jun 9, 2026
7e02e64
fix(mobile): static onboarding scheme swatch to match web
thomasluizon Jun 9, 2026
56b80a4
fix(shared): goal form errors name the real missing fields, never the…
thomasluizon Jun 9, 2026
c10e5b7
fix(web,mobile,shared): goal detail drawer - Status row label + foote…
thomasluizon Jun 9, 2026
94e1cfb
feat(web,mobile): redesign the goal detail drawer footer to the drawe…
thomasluizon Jun 9, 2026
9a78ec3
fix(web,mobile): visible drawer hover states, menu-style goal actions…
thomasluizon Jun 9, 2026
d567b03
refactor(web,mobile): delete dead SkeletonCard/SkeletonAvatar exports
thomasluizon Jun 9, 2026
4045193
fix(web,mobile): design-review fixes for #117 sweep
thomasluizon Jun 9, 2026
446e677
fix(web): restore tour FAB anchor + read-only StatusDot semantics
thomasluizon Jun 9, 2026
0e8fab2
fix(web,mobile): onboarding regressions + StatusDot disabled ARIA
thomasluizon Jun 9, 2026
ca179ec
fix(web): restore three more lost tour anchors
thomasluizon Jun 9, 2026
574099c
fix(web,mobile): whitespace over hairlines around settings hint text
thomasluizon Jun 9, 2026
4ac6d12
feat(web,mobile,shared): replace the sparkle with the Orbit mark for …
thomasluizon Jun 9, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,12 @@ vi.mock('react-i18next', () => ({
}),
}))

vi.mock('expo-router', () => ({
useRouter: () => ({
push: vi.fn(),
}),
}))

vi.mock('react-native-safe-area-context', () => ({
useSafeAreaInsets: () => ({ top: 0, right: 0, bottom: 0, left: 0 }),
}))
Expand Down Expand Up @@ -97,7 +103,7 @@ vi.mock('lucide-react-native', () => {
Flame: createIcon('Flame'),
PencilLine: createIcon('PencilLine'),
RotateCw: createIcon('RotateCw'),
Sparkles: createIcon('Sparkles'),
Orbit: createIcon('Orbit'),
Trash2: createIcon('Trash2'),
}
})
Expand Down
Original file line number Diff line number Diff line change
@@ -1,18 +1,10 @@
import React from 'react'
import { describe, expect, it, vi } from 'vitest'
import { describe, expect, it } from 'vitest'

import {
HabitDetailStatsGrid,
} from '@/components/habits/habit-detail-sections'
import { HabitDetailStatsRow } from '@/components/habits/habit-detail-sections'
import { createTokensV2 } from '@/lib/theme'
const TestRenderer = require('react-test-renderer')

vi.mock('lucide-react-native', () => ({
BarChart3: () => null,
Flame: () => null,
Trophy: () => null,
}))

function findTextNodes(tree: ReturnType<typeof TestRenderer.create>, text: string) {
return tree.root.findAll((node: { props: { children?: unknown } }) => {
const props = node.props
Expand All @@ -23,40 +15,44 @@ function findTextNodes(tree: ReturnType<typeof TestRenderer.create>, text: strin
})
}

describe('habit detail sections', () => {
it('renders the stats grid', () => {
describe('HabitDetailStatsRow', () => {
it('renders current streak, longest streak, and monthly rate', () => {
let tree: ReturnType<typeof TestRenderer.create> | null = null
TestRenderer.act(() => {
tree = TestRenderer.create(
<HabitDetailStatsGrid
<HabitDetailStatsRow
metrics={{
currentStreak: 5,
longestStreak: 14,
monthlyCompletionRate: 85.5,
}}
loading={false}
t={(key, params) =>
params ? `${key}(${JSON.stringify(params)})` : key
}
t={(key) => key}
tokens={createTokensV2('purple', 'dark')}
styles={{
statsGrid: { flexDirection: 'row' },
statCard: { padding: 8 },
statLabel: { fontSize: 10 },
statValue: { fontSize: 18 },
skeletonIcon: { width: 20, height: 20 },
skeletonLabel: { width: 40, height: 10 },
skeletonValue: { width: 32, height: 20 },
sectionTitle: { fontSize: 14 },
noDataText: { fontSize: 14 },
}}
/>,
)
})

expect(tree).not.toBeNull()
expect(findTextNodes(tree!, 'habits.detail.currentStreak').length).toBeGreaterThanOrEqual(1)
expect(findTextNodes(tree!, 'habits.detail.streakDays({"n":5})').length).toBeGreaterThanOrEqual(1)
expect(findTextNodes(tree!, 'habits.detail.monthlyRate').length).toBeGreaterThanOrEqual(1)
expect(findTextNodes(tree!, '5').length).toBeGreaterThanOrEqual(1)
expect(findTextNodes(tree!, '14').length).toBeGreaterThanOrEqual(1)
expect(findTextNodes(tree!, '86%').length).toBeGreaterThanOrEqual(1)
})

it('renders the empty state when there are no metrics', () => {
let tree: ReturnType<typeof TestRenderer.create> | null = null
TestRenderer.act(() => {
tree = TestRenderer.create(
<HabitDetailStatsRow
metrics={null}
loading={false}
t={(key) => key}
tokens={createTokensV2('purple', 'dark')}
/>,
)
})

expect(findTextNodes(tree!, 'habits.detail.noDataYet').length).toBeGreaterThanOrEqual(1)
})
})
9 changes: 5 additions & 4 deletions apps/mobile/__tests__/components/habits/habit-list.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1262,7 +1262,7 @@ describe('HabitList', () => {
expect(futureNodes.length).toBeGreaterThan(0)
})

it('renders the status dot read-only for a non-loggable row and interactive for a loggable one', () => {
it('renders the status dot disabled for a non-loggable row and interactive for a loggable one', () => {
const nonLoggable = createMockHabit({
id: 'non-loggable',
title: 'Daily yoga',
Expand Down Expand Up @@ -1300,10 +1300,11 @@ describe('HabitList', () => {
expect(nonLoggableDot.props.disabled).toBe(true)
expect(loggableDot.props.disabled).toBe(false)

const nonLoggablePressables = nonLoggableDot.findAll(
(node: any) => typeof node.props?.onPress === 'function',
const nonLoggableButton = nonLoggableDot.find(
(node: any) => node.props?.accessibilityRole === 'button',
)
expect(nonLoggablePressables).toHaveLength(0)
expect(nonLoggableButton.props.disabled).toBe(true)
expect(nonLoggableButton.props.accessibilityState).toEqual({ disabled: true })
expect(onLog).not.toHaveBeenCalled()

const loggablePressables = loggableDot.findAll(
Expand Down
2 changes: 1 addition & 1 deletion apps/mobile/__tests__/components/message-bubble.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,7 @@ vi.mock('lucide-react-native', () => {

const React = require('react')
return {
Sparkles: (props: Record<string, unknown>) => React.createElement('Sparkles', props),
Orbit: (props: Record<string, unknown>) => React.createElement('Orbit', props),
User: (props: Record<string, unknown>) => React.createElement('User', props),
ArrowUpRight: (props: Record<string, unknown>) =>
React.createElement('ArrowUpRight', props),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -322,6 +322,7 @@ vi.mock('lucide-react-native', () => {
RotateCcw: createIcon('RotateCcw'),
Settings: createIcon('Settings'),
ShieldCheck: createIcon('ShieldCheck'),
Orbit: createIcon('Orbit'),
Sparkles: createIcon('Sparkles'),
Play: createIcon('Play'),
Send: createIcon('Send'),
Expand Down
6 changes: 1 addition & 5 deletions apps/mobile/__tests__/screens/profile-screen.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -98,10 +98,6 @@ vi.mock('@/lib/theme', () => ({
shadow2: {},
shadow3: {},
},
gradients: {
surfaceSheen: ['transparent', 'transparent'],
surfaceSheenLocations: [0, 0.4],
},
}))

vi.mock('@/lib/api-client', () => ({
Expand Down Expand Up @@ -204,7 +200,7 @@ vi.mock('lucide-react-native', () => {
ChevronRight: createIcon('ChevronRight'),
Clock: createIcon('Clock'),
BadgeCheck: createIcon('BadgeCheck'),
Sparkles: createIcon('Sparkles'),
Orbit: createIcon('Orbit'),
X: createIcon('X'),
Check: createIcon('Check'),
Compass: createIcon('Compass'),
Expand Down
7 changes: 5 additions & 2 deletions apps/mobile/__tests__/screens/today-screen.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -66,8 +66,6 @@ const createSurfaceLayer = () => ({
const surfacesMock = {
screen: {
backgroundColor: "#111111",
ambientStart: "#111111",
ambientEnd: "transparent",
},
ground: createSurfaceLayer(),
card: createSurfaceLayer(),
Expand Down Expand Up @@ -302,6 +300,11 @@ vi.mock("@/lib/habit-selection-state", () => ({
vi.mock("@/lib/theme", () => ({
createColors: () => colorProxy,
createTokensV2: () => colorProxy,
easings: {
spring: [0.34, 1.56, 0.64, 1],
out: [0.16, 1, 0.3, 1],
smooth: [0.2, 0, 0, 1],
},
radius: {
full: 999,
lg: 16,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -148,6 +148,7 @@ vi.mock("lucide-react-native", () => {
ChevronRight: createIcon("ChevronRight"),
Lock: createIcon("Lock"),
BarChart3: createIcon("BarChart3"),
Orbit: createIcon("Orbit"),
Sparkles: createIcon("Sparkles"),
Check: createIcon("Check"),
};
Expand Down
106 changes: 77 additions & 29 deletions apps/mobile/app/(tabs)/calendar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -59,16 +59,16 @@ interface GridDay {
}

type Tokens = ReturnType<typeof createTokensV2>;
type DayStatus = "none" | "full" | "partial" | "upcoming";
type DayStatus = "empty" | "full" | "partial" | "missed";

function dayStatus(cell: GridDay): DayStatus {
if (!cell.isCurrentMonth || cell.totalCount === 0) return "none";
if (!cell.isCurrentMonth || cell.totalCount === 0) return "empty";
if (cell.completedCount === cell.totalCount) return "full";
const hasMissed = cell.entries.some(
(entry: CalendarDayEntry) => entry.status === "missed",
);
if (hasMissed) return "partial";
return "upcoming";
if (hasMissed) return "missed";
return "partial";
}

function entryDotState(entry: CalendarDayEntry): StatusDotState {
Expand Down Expand Up @@ -223,6 +223,29 @@ export default function CalendarScreen() {
(entry: CalendarDayEntry) => entry.status === "completed",
).length;

const monthSummary = useMemo(() => {
if (isLoading || dayMap.size === 0) return null;
let daysWithActivity = 0;
let daysCompleted = 0;
dayMap.forEach((entries: CalendarDayEntry[]) => {
if (entries.length === 0) return;
daysWithActivity++;
const allDone = entries.every(
(entry: CalendarDayEntry) => entry.status === "completed",
);
if (allDone) daysCompleted++;
});
if (daysWithActivity === 0) return null;
const pct = Math.round((daysCompleted / daysWithActivity) * 100);
return `${daysCompleted}/${daysWithActivity} ${plural(t("calendar.summary.days"), daysCompleted)} (${pct}%)`;
}, [dayMap, isLoading, t]);

const goToSelectedDay = () => {
if (!selectedDay) return;
setShowDayDetail(false);
router.push(`/?date=${selectedDay}`);
};

const monthStats = useMemo(() => {
const monthDays = gridDays.filter((d) => d.isCurrentMonth);
const totalLogs = monthDays.reduce(
Expand Down Expand Up @@ -253,6 +276,7 @@ export default function CalendarScreen() {
<CalendarHeader
title={t("nav.calendar")}
monthLabel={monthLabel}
subtitle={monthSummary}
previousMonthLabel={t("common.previousMonth")}
nextMonthLabel={t("common.nextMonth")}
onPreviousMonth={prevMonth}
Expand Down Expand Up @@ -307,7 +331,7 @@ export default function CalendarScreen() {
styles.dayText,
{
color:
status === "none" && !cell.isToday
status === "empty" && !cell.isToday
? tokens.fg3
: cell.isCurrentMonth
? tokens.fg1
Expand All @@ -330,9 +354,10 @@ export default function CalendarScreen() {
</View>

<CalendarLegend
fullLabel={t("calendar.legend.done")}
partialLabel={t("calendar.legend.upcoming")}
noneLabel={t("calendar.legend.missed")}
todayLabel={t("calendar.legend.today")}
doneLabel={t("calendar.legend.done")}
partialLabel={t("calendar.legend.partial")}
missedLabel={t("calendar.legend.missed")}
tokens={tokens}
/>

Expand Down Expand Up @@ -370,6 +395,12 @@ export default function CalendarScreen() {
<Text style={[styles.emptyDayText, { color: tokens.fg3 }]}>
{t("calendar.noHabitsScheduled")}
</Text>
<GoToDayButton
label={t("calendar.goToDay")}
tokens={tokens}
styles={styles}
onPress={goToSelectedDay}
/>
</View>
) : (
<ScrollView
Expand Down Expand Up @@ -427,25 +458,12 @@ export default function CalendarScreen() {
);
})}

<Pressable
onPress={() => {
if (!selectedDay) return;
setShowDayDetail(false);
router.push(`/?date=${selectedDay}`);
}}
style={({ pressed }) => [
styles.goToDayButton,
{
backgroundColor: pressed ? tokens.primaryPressed : tokens.primary,
},
]}
accessibilityRole="button"
accessibilityLabel={t("calendar.goToDay")}
>
<Text style={[styles.goToDayButtonText, { color: tokens.fgOnPrimary }]}>
{t("calendar.goToDay")}
</Text>
</Pressable>
<GoToDayButton
label={t("calendar.goToDay")}
tokens={tokens}
styles={styles}
onPress={goToSelectedDay}
/>
</ScrollView>
)}
</BottomSheetModal>
Expand Down Expand Up @@ -486,7 +504,7 @@ function DayDot({
/>
);
}
if (status === "partial") {
if (status === "missed") {
return (
<View
style={{
Expand All @@ -498,7 +516,7 @@ function DayDot({
/>
);
}
if (status === "upcoming") {
if (status === "partial") {
return (
<View
style={{
Expand All @@ -514,6 +532,36 @@ function DayDot({
return <View style={{ width: 5, height: 5 }} />;
}

function GoToDayButton({
label,
tokens,
styles,
onPress,
}: Readonly<{
label: string;
tokens: Tokens;
styles: ReturnType<typeof createStyles>;
onPress: () => void;
}>) {
return (
<Pressable
onPress={onPress}
style={({ pressed }) => [
styles.goToDayButton,
{
backgroundColor: pressed ? tokens.primaryPressed : tokens.primary,
},
]}
accessibilityRole="button"
accessibilityLabel={label}
>
<Text style={[styles.goToDayButtonText, { color: tokens.fgOnPrimary }]}>
{label}
</Text>
</Pressable>
);
}

function createStyles(tokens: Tokens) {
return StyleSheet.create({
safeArea: { flex: 1 },
Expand Down
Loading
Loading