diff --git a/__tests__/app/buddy.test.tsx b/__tests__/app/buddy.test.tsx
new file mode 100644
index 00000000..da6db4a1
--- /dev/null
+++ b/__tests__/app/buddy.test.tsx
@@ -0,0 +1,114 @@
+/**
+ * @fileoverview Tests for the Buddy tab screen
+ *
+ * Tests the Sobers Buddy placeholder screen including:
+ * - Rendering the coming soon message
+ * - Displaying feature list
+ * - Personalized greeting with display name
+ */
+
+import React from 'react';
+import { render, screen } from '@testing-library/react-native';
+import BuddyScreen from '@/app/(app)/(tabs)/buddy';
+
+// =============================================================================
+// Mocks
+// =============================================================================
+
+const mockProfile = {
+ id: 'user-123',
+ email: 'test@example.com',
+ display_name: 'Test User',
+ sobriety_date: '2024-01-01',
+ ai_buddy_enabled: true,
+};
+
+jest.mock('@/contexts/AuthContext', () => ({
+ useAuth: () => ({
+ profile: mockProfile,
+ }),
+}));
+
+jest.mock('@/contexts/ThemeContext', () => ({
+ useTheme: () => ({
+ theme: {
+ primary: '#007AFF',
+ primaryLight: '#E5F1FF',
+ text: '#111827',
+ textSecondary: '#6b7280',
+ background: '#ffffff',
+ surface: '#ffffff',
+ card: '#ffffff',
+ border: '#e5e7eb',
+ fontRegular: 'JetBrainsMono-Regular',
+ fontMedium: 'JetBrainsMono-Medium',
+ fontSemiBold: 'JetBrainsMono-SemiBold',
+ fontBold: 'JetBrainsMono-Bold',
+ },
+ isDark: false,
+ }),
+}));
+
+jest.mock('@/hooks/useTabBarPadding', () => ({
+ useTabBarPadding: () => 0,
+}));
+
+jest.mock('@/components/navigation/SettingsButton', () => {
+ const React = require('react');
+ return {
+ __esModule: true,
+ default: () => React.createElement('View', { testID: 'settings-button' }),
+ };
+});
+
+jest.mock('lucide-react-native', () => ({
+ Bot: () => null,
+}));
+
+jest.mock('@/lib/format', () => ({
+ hexWithAlpha: (hex: string, _alpha: number) => hex,
+}));
+
+// =============================================================================
+// Test Suite
+// =============================================================================
+
+describe('BuddyScreen', () => {
+ it('renders the title', () => {
+ render();
+
+ expect(screen.getByText('Sobers Buddy')).toBeTruthy();
+ });
+
+ it('renders the subtitle', () => {
+ render();
+
+ expect(screen.getByText('Your AI-powered accountability partner')).toBeTruthy();
+ });
+
+ it('renders the coming soon card', () => {
+ render();
+
+ expect(screen.getByText('Coming Soon')).toBeTruthy();
+ });
+
+ it('includes personalized greeting with display name', () => {
+ render();
+
+ expect(screen.getByText(/Hey Test User!/)).toBeTruthy();
+ });
+
+ it('renders feature list items', () => {
+ render();
+
+ expect(screen.getByText('💬 Real-time chat conversations')).toBeTruthy();
+ expect(screen.getByText('🎯 Personalized recovery support')).toBeTruthy();
+ expect(screen.getByText('🔒 Private and secure')).toBeTruthy();
+ expect(screen.getByText('🤝 Non-judgmental, always supportive')).toBeTruthy();
+ });
+
+ it('renders without crashing', () => {
+ const { toJSON } = render();
+ expect(toJSON()).toBeTruthy();
+ });
+});
diff --git a/__tests__/app/settings.test.tsx b/__tests__/app/settings.test.tsx
index 16b0c4f4..c3c560c8 100644
--- a/__tests__/app/settings.test.tsx
+++ b/__tests__/app/settings.test.tsx
@@ -121,6 +121,7 @@ jest.mock('lucide-react-native', () => ({
RotateCcw: () => null,
Zap: () => null,
BookOpen: () => null,
+ Bot: () => null,
}));
jest.mock('@react-native-community/datetimepicker', () => {
diff --git a/__tests__/app/tabs-layout-native.test.tsx b/__tests__/app/tabs-layout-native.test.tsx
index 33a91015..dc653faa 100644
--- a/__tests__/app/tabs-layout-native.test.tsx
+++ b/__tests__/app/tabs-layout-native.test.tsx
@@ -91,7 +91,9 @@ jest.mock('expo-router/unstable-native-tabs', () => {
MockTrigger.Label = MockLabel;
MockTrigger.Icon = MockIcon;
MockTrigger.Badge = MockBadge;
- MockTrigger.VectorIcon = () => null;
+ const MockVectorIcon = () => null;
+ MockVectorIcon.displayName = 'MockVectorIcon';
+ MockTrigger.VectorIcon = MockVectorIcon;
const MockBottomAccessory = ({ children }: { children?: React.ReactNode }) =>
React.createElement(View, { testID: 'bottom-accessory' }, children);
@@ -165,6 +167,7 @@ jest.mock('lucide-react-native', () => ({
TrendingUp: () => null,
CheckSquare: () => null,
User: () => null,
+ Bot: () => null,
}));
// Store original Platform.OS
@@ -220,6 +223,7 @@ describe('TabsLayout', () => {
renderWithProviders();
expect(screen.getByTestId('trigger-index')).toBeTruthy();
+ expect(screen.getByTestId('trigger-buddy')).toBeTruthy();
expect(screen.getByTestId('trigger-program')).toBeTruthy();
expect(screen.getByTestId('trigger-journey')).toBeTruthy();
expect(screen.getByTestId('trigger-tasks')).toBeTruthy();
@@ -231,6 +235,7 @@ describe('TabsLayout', () => {
renderWithProviders();
expect(screen.getByTestId('trigger-label-Home')).toBeTruthy();
+ expect(screen.getByTestId('trigger-label-Buddy')).toBeTruthy();
expect(screen.getByTestId('trigger-label-Program')).toBeTruthy();
expect(screen.getByTestId('trigger-label-Journey')).toBeTruthy();
expect(screen.getByTestId('trigger-label-Tasks')).toBeTruthy();
@@ -242,11 +247,12 @@ describe('TabsLayout', () => {
renderWithProviders();
const icons = screen.getAllByTestId('trigger-icon');
- // 5 visible tabs have icons (manage-tasks is hidden and has no icon)
- expect(icons.length).toBe(5);
+ // 6 visible tabs have icons (manage-tasks is hidden and has no icon)
+ expect(icons.length).toBe(6);
// Verify icon data via captured triggers
expect(mockCapturedTriggers['index']).toBeDefined();
+ expect(mockCapturedTriggers['buddy']).toBeDefined();
expect(mockCapturedTriggers['program']).toBeDefined();
expect(mockCapturedTriggers['journey']).toBeDefined();
expect(mockCapturedTriggers['tasks']).toBeDefined();
@@ -283,6 +289,7 @@ describe('TabsLayout', () => {
renderWithProviders();
expect(screen.getByTestId('expo-tab-screen-index')).toBeTruthy();
+ expect(screen.getByTestId('expo-tab-screen-buddy')).toBeTruthy();
expect(screen.getByTestId('expo-tab-screen-program')).toBeTruthy();
expect(screen.getByTestId('expo-tab-screen-journey')).toBeTruthy();
expect(screen.getByTestId('expo-tab-screen-tasks')).toBeTruthy();
@@ -402,12 +409,74 @@ describe('TabsLayout', () => {
// All other tabs should not be hidden
expect(screen.getByTestId('trigger-index').props['data-hidden']).toBeFalsy();
+ expect(screen.getByTestId('trigger-buddy').props['data-hidden']).toBeFalsy();
expect(screen.getByTestId('trigger-journey').props['data-hidden']).toBeFalsy();
expect(screen.getByTestId('trigger-tasks').props['data-hidden']).toBeFalsy();
expect(screen.getByTestId('trigger-profile').props['data-hidden']).toBeFalsy();
});
});
+ describe('conditional Buddy tab visibility', () => {
+ beforeEach(() => {
+ setPlatform('ios');
+ });
+
+ it('shows Buddy tab when ai_buddy_enabled is true', () => {
+ (useAuth as jest.Mock).mockReturnValue({
+ profile: { ...mockProfile, ai_buddy_enabled: true },
+ });
+
+ renderWithProviders();
+
+ const trigger = screen.getByTestId('trigger-buddy');
+ expect(trigger.props['data-hidden']).toBeFalsy();
+ });
+
+ it('hides Buddy tab on native when ai_buddy_enabled is false', () => {
+ (useAuth as jest.Mock).mockReturnValue({
+ profile: { ...mockProfile, ai_buddy_enabled: false },
+ });
+
+ renderWithProviders();
+
+ const trigger = screen.getByTestId('trigger-buddy');
+ expect(trigger.props['data-hidden']).toBe(true);
+ });
+
+ it('shows Buddy tab when ai_buddy_enabled is undefined (default on)', () => {
+ (useAuth as jest.Mock).mockReturnValue({
+ profile: { ...mockProfile, ai_buddy_enabled: undefined },
+ });
+
+ renderWithProviders();
+
+ const trigger = screen.getByTestId('trigger-buddy');
+ expect(trigger.props['data-hidden']).toBeFalsy();
+ });
+
+ it('hides Buddy tab on web when ai_buddy_enabled is false', () => {
+ setPlatform('web');
+ (useAuth as jest.Mock).mockReturnValue({
+ profile: { ...mockProfile, ai_buddy_enabled: false },
+ });
+
+ renderWithProviders();
+
+ expect(screen.queryByTestId('expo-tab-screen-buddy')).toBeNull();
+ });
+
+ it('shows Buddy tab on web when ai_buddy_enabled is true', () => {
+ setPlatform('web');
+ (useAuth as jest.Mock).mockReturnValue({
+ profile: { ...mockProfile, ai_buddy_enabled: true },
+ });
+
+ renderWithProviders();
+
+ expect(screen.getByTestId('expo-tab-screen-buddy')).toBeTruthy();
+ });
+ });
+
describe('rendering', () => {
it('renders without errors', () => {
const { toJSON } = renderWithProviders();
diff --git a/__tests__/components/SettingsSheet.test.tsx b/__tests__/components/SettingsSheet.test.tsx
index 3d01b286..044c04aa 100644
--- a/__tests__/components/SettingsSheet.test.tsx
+++ b/__tests__/components/SettingsSheet.test.tsx
@@ -209,6 +209,7 @@ jest.mock('lucide-react-native', () => ({
RotateCcw: () => null,
Zap: () => null,
BookOpen: () => null,
+ Bot: () => null,
}));
jest.mock('@react-native-community/datetimepicker', () => {
diff --git a/__tests__/components/settings/SettingsContent.analytics.test.tsx b/__tests__/components/settings/SettingsContent.analytics.test.tsx
index 9acab166..15c32eec 100644
--- a/__tests__/components/settings/SettingsContent.analytics.test.tsx
+++ b/__tests__/components/settings/SettingsContent.analytics.test.tsx
@@ -75,6 +75,7 @@ jest.mock('lucide-react-native', () => ({
ChevronUp: () => null,
Calendar: () => null,
BookOpen: () => null,
+ Bot: () => null,
}));
jest.mock('@react-native-community/datetimepicker', () => {
diff --git a/__tests__/components/settings/SettingsContent.dev.test.tsx b/__tests__/components/settings/SettingsContent.dev.test.tsx
index 30ef798b..66910900 100644
--- a/__tests__/components/settings/SettingsContent.dev.test.tsx
+++ b/__tests__/components/settings/SettingsContent.dev.test.tsx
@@ -51,6 +51,7 @@ jest.mock('lucide-react-native', () => ({
Bell: () => null,
Calendar: () => null,
BookOpen: () => null,
+ Bot: () => null,
}));
jest.mock('@react-native-community/datetimepicker', () => {
diff --git a/__tests__/components/settings/SettingsContent.sections.test.tsx b/__tests__/components/settings/SettingsContent.sections.test.tsx
index 244eab72..80e3b531 100644
--- a/__tests__/components/settings/SettingsContent.sections.test.tsx
+++ b/__tests__/components/settings/SettingsContent.sections.test.tsx
@@ -48,6 +48,7 @@ jest.mock('lucide-react-native', () => ({
Bell: () => null,
Calendar: () => null,
BookOpen: () => null,
+ Bot: () => null,
}));
jest.mock('@react-native-community/datetimepicker', () => {
@@ -337,5 +338,25 @@ describe('SettingsContent - Section Structure', () => {
expect(screen.getByTestId('settings-show-savings-toggle')).toBeTruthy();
expect(screen.getByText('Show savings card')).toBeTruthy();
});
+
+ it('renders AI Buddy toggle in Features section', () => {
+ render();
+
+ expect(screen.getByTestId('settings-ai-buddy-toggle')).toBeTruthy();
+ expect(screen.getByText('Sobers Buddy')).toBeTruthy();
+ expect(
+ screen.getByText(/AI-powered accountability partner for your recovery journey/)
+ ).toBeTruthy();
+ });
+
+ it('displays AI Buddy toggle as ON when ai_buddy_enabled is true or undefined', () => {
+ render();
+
+ // Default profile has ai_buddy_enabled undefined, treated as true
+ const toggle = screen.getByTestId('settings-ai-buddy-toggle');
+ expect(toggle).toBeTruthy();
+
+ expect(within(toggle).getByText('ON')).toBeTruthy();
+ });
});
});
diff --git a/__tests__/types/analytics.test.ts b/__tests__/types/analytics.test.ts
index fdcf166d..111f4ab2 100644
--- a/__tests__/types/analytics.test.ts
+++ b/__tests__/types/analytics.test.ts
@@ -66,6 +66,12 @@ describe('types/analytics', () => {
// Savings events
expect(AnalyticsEvents.SAVINGS_GOAL_SET).toBe('Savings Goal Set');
expect(AnalyticsEvents.SAVINGS_UPDATED).toBe('Savings Updated');
+
+ // AI Buddy events
+ expect(AnalyticsEvents.AI_BUDDY_ENABLED).toBe('AI Buddy Enabled');
+ expect(AnalyticsEvents.AI_BUDDY_DISABLED).toBe('AI Buddy Disabled');
+ expect(AnalyticsEvents.AI_BUDDY_MESSAGE_SENT).toBe('AI Buddy Message Sent');
+ expect(AnalyticsEvents.AI_BUDDY_CONVERSATION_STARTED).toBe('AI Buddy Conversation Started');
});
it('should use Title Case naming convention', () => {
@@ -109,6 +115,7 @@ describe('types/analytics', () => {
task_streak_current: 7,
steps_completed_count: '4-6',
savings_goal_set: true,
+ ai_buddy_enabled: true,
};
expect(props).toBeDefined();
});
@@ -138,9 +145,9 @@ describe('types/analytics', () => {
});
describe('AnalyticsEvents constant integrity', () => {
- it('should have exactly 37 events as documented', () => {
+ it('should have exactly 41 events as documented', () => {
const eventCount = Object.keys(AnalyticsEvents).length;
- expect(eventCount).toBe(37);
+ expect(eventCount).toBe(41);
});
it('should not have duplicate event values', () => {
@@ -232,6 +239,13 @@ describe('AnalyticsEvents constant integrity', () => {
expect(AnalyticsEvents.SAVINGS_UPDATED).toBeDefined();
});
+ it('should have all AI Buddy events', () => {
+ expect(AnalyticsEvents.AI_BUDDY_ENABLED).toBeDefined();
+ expect(AnalyticsEvents.AI_BUDDY_DISABLED).toBeDefined();
+ expect(AnalyticsEvents.AI_BUDDY_MESSAGE_SENT).toBeDefined();
+ expect(AnalyticsEvents.AI_BUDDY_CONVERSATION_STARTED).toBeDefined();
+ });
+
it('should be a const object (frozen)', () => {
// TypeScript const assertions make the object readonly
// Attempting to modify should fail in TypeScript (compile-time check)
@@ -242,11 +256,11 @@ describe('AnalyticsEvents constant integrity', () => {
it('should have event values that work as Amplitude event names', () => {
// Amplitude recommends Title Case with spaces
- // Each word should start with capital letter
+ // Each word starts with uppercase. Allows all-caps acronyms like "AI".
const eventValues = Object.values(AnalyticsEvents);
eventValues.forEach((value) => {
- // Should contain only letters and spaces
- expect(value).toMatch(/^[A-Z][a-z]+(?: [A-Z][a-z]+)*$/);
+ // Each word is either Title Case (e.g. "Buddy") or all-caps acronym (e.g. "AI")
+ expect(value).toMatch(/^(?:[A-Z][a-z]+|[A-Z]+)(?: (?:[A-Z][a-z]+|[A-Z]+))*$/);
});
});
});
@@ -280,7 +294,7 @@ describe('Type safety', () => {
});
it('should allow all valid theme preferences', () => {
- const themes: Array<'light' | 'dark' | 'system'> = ['light', 'dark', 'system'];
+ const themes: ('light' | 'dark' | 'system')[] = ['light', 'dark', 'system'];
themes.forEach((theme) => {
const prop: UserProperties = { theme_preference: theme };
@@ -289,7 +303,7 @@ describe('Type safety', () => {
});
it('should allow all valid sign-in methods', () => {
- const methods: Array<'email' | 'google' | 'apple'> = ['email', 'google', 'apple'];
+ const methods: ('email' | 'google' | 'apple')[] = ['email', 'google', 'apple'];
methods.forEach((method) => {
const prop: UserProperties = { sign_in_method: method };
@@ -359,6 +373,10 @@ describe('Documentation and discoverability', () => {
const savingsEvents = Object.keys(AnalyticsEvents).filter((k) => k.startsWith('SAVINGS_'));
expect(savingsEvents).toHaveLength(2);
+ // AI Buddy events: 4
+ const aiBuddyEvents = Object.keys(AnalyticsEvents).filter((k) => k.startsWith('AI_BUDDY_'));
+ expect(aiBuddyEvents).toHaveLength(4);
+
// Screen viewed: 1
expect(AnalyticsEvents.SCREEN_VIEWED).toBeDefined();
});
diff --git a/app/(app)/(tabs)/_layout.tsx b/app/(app)/(tabs)/_layout.tsx
index 1adc5ffd..4d5d82a5 100644
--- a/app/(app)/(tabs)/_layout.tsx
+++ b/app/(app)/(tabs)/_layout.tsx
@@ -5,7 +5,7 @@ import React, { useMemo } from 'react';
import { Platform } from 'react-native';
import { Tabs } from 'expo-router';
import { NativeTabs } from 'expo-router/unstable-native-tabs';
-import { Home, Compass, TrendingUp, CheckSquare, User } from 'lucide-react-native';
+import { Home, Compass, TrendingUp, CheckSquare, User, Bot } from 'lucide-react-native';
import { useTheme } from '@/contexts/ThemeContext';
import { useAuth } from '@/contexts/AuthContext';
import type { SFSymbol } from 'sf-symbols-typescript';
@@ -38,6 +38,13 @@ const TAB_ROUTES: TabRoute[] = [
mdIcon: 'home',
icon: Home,
},
+ {
+ name: 'buddy',
+ title: 'Buddy',
+ sfSymbol: 'bubble.left.and.text.bubble.right.fill',
+ mdIcon: 'smart_toy',
+ icon: Bot,
+ },
{
name: 'program',
title: 'Program',
@@ -86,10 +93,18 @@ export default function TabLayout(): React.ReactElement {
// Show Program tab unless explicitly set to false (treats null/undefined as true for backwards compatibility)
const shouldShowProgram = profile?.show_program_content !== false;
+ // Determine if Buddy tab should be visible
+ // Show Buddy tab unless explicitly set to false (treats null/undefined as true for new users)
+ const shouldShowBuddy = profile?.ai_buddy_enabled !== false;
+
// Filter tab routes for web navigation items only
const visibleTabRoutes = useMemo(() => {
- return shouldShowProgram ? TAB_ROUTES : TAB_ROUTES.filter((route) => route.name !== 'program');
- }, [shouldShowProgram]);
+ return TAB_ROUTES.filter((route) => {
+ if (route.name === 'program' && !shouldShowProgram) return false;
+ if (route.name === 'buddy' && !shouldShowBuddy) return false;
+ return true;
+ });
+ }, [shouldShowProgram, shouldShowBuddy]);
// Web: Use top navigation instead of bottom tabs
if (Platform.OS === 'web') {
@@ -135,7 +150,10 @@ export default function TabLayout(): React.ReactElement {
{route.title}
diff --git a/app/(app)/(tabs)/buddy.tsx b/app/(app)/(tabs)/buddy.tsx
new file mode 100644
index 00000000..fb81cd52
--- /dev/null
+++ b/app/(app)/(tabs)/buddy.tsx
@@ -0,0 +1,125 @@
+// =============================================================================
+// Imports
+// =============================================================================
+import React from 'react';
+import { View, Text, StyleSheet, Platform } from 'react-native';
+import { useTheme, type ThemeColors } from '@/contexts/ThemeContext';
+import { useAuth } from '@/contexts/AuthContext';
+import { useTabBarPadding } from '@/hooks/useTabBarPadding';
+import SettingsButton from '@/components/navigation/SettingsButton';
+import { Bot } from 'lucide-react-native';
+import { hexWithAlpha } from '@/lib/format';
+
+// =============================================================================
+// Component
+// =============================================================================
+
+/**
+ * Buddy tab screen — placeholder for the Sobers Buddy AI companion.
+ *
+ * Displays a coming soon message while the full chat interface is being built.
+ * This screen is only visible when the user has `ai_buddy_enabled` set to true.
+ */
+export default function BuddyScreen(): React.ReactElement {
+ const { theme } = useTheme();
+ const { profile } = useAuth();
+ const tabBarPadding = useTabBarPadding();
+ const styles = createStyles(theme);
+
+ return (
+
+ {Platform.OS !== 'web' && }
+
+
+
+
+ Sobers Buddy
+ Your AI-powered accountability partner
+
+ Coming Soon
+
+ {profile?.display_name ? `Hey ${profile.display_name}! ` : ''}Sobers Buddy is an AI
+ companion designed to support your recovery journey. Chat, get encouragement, and
+ receive personalized support — all with complete privacy.
+
+
+
+ 💬 Real-time chat conversations
+ 🎯 Personalized recovery support
+ 🔒 Private and secure
+ 🤝 Non-judgmental, always supportive
+
+
+
+ );
+}
+
+// =============================================================================
+// Styles
+// =============================================================================
+
+const createStyles = (theme: ThemeColors) =>
+ StyleSheet.create({
+ container: {
+ flex: 1,
+ backgroundColor: theme.background,
+ },
+ content: {
+ flex: 1,
+ alignItems: 'center',
+ justifyContent: 'center',
+ paddingHorizontal: 24,
+ },
+ iconContainer: {
+ width: 80,
+ height: 80,
+ borderRadius: 40,
+ backgroundColor: hexWithAlpha(theme.primary, 0.1),
+ alignItems: 'center',
+ justifyContent: 'center',
+ marginBottom: 16,
+ },
+ title: {
+ fontSize: 24,
+ fontFamily: theme.fontBold,
+ color: theme.text,
+ marginBottom: 8,
+ },
+ subtitle: {
+ fontSize: 16,
+ fontFamily: theme.fontRegular,
+ color: theme.textSecondary,
+ marginBottom: 32,
+ textAlign: 'center',
+ },
+ card: {
+ backgroundColor: theme.card,
+ borderRadius: 16,
+ padding: 20,
+ width: '100%',
+ marginBottom: 24,
+ borderWidth: 1,
+ borderColor: theme.border,
+ },
+ cardTitle: {
+ fontSize: 18,
+ fontFamily: theme.fontSemiBold,
+ color: theme.primary,
+ marginBottom: 8,
+ },
+ cardText: {
+ fontSize: 14,
+ fontFamily: theme.fontRegular,
+ color: theme.textSecondary,
+ lineHeight: 22,
+ },
+ featureList: {
+ width: '100%',
+ gap: 12,
+ },
+ featureItem: {
+ fontSize: 15,
+ fontFamily: theme.fontMedium,
+ color: theme.text,
+ },
+ });
diff --git a/components/settings/SettingsContent.tsx b/components/settings/SettingsContent.tsx
index d8c4e1c5..4ab344cc 100644
--- a/components/settings/SettingsContent.tsx
+++ b/components/settings/SettingsContent.tsx
@@ -51,6 +51,7 @@ import {
Sparkles,
Calendar,
BookOpen,
+ Bot,
} from 'lucide-react-native';
import * as Clipboard from 'expo-clipboard';
import { logger, LogCategory } from '@/lib/logger';
@@ -490,6 +491,7 @@ export function SettingsContent({ onDismiss }: SettingsContentProps) {
const [isSavingName, setIsSavingName] = useState(false);
const [isSavingDashboard, setIsSavingDashboard] = useState(false);
const [isSavingTwelveStep, setIsSavingTwelveStep] = useState(false);
+ const [isSavingAiBuddy, setIsSavingAiBuddy] = useState(false);
const [showSobrietyDatePicker, setShowSobrietyDatePicker] = useState(false);
const [selectedSobrietyDate, setSelectedSobrietyDate] = useState(new Date());
const buildInfo = getBuildInfo();
@@ -766,6 +768,45 @@ export function SettingsContent({ onDismiss }: SettingsContentProps) {
}
}, [profile?.id, profile?.show_program_content, isSavingTwelveStep, refreshProfile]);
+ /**
+ * Handles toggling the AI Buddy feature.
+ * Updates profile in Supabase and refreshes profile state.
+ */
+ const handleToggleAiBuddy = useCallback(async () => {
+ if (!profile?.id || isSavingAiBuddy) return;
+
+ setIsSavingAiBuddy(true);
+ try {
+ const currentValue = profile.ai_buddy_enabled !== false;
+ const newValue = !currentValue;
+ const { error } = await supabase
+ .from('profiles')
+ .update({ ai_buddy_enabled: newValue })
+ .eq('id', profile.id);
+
+ if (error) throw error;
+
+ await refreshProfile();
+
+ // Track settings change
+ trackEvent(newValue ? AnalyticsEvents.AI_BUDDY_ENABLED : AnalyticsEvents.AI_BUDDY_DISABLED, {
+ setting: 'ai_buddy_enabled',
+ value: newValue,
+ });
+
+ showToast.success(newValue ? 'Sobers Buddy enabled' : 'Sobers Buddy disabled');
+ } catch (error) {
+ const err = error instanceof Error ? error : new Error('Failed to update setting');
+ logger.error('Failed to toggle AI Buddy', err, {
+ userId: profile.id,
+ category: LogCategory.DATABASE,
+ });
+ showToast.error('Failed to update. Please try again.');
+ } finally {
+ setIsSavingAiBuddy(false);
+ }
+ }, [profile?.id, profile?.ai_buddy_enabled, isSavingAiBuddy, refreshProfile]);
+
/**
* Opens the sobriety date picker with the current sobriety date pre-selected.
*/
@@ -1074,6 +1115,37 @@ export function SettingsContent({ onDismiss }: SettingsContentProps) {
)}
+
+
+
+
+
+ Sobers Buddy
+
+ AI-powered accountability partner for your recovery journey
+
+
+
+ {isSavingAiBuddy ? (
+
+ ) : (
+
+
+ {profile?.ai_buddy_enabled !== false ? 'ON' : 'OFF'}
+
+
+ )}
+
diff --git a/supabase/migrations/20260312000001_create_ai_buddy_tables.sql b/supabase/migrations/20260312000001_create_ai_buddy_tables.sql
new file mode 100644
index 00000000..35ac730a
--- /dev/null
+++ b/supabase/migrations/20260312000001_create_ai_buddy_tables.sql
@@ -0,0 +1,146 @@
+/*
+ # Create AI Buddy tables
+
+ Creates the database schema for the Sobers Buddy AI companion feature.
+
+ Tables:
+ - ai_buddy_conversations: Chat conversation sessions between user and AI
+ - ai_buddy_messages: Individual messages within conversations
+
+ Also adds `ai_buddy_enabled` column to profiles for feature gating.
+
+ Part of Epic #412: Sobers Buddy — AI-Powered Accountability Partner
+*/
+
+-- =============================================================================
+-- Add ai_buddy_enabled feature flag to profiles
+-- =============================================================================
+
+DO $$
+BEGIN
+ IF NOT EXISTS (
+ SELECT 1 FROM information_schema.columns
+ WHERE table_schema = 'public'
+ AND table_name = 'profiles'
+ AND column_name = 'ai_buddy_enabled'
+ ) THEN
+ ALTER TABLE public.profiles
+ ADD COLUMN ai_buddy_enabled BOOLEAN DEFAULT true;
+ END IF;
+END $$;
+
+ALTER TABLE public.profiles
+ ALTER COLUMN ai_buddy_enabled SET DEFAULT true;
+
+COMMENT ON COLUMN public.profiles.ai_buddy_enabled IS
+ 'Whether the AI Buddy feature is enabled for this user. Default true (enabled for all new users). When false, the Buddy tab is hidden from navigation.';
+
+-- =============================================================================
+-- AI Buddy Conversations
+-- =============================================================================
+
+CREATE TABLE IF NOT EXISTS public.ai_buddy_conversations (
+ id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
+ user_id uuid NOT NULL REFERENCES public.profiles(id) ON DELETE CASCADE,
+ title text,
+ created_at timestamptz DEFAULT now(),
+ updated_at timestamptz DEFAULT now()
+);
+
+COMMENT ON TABLE public.ai_buddy_conversations IS
+ 'Chat conversation sessions between a user and the Sobers Buddy AI companion.';
+
+-- Trigger for updated_at
+DROP TRIGGER IF EXISTS update_ai_buddy_conversations_updated_at ON public.ai_buddy_conversations;
+CREATE TRIGGER update_ai_buddy_conversations_updated_at
+ BEFORE UPDATE ON public.ai_buddy_conversations
+ FOR EACH ROW EXECUTE FUNCTION public.update_updated_at_column();
+
+-- =============================================================================
+-- AI Buddy Messages
+-- =============================================================================
+
+CREATE TABLE IF NOT EXISTS public.ai_buddy_messages (
+ id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
+ conversation_id uuid NOT NULL REFERENCES public.ai_buddy_conversations(id) ON DELETE CASCADE,
+ user_id uuid NOT NULL REFERENCES public.profiles(id) ON DELETE CASCADE,
+ role text NOT NULL CHECK (role IN ('user', 'assistant')),
+ content text NOT NULL,
+ created_at timestamptz DEFAULT now(),
+ updated_at timestamptz DEFAULT now()
+);
+
+COMMENT ON TABLE public.ai_buddy_messages IS
+ 'Individual messages within an AI Buddy conversation. Messages are from the user or the AI assistant.';
+
+-- Trigger for updated_at
+DROP TRIGGER IF EXISTS update_ai_buddy_messages_updated_at ON public.ai_buddy_messages;
+CREATE TRIGGER update_ai_buddy_messages_updated_at
+ BEFORE UPDATE ON public.ai_buddy_messages
+ FOR EACH ROW EXECUTE FUNCTION public.update_updated_at_column();
+
+-- =============================================================================
+-- Indexes
+-- =============================================================================
+
+CREATE INDEX IF NOT EXISTS idx_ai_buddy_conversations_user
+ ON public.ai_buddy_conversations(user_id);
+CREATE INDEX IF NOT EXISTS idx_ai_buddy_conversations_updated
+ ON public.ai_buddy_conversations(updated_at DESC);
+CREATE INDEX IF NOT EXISTS idx_ai_buddy_messages_conversation
+ ON public.ai_buddy_messages(conversation_id);
+CREATE INDEX IF NOT EXISTS idx_ai_buddy_messages_user
+ ON public.ai_buddy_messages(user_id);
+CREATE INDEX IF NOT EXISTS idx_ai_buddy_messages_created
+ ON public.ai_buddy_messages(created_at DESC);
+
+-- =============================================================================
+-- Enable RLS
+-- =============================================================================
+
+ALTER TABLE public.ai_buddy_conversations ENABLE ROW LEVEL SECURITY;
+ALTER TABLE public.ai_buddy_messages ENABLE ROW LEVEL SECURITY;
+
+-- =============================================================================
+-- RLS Policies: ai_buddy_conversations
+-- =============================================================================
+
+DROP POLICY IF EXISTS "Users can view own conversations" ON public.ai_buddy_conversations;
+CREATE POLICY "Users can view own conversations"
+ ON public.ai_buddy_conversations FOR SELECT TO authenticated
+ USING (user_id = auth.uid());
+
+DROP POLICY IF EXISTS "Users can create own conversations" ON public.ai_buddy_conversations;
+CREATE POLICY "Users can create own conversations"
+ ON public.ai_buddy_conversations FOR INSERT TO authenticated
+ WITH CHECK (user_id = auth.uid());
+
+DROP POLICY IF EXISTS "Users can update own conversations" ON public.ai_buddy_conversations;
+CREATE POLICY "Users can update own conversations"
+ ON public.ai_buddy_conversations FOR UPDATE TO authenticated
+ USING (user_id = auth.uid())
+ WITH CHECK (user_id = auth.uid());
+
+DROP POLICY IF EXISTS "Users can delete own conversations" ON public.ai_buddy_conversations;
+CREATE POLICY "Users can delete own conversations"
+ ON public.ai_buddy_conversations FOR DELETE TO authenticated
+ USING (user_id = auth.uid());
+
+-- =============================================================================
+-- RLS Policies: ai_buddy_messages
+-- =============================================================================
+
+DROP POLICY IF EXISTS "Users can view own messages" ON public.ai_buddy_messages;
+CREATE POLICY "Users can view own messages"
+ ON public.ai_buddy_messages FOR SELECT TO authenticated
+ USING (user_id = auth.uid());
+
+DROP POLICY IF EXISTS "Users can create own messages" ON public.ai_buddy_messages;
+CREATE POLICY "Users can create own messages"
+ ON public.ai_buddy_messages FOR INSERT TO authenticated
+ WITH CHECK (user_id = auth.uid());
+
+DROP POLICY IF EXISTS "Users can delete own messages" ON public.ai_buddy_messages;
+CREATE POLICY "Users can delete own messages"
+ ON public.ai_buddy_messages FOR DELETE TO authenticated
+ USING (user_id = auth.uid());
diff --git a/types/analytics.ts b/types/analytics.ts
index 04dfc6a8..80bde523 100644
--- a/types/analytics.ts
+++ b/types/analytics.ts
@@ -56,6 +56,8 @@ export interface UserProperties {
steps_completed_count?: StepsCompletedBucket;
/** Whether user has set a savings goal */
savings_goal_set?: boolean;
+ /** Whether user has AI Buddy enabled */
+ ai_buddy_enabled?: boolean;
}
/**
@@ -125,6 +127,12 @@ export const AnalyticsEvents = {
// Savings
SAVINGS_GOAL_SET: 'Savings Goal Set',
SAVINGS_UPDATED: 'Savings Updated',
+
+ // AI Buddy
+ AI_BUDDY_ENABLED: 'AI Buddy Enabled',
+ AI_BUDDY_DISABLED: 'AI Buddy Disabled',
+ AI_BUDDY_MESSAGE_SENT: 'AI Buddy Message Sent',
+ AI_BUDDY_CONVERSATION_STARTED: 'AI Buddy Conversation Started',
} as const;
/**
diff --git a/types/database.ts b/types/database.ts
index 12d221b2..105e329e 100644
--- a/types/database.ts
+++ b/types/database.ts
@@ -89,6 +89,13 @@ export interface Profile {
* Existing users (null/undefined) are treated as true.
*/
show_program_content?: boolean;
+ /**
+ * Whether the AI Buddy feature is enabled for this user.
+ * Default true (enabled for all new users). When false, the Buddy tab
+ * is hidden from navigation and AI features are disabled.
+ * Existing users (null/undefined) are treated as true.
+ */
+ ai_buddy_enabled?: boolean;
notification_preferences: {
tasks: boolean;
messages: boolean;
@@ -369,3 +376,33 @@ export interface TaskTemplate {
created_at: string;
updated_at: string;
}
+
+// =============================================================================
+// AI Buddy Types
+// =============================================================================
+
+export type AiBuddyMessageRole = 'user' | 'assistant';
+
+/**
+ * A conversation session between the user and the AI Buddy.
+ */
+export interface AiBuddyConversation {
+ id: string;
+ user_id: string;
+ title?: string;
+ created_at: string;
+ updated_at: string;
+}
+
+/**
+ * An individual message within an AI Buddy conversation.
+ */
+export interface AiBuddyMessage {
+ id: string;
+ conversation_id: string;
+ user_id: string;
+ role: AiBuddyMessageRole;
+ content: string;
+ created_at: string;
+ updated_at: string;
+}