diff --git a/backend/models/symptom.py b/backend/models/symptom.py
index 1dc54e9..d65db01 100644
--- a/backend/models/symptom.py
+++ b/backend/models/symptom.py
@@ -2,7 +2,7 @@
import uuid
-from sqlalchemy import Column, DateTime, ForeignKey, String
+from sqlalchemy import Column, DateTime, ForeignKey, String, UniqueConstraint
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import relationship
from sqlalchemy.sql import func
@@ -18,8 +18,17 @@ class Symptom(Base):
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
username = Column(String, ForeignKey("users.username", ondelete="CASCADE"), nullable=False)
name = Column(String, nullable=False)
- location = Column(String, nullable=True)
- sensation = Column(String, nullable=True)
+ location = Column(String, nullable=False)
+ sensation = Column(String, nullable=False)
created_at = Column(DateTime(timezone=True), server_default=func.now())
symptom_logs = relationship("SymptomLog", back_populates="symptom")
+
+ __table_args__ = (
+ UniqueConstraint(
+ "username",
+ "location",
+ "sensation",
+ name="uq_symptoms_username_location_sensation",
+ ),
+ )
diff --git a/backend/routers/symptom_router.py b/backend/routers/symptom_router.py
index 4e86554..fbe8550 100644
--- a/backend/routers/symptom_router.py
+++ b/backend/routers/symptom_router.py
@@ -31,7 +31,10 @@ async def create_symptom(
symptom.username = current_user.username
symptom_service = SymptomService()
- created_symptom = symptom_service.create_symptom(db, symptom)
+ try:
+ created_symptom = symptom_service.create_symptom(db, symptom)
+ except ValueError as e:
+ raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e)) from e
return created_symptom
diff --git a/backend/schemas/symptom.py b/backend/schemas/symptom.py
index 3b672e2..06555ab 100644
--- a/backend/schemas/symptom.py
+++ b/backend/schemas/symptom.py
@@ -7,8 +7,8 @@
class SymptomBase(BaseModel):
name: str = Field(..., min_length=1, max_length=255)
- location: Optional[str] = None
- sensation: Optional[str] = None
+ location: str = Field(..., min_length=1, max_length=255)
+ sensation: str = Field(..., min_length=1, max_length=255)
username: Optional[str] = None
@@ -27,8 +27,8 @@ class SymptomUpdate(BaseModel):
"""
name: Optional[str] = Field(default=None, min_length=1, max_length=255)
- location: Optional[str] = None
- sensation: Optional[str] = None
+ location: Optional[str] = Field(default=None, min_length=1, max_length=255)
+ sensation: Optional[str] = Field(default=None, min_length=1, max_length=255)
username: Optional[str] = None
diff --git a/backend/services/symptom_service.py b/backend/services/symptom_service.py
index 059f328..93c0ed7 100644
--- a/backend/services/symptom_service.py
+++ b/backend/services/symptom_service.py
@@ -5,6 +5,7 @@
from sqlalchemy.orm import Session
+from models.symptom import Symptom
from repositories.symptom_repository import SymptomRepository
from schemas.symptom import SymptomCreate, SymptomResponse
@@ -31,8 +32,23 @@ def create_symptom(self, db: Session, symptom_data: SymptomCreate) -> SymptomRes
The created symptom with generated ID and timestamps
Raises:
- ValueError: If validation fails
+ ValueError: If a symptom with the same (username, location, sensation) already exists.
"""
+ existing = (
+ db.query(Symptom)
+ .filter(
+ Symptom.username == symptom_data.username,
+ Symptom.location == symptom_data.location,
+ Symptom.sensation == symptom_data.sensation,
+ )
+ .first()
+ )
+ if existing is not None:
+ raise ValueError(
+ f"A symptom with location '{symptom_data.location}' and "
+ f"sensation '{symptom_data.sensation}' already exists."
+ )
+
created_symptom = self.symptom_repo.create_symptom(db, symptom_data)
return SymptomResponse.model_validate(created_symptom)
diff --git a/backend/tests/integration/test_symptom_service.py b/backend/tests/integration/test_symptom_service.py
index cc25c00..362df5d 100644
--- a/backend/tests/integration/test_symptom_service.py
+++ b/backend/tests/integration/test_symptom_service.py
@@ -2,6 +2,8 @@
import uuid
+import pytest
+
from schemas.symptom import SymptomCreate
from services.symptom_service import SymptomService
@@ -87,3 +89,19 @@ def test_get_symptom_fail(self, db_session):
symptom = service.get_symptom_by_id(db_session, uuid.UUID(int=0))
assert symptom is None
+
+ def test_create_symptom_duplicate_location_sensation_rejected(
+ self, db_session, authenticated_user, sample_symptom_data
+ ):
+ """Two symptoms for the same user with the same (location, sensation) must be rejected."""
+ service = SymptomService()
+ service.create_symptom(db_session, SymptomCreate(**sample_symptom_data))
+
+ duplicate = SymptomCreate(
+ name="different name but same pair",
+ location=sample_symptom_data["location"],
+ sensation=sample_symptom_data["sensation"],
+ username=sample_symptom_data["username"],
+ )
+ with pytest.raises(ValueError, match="already exists"):
+ service.create_symptom(db_session, duplicate)
diff --git a/frontend/src/assets/icon.png b/frontend/src/assets/icon.png
index a0b1526..83c1358 100644
Binary files a/frontend/src/assets/icon.png and b/frontend/src/assets/icon.png differ
diff --git a/frontend/src/components/AddSymptomModal.tsx b/frontend/src/components/AddSymptomModal.tsx
index 8c2e32a..b1e3f77 100644
--- a/frontend/src/components/AddSymptomModal.tsx
+++ b/frontend/src/components/AddSymptomModal.tsx
@@ -1,9 +1,9 @@
-import { Text } from 'react-native';
+import { Text, View } from 'react-native';
import { useState } from 'react';
-import { Dropdown } from 'react-native-element-dropdown';
import { useBankStore } from '../store/bankStore';
import { BaseAddModal } from './BaseAddModal';
import { sensationOptions, locationOptions } from '../types/symptomOptions';
+import { ComboBox } from './ComboBox';
interface Props {
visible: boolean;
@@ -43,37 +43,23 @@ export function AddSymptomModal({ visible, onClose }: Props) {
return (
Sensation
- {/* Dropdown is a third-party component that only accepts style, not className */}
- setSensation(item.value)}
- style={{ borderWidth: 1, borderColor: '#ccc', borderRadius: 8, padding: 8, marginBottom: 16 }}
- placeholderStyle={{ color: '#aaa', fontSize: 14 }}
- selectedTextStyle={{ fontSize: 14, color: '#5C2E14' }}
- itemTextStyle={{ color: '#5C2E14'}}
- />
+
+
+
Location
- {/* Dropdown is a third-party component that only accepts style, not className */}
- setLocation(item.value)}
- style={{ borderWidth: 1, borderColor: '#ccc', borderRadius: 8, padding: 8, marginBottom: 16 }}
- placeholderStyle={{ color: '#aaa', fontSize: 14 }}
- selectedTextStyle={{ fontSize: 14, color: '#5C2E14' }}
- itemTextStyle={{ color: '#5C2E14'}}
- />
+
+
+
);
}
diff --git a/frontend/src/components/ComboBox.tsx b/frontend/src/components/ComboBox.tsx
new file mode 100644
index 0000000..6d9a2f0
--- /dev/null
+++ b/frontend/src/components/ComboBox.tsx
@@ -0,0 +1,123 @@
+import { useEffect, useRef, useState } from 'react';
+import {
+ Modal,
+ Pressable,
+ ScrollView,
+ Text,
+ TextInput,
+ TouchableOpacity,
+} from 'react-native';
+
+interface ComboBoxProps {
+ value: string;
+ onSelect: (value: string) => void;
+ options: string[];
+ placeholder?: string;
+ allowCustom?: boolean;
+}
+
+export function ComboBox({
+ value,
+ onSelect,
+ options,
+ placeholder,
+ allowCustom = true,
+}: ComboBoxProps) {
+ const [open, setOpen] = useState(false);
+ const [query, setQuery] = useState('');
+ const inputRef = useRef(null);
+
+ useEffect(() => {
+ if (open) {
+ setQuery(value || '');
+ }
+ }, [open, value]);
+
+ const trimmed = query.trim();
+ const filtered = options.filter((item) =>
+ item.toLowerCase().includes(query.toLowerCase())
+ );
+ const exactMatch = options.some(
+ (item) => item.toLowerCase() === trimmed.toLowerCase()
+ );
+
+ const close = () => setOpen(false);
+
+ const handleSelect = (item: string) => {
+ onSelect(item);
+ setOpen(false);
+ };
+
+ const handleSubmit = () => {
+ if (allowCustom && trimmed) {
+ onSelect(trimmed);
+ setOpen(false);
+ }
+ };
+
+ return (
+ <>
+ setOpen(true)}
+ className="border border-remetra-border rounded-lg p-3 bg-remetra-surface"
+ activeOpacity={0.7}
+ >
+
+ {value || placeholder || 'Select...'}
+
+
+
+ inputRef.current?.focus()}
+ >
+
+ {}}
+ className="bg-white rounded-2xl w-full p-4"
+ style={{ maxHeight: '70%' }}
+ >
+
+
+ {filtered.map((item) => (
+ handleSelect(item)}
+ className="px-3 py-3 border-b border-remetra-border/40"
+ >
+ {item}
+
+ ))}
+ {allowCustom && trimmed && !exactMatch ? (
+
+ Add “{trimmed}”
+
+ ) : null}
+ {filtered.length === 0 && !(allowCustom && trimmed) ? (
+ No matches
+ ) : null}
+
+
+
+
+ >
+ );
+}
diff --git a/frontend/src/components/SymptomLogForm.tsx b/frontend/src/components/SymptomLogForm.tsx
index 23fc352..cbce130 100644
--- a/frontend/src/components/SymptomLogForm.tsx
+++ b/frontend/src/components/SymptomLogForm.tsx
@@ -5,10 +5,10 @@ import { useAuthStore } from "../store/useAuthStore";
import { useState } from "react";
import { View, Text, TouchableOpacity, TextInput } from "react-native";
import { ArrowLeft } from "lucide-react-native";
-import { Dropdown } from "react-native-element-dropdown";
import { LogDateTimePicker } from "./LogDateTimePicker";
import { sensationOptions, locationOptions } from "../types/symptomOptions";
import { useUIStore } from "../store/uiStore";
+import { ComboBox } from "./ComboBox";
interface SymptomLogFormProps {
onSubmit: (entry: SymptomLogEntry) => void;
@@ -167,37 +167,22 @@ export const SymptomLogForm: React.FC = ({ onSubmit, onBack
{/* New symptom inline form */}
{isCustom && (
- {/* Dropdown is a third-party component that only accepts style, not className */}
- { setCustomSensation(item.value); if (errors.sensation) clearError(); }}
- style={{ borderWidth: 1, borderColor: '#ccc', borderRadius: 8, padding: 8, marginBottom: 8 }}
- placeholderStyle={{ color: '#aaa', fontSize: 14 }}
- selectedTextStyle={{ fontSize: 14, color: '#5C2E14' }}
- itemTextStyle={{ color: '#5C2E14'}}
- />
+
+ { setCustomSensation(val); if (errors.sensation) clearError(); }}
+ />
+
{errors.sensation && (
{errors.sensation}
)}
- { setCustomLocation(item.value); if (errors.location) clearError(); }}
- style={{ borderWidth: 1, borderColor: '#ccc', borderRadius: 8, padding: 8 }}
- placeholderStyle={{ color: '#aaa', fontSize: 14 }}
- selectedTextStyle={{ fontSize: 14, color: '#5C2E14' }}
- itemTextStyle={{ color: '#5C2E14'}}
+ placeholder="Select a location..."
+ onSelect={(val) => { setCustomLocation(val); if (errors.location) clearError(); }}
/>
{errors.location && (
{errors.location}
diff --git a/frontend/src/screens/main/AboutScreen.tsx b/frontend/src/screens/main/AboutScreen.tsx
index 0528286..37224fb 100644
--- a/frontend/src/screens/main/AboutScreen.tsx
+++ b/frontend/src/screens/main/AboutScreen.tsx
@@ -10,7 +10,7 @@ import {
} from 'lucide-react-native';
import { BackgroundGradient } from '../../components/BackgroundGradient';
-const CONTACT_EMAIL = 'hello@remetra.app';
+const CONTACT_EMAIL = 'nicole@remetra.tech';
export function AboutScreen() {
return (
@@ -26,7 +26,7 @@ export function AboutScreen() {
REMETRA
- Lorem ipsum dolor sit amet
+ Est. 2025
@@ -34,18 +34,18 @@ export function AboutScreen() {
- Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor
- incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud
- exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat.
+ Remetra was founded by Nicole Gaudango after recognizing that many people living with autoimmune and digestive-related conditions
+ lacked a clear way to understand how their lifestyle choices impact their symptoms. She wanted to create a tool
+ that helps users gain peace or mind, feel more in control of their health, and improve their overall quality of life.
{/* How it works */}
-
-
-
+
+
+
{/* Links */}
diff --git a/frontend/src/types/symptomOptions.ts b/frontend/src/types/symptomOptions.ts
index 9ef22a0..59f813b 100644
--- a/frontend/src/types/symptomOptions.ts
+++ b/frontend/src/types/symptomOptions.ts
@@ -1,47 +1,47 @@
export const sensationOptions = [
- { label: 'Bloating', value: 'Bloating' },
- { label: 'Pain', value: 'Pain' },
- { label: 'Stiffness', value: 'Stiffness' },
- { label: 'Fatigue', value: 'Fatigue' },
- { label: 'Swelling', value: 'Swelling' },
- { label: 'Burning', value: 'Burning' },
- { label: 'Tingling', value: 'Tingling' },
- { label: 'Numbness', value: 'Numbness' },
- { label: 'Itching', value: 'Itching' },
- { label: 'Cramping', value: 'Cramping' },
- { label: 'Throbbing', value: 'Throbbing' },
- { label: 'Weakness', value: 'Weakness' },
- { label: 'Tenderness', value: 'Tenderness' },
- { label: 'Tightness', value: 'Tightness' },
- { label: 'Aching', value: 'Aching' },
- { label: 'Soreness', value: 'Soreness' },
- { label: 'Sensitivity', value: 'Sensitivity' },
- { label: 'Heaviness', value: 'Heaviness' },
- { label: 'Spasms', value: 'Spasms' },
- { label: 'Prickling', value: 'Prickling' },
- { label: 'Pressure', value: 'Pressure' },
- { label: 'Other', value: 'Other' },
+ 'Bloating',
+ 'Pain',
+ 'Stiffness',
+ 'Fatigue',
+ 'Swelling',
+ 'Burning',
+ 'Tingling',
+ 'Numbness',
+ 'Itching',
+ 'Cramping',
+ 'Throbbing',
+ 'Weakness',
+ 'Tenderness',
+ 'Tightness',
+ 'Aching',
+ 'Soreness',
+ 'Sensitivity',
+ 'Heaviness',
+ 'Spasms',
+ 'Prickling',
+ 'Pressure',
+ 'Other',
];
export const locationOptions = [
- { label: 'Whole Body', value: 'Whole Body' },
- { label: 'Head', value: 'Head' },
- { label: 'Neck', value: 'Neck' },
- { label: 'Jaw', value: 'Jaw' },
- { label: 'Chest', value: 'Chest' },
- { label: 'Upper Back', value: 'Upper Back' },
- { label: 'Lower Back', value: 'Lower Back' },
- { label: 'Abdomen', value: 'Abdomen' },
- { label: 'Shoulders', value: 'Shoulders' },
- { label: 'Elbows', value: 'Elbows' },
- { label: 'Wrists', value: 'Wrists' },
- { label: 'Hands / Fingers', value: 'Hands / Fingers' },
- { label: 'Hips', value: 'Hips' },
- { label: 'Knees', value: 'Knees' },
- { label: 'Ankles', value: 'Ankles' },
- { label: 'Feet / Toes', value: 'Feet / Toes' },
- { label: 'Eyes', value: 'Eyes' },
- { label: 'Skin', value: 'Skin' },
- { label: 'Throat', value: 'Throat' },
- { label: 'Joints (Multiple)', value: 'Joints (Multiple)' },
+ 'Whole Body',
+ 'Head',
+ 'Neck',
+ 'Jaw',
+ 'Chest',
+ 'Upper Back',
+ 'Lower Back',
+ 'Abdomen',
+ 'Shoulders',
+ 'Elbows',
+ 'Wrists',
+ 'Hands / Fingers',
+ 'Hips',
+ 'Knees',
+ 'Ankles',
+ 'Feet / Toes',
+ 'Eyes',
+ 'Skin',
+ 'Throat',
+ 'Joints (Multiple)',
];