diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml
new file mode 100644
index 0000000..f4f0a8d
--- /dev/null
+++ b/.gitea/workflows/ci.yml
@@ -0,0 +1,28 @@
+name: MetaBloom CI
+
+on:
+ push:
+ branches: [main]
+ pull_request:
+
+jobs:
+ app:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+ - uses: actions/setup-node@v4
+ with:
+ node-version: 22
+ cache: npm
+ - run: npm ci
+ - run: npm run typecheck
+ - run: npm run build:web
+ api:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+ - uses: actions/setup-python@v5
+ with:
+ python-version: '3.12'
+ - run: pip install -r server/requirements-dev.txt
+ - run: pytest -q server/tests
diff --git a/App.tsx b/App.tsx
index 9270c97..ae06b50 100644
--- a/App.tsx
+++ b/App.tsx
@@ -2,10 +2,11 @@ import AsyncStorage from '@react-native-async-storage/async-storage';
import { LinearGradient } from 'expo-linear-gradient';
import { StatusBar } from 'expo-status-bar';
import React, { useEffect, useMemo, useState } from 'react';
-import { Alert, Pressable, SafeAreaView, ScrollView, StyleSheet, Text, TextInput, View, useWindowDimensions } from 'react-native';
+import { Alert, Pressable, SafeAreaView, ScrollView, Share, StyleSheet, Switch, Text, TextInput, View, useWindowDimensions } from 'react-native';
-type Tab = 'today' | 'journal' | 'progress' | 'profile';
-type Entry = { date: string; weight: number; water: number; steps: number; glucose?: number };
+type Tab = 'today' | 'journal' | 'progress' | 'coach' | 'profile';
+type Entry = { date: string; iso?: string; weight: number; water: number; steps: number; glucose?: number; sleep?: number; meals?: number; mood?: string };
+type ProfileData = { name:string; goal:string; journey:string; glucoseUnit:'mg/dL'|'mmol/L'; reminders:boolean; complete:boolean };
const C = { ink:'#15211c', muted:'#637169', cream:'#f6f7f2', white:'#fff', green:'#1d6b50', mint:'#dcefe5', coral:'#ef835f', yellow:'#f4d58d', line:'#e5e9e3' };
const seed: Entry[] = [
{date:'Lun',weight:91.4,water:1500,steps:6100,glucose:112},{date:'Mar',weight:91.2,water:1800,steps:7400,glucose:108},
@@ -17,22 +18,27 @@ function Card({children, style}: any){return {child
function Pill({children, tone='green'}:any){return {children}}
function Metric({emoji,label,value,sub,onPress}:any){return [s.metric,pressed&&{opacity:.7}]}>{emoji}{label}{value}{sub}}
function ProgressBar({value,color=C.green}:any){return }
-function Nav({tab,setTab}:any){const items:[Tab,string,string][]=[['today','⌂','Aujourd’hui'],['journal','+','Journal'],['progress','↗','Progrès'],['profile','○','Profil']];return {items.map(([id,icon,label])=>setTab(id)} style={s.navItem}>{icon}{label})}}
+function Nav({tab,setTab}:any){const items:[Tab,string,string][]=[['today','⌂','Aujourd’hui'],['journal','+','Journal'],['progress','↗','Progrès'],['coach','✦','Coach'],['profile','○','Profil']];return {items.map(([id,icon,label])=>setTab(id)} style={s.navItem}>{icon}{label})}}
export default function App(){
const [tab,setTab]=useState('today'); const [entries,setEntries]=useState(seed); const [water,setWater]=useState(1250); const [steps,setSteps]=useState(6240);
- const [weight,setWeight]=useState('90.6'); const [glucose,setGlucose]=useState('103'); const [saved,setSaved]=useState(false); const {width}=useWindowDimensions();
- useEffect(()=>{AsyncStorage.getItem('metabloom').then(v=>{if(v){const d=JSON.parse(v);setEntries(d.entries||seed);setWater(d.water||1250);setSteps(d.steps||6240)}}).catch(()=>{})},[]);
- useEffect(()=>{AsyncStorage.setItem('metabloom',JSON.stringify({entries,water,steps})).catch(()=>{})},[entries,water,steps]);
+ const [weight,setWeight]=useState('90.6'); const [glucose,setGlucose]=useState('103'); const [sleep,setSleep]=useState('7.2'); const [meals,setMeals]=useState(2); const [mood,setMood]=useState('🙂'); const [saved,setSaved]=useState(false); const [loaded,setLoaded]=useState(false); const [profile,setProfile]=useState({name:'',goal:'Retrouver mon équilibre',journey:'Prévention',glucoseUnit:'mg/dL',reminders:false,complete:false}); const {width}=useWindowDimensions();
+ useEffect(()=>{AsyncStorage.getItem('metabloom-v2').then(v=>{if(v){const d=JSON.parse(v);setEntries(d.entries||seed);setWater(d.water||1250);setSteps(d.steps||6240);setProfile(d.profile||profile)}}).finally(()=>setLoaded(true))},[]);
+ useEffect(()=>{if(loaded)AsyncStorage.setItem('metabloom-v2',JSON.stringify({entries,water,steps,profile})).catch(()=>{})},[entries,water,steps,profile,loaded]);
const score=Math.round((Math.min(water/2000,1)+Math.min(steps/8000,1)+.82+.75)*25);
- const save=()=>{const w=parseFloat(weight.replace(',','.')); const g=parseFloat(glucose); if(!w||w<30||w>300){Alert.alert('Valeur à vérifier','Saisis un poids compris entre 30 et 300 kg.');return} setEntries(x=>[...x.slice(0,6),{date:'Auj.',weight:w,water,steps,glucose:g||undefined}]);setSaved(true);setTimeout(()=>setSaved(false),2200)};
- const content=useMemo(()=>tab==='today'?:tab==='journal'?:tab==='progress'?}/>
+function Today({name,score,water,setWater,steps,setSteps,go}:any){const date=new Intl.DateTimeFormat('fr-FR',{weekday:'long',day:'numeric',month:'long'}).format(new Date()).toUpperCase();return
+ }/>
Aujourd’huiChaque petit pas compte.Tu avances à ton rythme, sans pression. Encore une action douce pour compléter ta journée.go('journal')} style={s.heroButton}>Ajouter une donnée →{score}/ 100
Mes essentielsObjectifs ajustables
setWater((x:number)=>Math.min(x+250,2500))}/>setSteps((x:number)=>x+500)}/>
@@ -40,17 +46,21 @@ function Today({score,water,setWater,steps,setSteps,go}:any){return MetaBloom accompagne vos habitudes. Il ne remplace ni un diagnostic, ni un professionnel de santé.
}
-function Journal({weight,setWeight,glucose,setGlucose,water,setWater,steps,setSteps,save,saved}:any){return 🔒 Local}/>Note ce qui compte pour toi. Rien n’est obligatoire.
- Mesures du jourPoids facultatifkgGlycémie facultatif · saisie manuellemg/dLCes valeurs servent uniquement à ton suivi personnel. Aucune interprétation médicale n’est fournie.
+function Journal({weight,setWeight,glucose,setGlucose,glucoseUnit,water,setWater,steps,setSteps,sleep,setSleep,meals,setMeals,mood,setMood,save,saved}:any){return ☁ Prêt à synchroniser}/>Note ce qui compte pour toi. Rien n’est obligatoire.
+ Mesures du jourPoids facultatifkgGlycémie facultatif · saisie manuelle{glucoseUnit}Ces valeurs servent uniquement à ton suivi personnel. Aucune interprétation médicale n’est fournie.
HabitudessetWater(Math.max(0,water-250))} plus={()=>setWater(water+250)}/>setSteps(Math.max(0,steps-500))} plus={()=>setSteps(steps+500)}/>
+ Bien-êtreSommeilheuresRepas équilibrés{[0,1,2,3].map(n=>setMeals(n)} style={[s.segmentItem,meals===n&&s.segmentActive]}>{n})}Humeur{['😔','😐','🙂','😄'].map(x=>setMood(x)} style={[s.segmentItem,mood===x&&s.segmentActive]}>{x})}
+ REPAS📷 Scanner ou photographier un repasLa confirmation des aliments restera toujours nécessaire. L’analyse automatique arrivera avec une base nutritionnelle validée.
[s.primary,pressed&&{opacity:.8}]}>{saved?'✓ Journée enregistrée':'Enregistrer ma journée'}
}
function Stepper({icon,label,value,minus,plus}:any){return {icon}{label}{value}−+}
-function Progress({entries}:any){const min=Math.min(...entries.map((e:Entry)=>e.weight)); const max=Math.max(...entries.map((e:Entry)=>e.weight));return ↘ 0,8 kg}/>Observe les tendances, pas la perfection.Tendance de poids{entries.at(-1).weight} kgProgression douce{entries.map((e:Entry)=>{e.weight}{e.date})}
- 💧1,7 Lmoyenne d’eau👟7 436pas en moyenne
+function Progress({entries}:any){const recent=entries.slice(-7);const min=Math.min(...recent.map((e:Entry)=>e.weight)); const max=Math.max(...recent.map((e:Entry)=>e.weight));const avg=(key:'water'|'steps')=>Math.round(recent.reduce((a:number,e:Entry)=>a+e[key],0)/recent.length);const delta=(recent.at(-1).weight-recent[0].weight).toFixed(1).replace('.',',');return {Number(delta)<=0?'↘':'↗'} {Math.abs(Number(delta))} kg}/>Observe les tendances, pas la perfection.Tendance de poids{recent.at(-1).weight} kgDonnées dynamiques{recent.map((e:Entry,i:number)=>{e.weight}{e.date})}
+ 💧{(avg('water')/1000).toFixed(1).replace('.',',')} Lmoyenne d’eau👟{avg('steps').toLocaleString('fr-FR')}pas en moyenne
À RETENIRUne semaine régulière 🌱Tu as bougé au moins 6 000 pas chaque jour. Cette régularité compte davantage qu’une journée parfaite.}
-function Profile({reset}:any){return VVincentObjectif : habitudes durablesPréférences{[['🎯','Objectif principal','Équilibre métabolique'],['⚖️','Unité de poids','Kilogrammes (kg)'],['🩸','Unité glycémie','mg/dL'],['🔔','Rappels','À configurer']].map(x=>{x[0]}{x[1]}{x[2]}›)}SÉCURITÉ & CONFIDENTIALITÉVos données restent sur cet appareil.Ce prototype n’envoie aucune donnée vers un serveur. Avant un usage réel, une validation clinique, RGPD/HDS et réglementaire sera nécessaire.Alert.alert('Effacer les données locales ?','Cette action réinitialise la démonstration.',[{text:'Annuler'},{text:'Effacer',style:'destructive',onPress:reset}])} style={s.danger}>Effacer mes données localesEn cas de malaise ou d’urgence, contactez immédiatement les services d’urgence. MetaBloom ne fournit pas de conseil thérapeutique.}
+function Coach({journey}:any){const lessons=[['🥗','Composer une assiette rassasiante','4 min'],['🚶','Bouger après un repas, à ton rythme','3 min'],['🧠','Sortir du tout ou rien','5 min']];return ✨ {journey}}/>Ta semaine, sans pression.3 petites étapes pédagogiques. Aucun conseil ne modifie ton traitement.42 % du parcours hebdomadaireMicro-leçons{lessons.map((x,i)=>{x[0]}{x[1]}{x[2]} · contenu éducatif›)}COACHING DU JOURChoisis une action si petite qu’elle paraît facile.Par exemple : préparer une bouteille d’eau ou marcher cinq minutes. Tu décides selon ton énergie et les recommandations de ton soignant.CONNEXIONS SANTÉApple Santé & Health ConnectArchitecture prête pour importer pas, activité et sommeil avec permission explicite. L’activation nécessite un build natif et les comptes développeur Apple/Google.}
-const s=StyleSheet.create({safe:{flex:1,backgroundColor:C.cream},shell:{flex:1,width:'100%',alignSelf:'center'},page:{padding:20,paddingTop:26,paddingBottom:120,gap:16},header:{flexDirection:'row',justifyContent:'space-between',alignItems:'center',marginBottom:4},eyebrow:{fontSize:11,fontWeight:'800',letterSpacing:1.4,color:C.green},title:{fontSize:28,fontWeight:'800',color:C.ink,letterSpacing:-.7,marginTop:4},avatar:{width:44,height:44,borderRadius:22,backgroundColor:C.yellow,alignItems:'center',justifyContent:'center'},avatarText:{fontWeight:'800',color:C.ink,fontSize:17},hero:{borderRadius:28,padding:22,minHeight:230,flexDirection:'row',alignItems:'center',gap:14,overflow:'hidden'},heroTitle:{fontSize:27,lineHeight:32,fontWeight:'800',color:C.white,marginTop:18,maxWidth:360},heroText:{fontSize:14,lineHeight:21,color:'#dcebe4',marginTop:8,maxWidth:400},heroButton:{backgroundColor:C.white,borderRadius:14,paddingVertical:13,paddingHorizontal:16,alignSelf:'flex-start',marginTop:18},heroButtonText:{color:C.green,fontWeight:'800'},ring:{width:96,height:96,borderWidth:8,borderColor:C.yellow,borderRadius:50,alignItems:'center',justifyContent:'center',backgroundColor:'#ffffff13'},ringBig:{color:C.white,fontWeight:'900',fontSize:28},ringSmall:{color:'#dcebe4',fontSize:11},pill:{borderRadius:99,paddingHorizontal:11,paddingVertical:6,alignSelf:'flex-start'},pillText:{fontSize:11,fontWeight:'800'},sectionHead:{flexDirection:'row',justifyContent:'space-between',alignItems:'center'},h2:{fontSize:19,fontWeight:'800',color:C.ink},caption:{fontSize:12,color:C.muted},grid:{flexDirection:'row',flexWrap:'wrap',gap:12},metric:{backgroundColor:C.white,borderRadius:20,padding:17,width:'48%',minWidth:150,borderWidth:1,borderColor:C.line},metricEmoji:{fontSize:24,marginBottom:12},metricLabel:{fontSize:12,fontWeight:'700',color:C.muted},metricValue:{fontSize:22,fontWeight:'900',color:C.ink,marginTop:3},metricSub:{fontSize:11,color:C.muted,marginTop:3},card:{backgroundColor:C.white,borderRadius:22,padding:19,borderWidth:1,borderColor:C.line},tip:{flexDirection:'row',gap:14,backgroundColor:'#fffaf0'},tipIcon:{width:46,height:46,borderRadius:14,backgroundColor:C.yellow,alignItems:'center',justifyContent:'center'},tipTag:{fontSize:10,fontWeight:'900',letterSpacing:1.1,color:C.green,marginBottom:6},tipTitle:{fontSize:17,fontWeight:'800',color:C.ink,marginBottom:5},body:{fontSize:13,lineHeight:19,color:C.muted},disclaimer:{fontSize:11,lineHeight:17,textAlign:'center',color:C.muted,paddingHorizontal:16},nav:{position:'absolute',bottom:0,left:0,right:0,height:82,backgroundColor:'#fffffffa',borderTopWidth:1,borderColor:C.line,flexDirection:'row',paddingTop:9},navItem:{flex:1,alignItems:'center',justifyContent:'center',gap:3},navIcon:{fontSize:24,color:'#829087'},navLabel:{fontSize:10,fontWeight:'700',color:'#829087'},active:{color:C.green},lead:{fontSize:15,lineHeight:22,color:C.muted,marginTop:-8},inputLabel:{fontSize:13,fontWeight:'800',color:C.ink,marginTop:17,marginBottom:7},optional:{fontWeight:'500',color:C.muted},inputRow:{height:56,borderWidth:1,borderColor:C.line,borderRadius:14,flexDirection:'row',alignItems:'center',paddingHorizontal:15,backgroundColor:C.cream},input:{flex:1,fontSize:20,fontWeight:'800',color:C.ink,outlineStyle:'none'} as any,unit:{fontWeight:'700',color:C.muted},helper:{fontSize:11,lineHeight:16,color:C.muted,marginTop:12},stepper:{flexDirection:'row',alignItems:'center',gap:12,paddingVertical:13,borderBottomWidth:1,borderBottomColor:C.line},stepValue:{fontSize:17,fontWeight:'800',color:C.ink,marginTop:2},stepBtn:{width:42,height:42,borderRadius:13,backgroundColor:C.mint,alignItems:'center',justifyContent:'center'},stepBtnText:{fontSize:24,color:C.green,fontWeight:'700'},primary:{backgroundColor:C.green,borderRadius:16,padding:17,alignItems:'center'},primaryText:{color:C.white,fontSize:15,fontWeight:'900'},track:{height:8,borderRadius:4,backgroundColor:C.line,overflow:'hidden'},fill:{height:8,borderRadius:4},bigNumber:{fontSize:28,fontWeight:'900',color:C.ink,marginTop:4},bigUnit:{fontSize:14,color:C.muted},chart:{height:160,flexDirection:'row',alignItems:'flex-end',justifyContent:'space-between',marginTop:20},barCol:{flex:1,alignItems:'center'},bar:{width:22,backgroundColor:C.green,borderRadius:8,marginVertical:6},barValue:{fontSize:9,color:C.muted},barLabel:{fontSize:10,color:C.muted},stat:{width:'48%',minWidth:150},statIcon:{fontSize:22},insight:{backgroundColor:C.mint},profile:{flexDirection:'row',alignItems:'center',gap:15},avatarLarge:{width:62,height:62,borderRadius:22,backgroundColor:C.yellow,alignItems:'center',justifyContent:'center'},avatarLargeText:{fontSize:23,fontWeight:'900',color:C.ink},setting:{flexDirection:'row',alignItems:'center',gap:13,padding:15},chevron:{fontSize:28,color:C.muted},safety:{backgroundColor:C.mint},danger:{padding:16,alignItems:'center'},dangerText:{color:'#a23d33',fontWeight:'800'}});
+function Profile({profile,setProfile,entries,reset}:any){const exportData=async()=>{const csv=['date,poids,eau_ml,pas,glycemie',...entries.map((e:Entry)=>[e.iso||e.date,e.weight,e.water,e.steps,e.glucose||''].join(','))].join('\n');await Share.share({title:'Export MetaBloom',message:csv})};return {profile.name[0]?.toUpperCase()}{profile.name}{profile.goal} · {profile.journey}Préférences🩸Unité glycémie{profile.glucoseUnit}setProfile({...profile,glucoseUnit:profile.glucoseUnit==='mg/dL'?'mmol/L':'mg/dL'})} style={s.smallButton}>Changer🔔Rappels doux{profile.reminders?'Activés à 19 h':'Désactivés'}setProfile({...profile,reminders})} trackColor={{true:C.green}}/>⇧ Exporter mes données CSVSÉCURITÉ & CONFIDENTIALITÉContrôle, export et effacement.Le backend auto-hébergeable fournit authentification, consentement, isolation des comptes, export et suppression. Une validation RGPD/HDS/MDR reste obligatoire avant production.Alert.alert('Effacer les données locales ?','Cette action réinitialise l’application.',[{text:'Annuler'},{text:'Effacer',style:'destructive',onPress:reset}])} style={s.danger}>Effacer mes données localesEn cas de malaise ou d’urgence, contactez immédiatement les services d’urgence. MetaBloom ne fournit pas de conseil thérapeutique.}
+
+const s=StyleSheet.create({safe:{flex:1,backgroundColor:C.cream},shell:{flex:1,width:'100%',alignSelf:'center'},page:{padding:20,paddingTop:26,paddingBottom:120,gap:16},header:{flexDirection:'row',justifyContent:'space-between',alignItems:'center',marginBottom:4},eyebrow:{fontSize:11,fontWeight:'800',letterSpacing:1.4,color:C.green},title:{fontSize:28,fontWeight:'800',color:C.ink,letterSpacing:-.7,marginTop:4},avatar:{width:44,height:44,borderRadius:22,backgroundColor:C.yellow,alignItems:'center',justifyContent:'center'},avatarText:{fontWeight:'800',color:C.ink,fontSize:17},hero:{borderRadius:28,padding:22,minHeight:230,flexDirection:'row',alignItems:'center',gap:14,overflow:'hidden'},heroTitle:{fontSize:27,lineHeight:32,fontWeight:'800',color:C.white,marginTop:18,maxWidth:360},heroText:{fontSize:14,lineHeight:21,color:'#dcebe4',marginTop:8,maxWidth:400},heroButton:{backgroundColor:C.white,borderRadius:14,paddingVertical:13,paddingHorizontal:16,alignSelf:'flex-start',marginTop:18},heroButtonText:{color:C.green,fontWeight:'800'},ring:{width:96,height:96,borderWidth:8,borderColor:C.yellow,borderRadius:50,alignItems:'center',justifyContent:'center',backgroundColor:'#ffffff13'},ringBig:{color:C.white,fontWeight:'900',fontSize:28},ringSmall:{color:'#dcebe4',fontSize:11},pill:{borderRadius:99,paddingHorizontal:11,paddingVertical:6,alignSelf:'flex-start'},pillText:{fontSize:11,fontWeight:'800'},sectionHead:{flexDirection:'row',justifyContent:'space-between',alignItems:'center'},h2:{fontSize:19,fontWeight:'800',color:C.ink},caption:{fontSize:12,color:C.muted},grid:{flexDirection:'row',flexWrap:'wrap',gap:12},metric:{backgroundColor:C.white,borderRadius:20,padding:17,width:'48%',minWidth:150,borderWidth:1,borderColor:C.line},metricEmoji:{fontSize:24,marginBottom:12},metricLabel:{fontSize:12,fontWeight:'700',color:C.muted},metricValue:{fontSize:22,fontWeight:'900',color:C.ink,marginTop:3},metricSub:{fontSize:11,color:C.muted,marginTop:3},card:{backgroundColor:C.white,borderRadius:22,padding:19,borderWidth:1,borderColor:C.line},tip:{flexDirection:'row',gap:14,backgroundColor:'#fffaf0'},tipIcon:{width:46,height:46,borderRadius:14,backgroundColor:C.yellow,alignItems:'center',justifyContent:'center'},tipTag:{fontSize:10,fontWeight:'900',letterSpacing:1.1,color:C.green,marginBottom:6},tipTitle:{fontSize:17,fontWeight:'800',color:C.ink,marginBottom:5},body:{fontSize:13,lineHeight:19,color:C.muted},disclaimer:{fontSize:11,lineHeight:17,textAlign:'center',color:C.muted,paddingHorizontal:16},nav:{position:'absolute',bottom:0,left:0,right:0,height:82,backgroundColor:'#fffffffa',borderTopWidth:1,borderColor:C.line,flexDirection:'row',paddingTop:9},navItem:{flex:1,alignItems:'center',justifyContent:'center',gap:3},navIcon:{fontSize:22,color:'#829087'},navLabel:{fontSize:9,fontWeight:'700',color:'#829087'},active:{color:C.green},lead:{fontSize:15,lineHeight:22,color:C.muted,marginTop:-8},inputLabel:{fontSize:13,fontWeight:'800',color:C.ink,marginTop:17,marginBottom:7},optional:{fontWeight:'500',color:C.muted},inputRow:{height:56,borderWidth:1,borderColor:C.line,borderRadius:14,flexDirection:'row',alignItems:'center',paddingHorizontal:15,backgroundColor:C.cream},input:{flex:1,fontSize:18,fontWeight:'800',color:C.ink,outlineStyle:'none'} as any,unit:{fontWeight:'700',color:C.muted},helper:{fontSize:11,lineHeight:16,color:C.muted,marginTop:12},stepper:{flexDirection:'row',alignItems:'center',gap:12,paddingVertical:13,borderBottomWidth:1,borderBottomColor:C.line},stepValue:{fontSize:17,fontWeight:'800',color:C.ink,marginTop:2},stepBtn:{width:44,height:44,borderRadius:13,backgroundColor:C.mint,alignItems:'center',justifyContent:'center'},stepBtnText:{fontSize:24,color:C.green,fontWeight:'700'},primary:{backgroundColor:C.green,borderRadius:16,padding:17,alignItems:'center'},primaryText:{color:C.white,fontSize:15,fontWeight:'900'},track:{height:8,borderRadius:4,backgroundColor:'#ffffff3d',overflow:'hidden'},fill:{height:8,borderRadius:4},bigNumber:{fontSize:28,fontWeight:'900',color:C.ink,marginTop:4},bigUnit:{fontSize:14,color:C.muted},chart:{height:160,flexDirection:'row',alignItems:'flex-end',justifyContent:'space-between',marginTop:20},barCol:{flex:1,alignItems:'center'},bar:{width:22,backgroundColor:C.green,borderRadius:8,marginVertical:6},barValue:{fontSize:9,color:C.muted},barLabel:{fontSize:10,color:C.muted},stat:{width:'48%',minWidth:150},statIcon:{fontSize:22},insight:{backgroundColor:C.mint},profile:{flexDirection:'row',alignItems:'center',gap:15},avatarLarge:{width:62,height:62,borderRadius:22,backgroundColor:C.yellow,alignItems:'center',justifyContent:'center'},avatarLargeText:{fontSize:23,fontWeight:'900',color:C.ink},setting:{flexDirection:'row',alignItems:'center',gap:13,padding:15},chevron:{fontSize:28,color:C.muted},safety:{backgroundColor:C.mint},danger:{padding:16,alignItems:'center'},dangerText:{color:'#a23d33',fontWeight:'800'},onboard:{padding:24,paddingTop:54,paddingBottom:50,gap:16,maxWidth:560,width:'100%',alignSelf:'center'},logo:{width:58,height:58,borderRadius:20,backgroundColor:C.green,alignItems:'center',justifyContent:'center',marginBottom:8},logoText:{color:C.white,fontSize:28,fontWeight:'900'},onboardTitle:{fontSize:34,lineHeight:40,fontWeight:'900',color:C.ink,letterSpacing:-1},choice:{backgroundColor:C.white,borderWidth:1,borderColor:C.line,borderRadius:18,padding:15,flexDirection:'row',alignItems:'center',gap:12},choiceActive:{borderWidth:2,borderColor:C.green,backgroundColor:C.mint},choiceEmoji:{fontSize:24},check:{fontSize:18,fontWeight:'900',color:C.green},secondary:{padding:14,alignItems:'center'},secondaryText:{color:C.green,fontWeight:'800'},segment:{flexDirection:'row',gap:8},segmentItem:{height:46,flex:1,borderRadius:13,backgroundColor:C.cream,alignItems:'center',justifyContent:'center',borderWidth:1,borderColor:C.line},segmentActive:{backgroundColor:C.green,borderColor:C.green},segmentText:{fontWeight:'800',color:C.ink},scan:{backgroundColor:'#fff8ed',borderStyle:'dashed',borderColor:'#d8a960'},coachHero:{borderRadius:26,padding:22,gap:14},coachProgress:{fontSize:12,fontWeight:'700',color:'#eee8f7'},lesson:{flexDirection:'row',alignItems:'center',gap:12},lessonIcon:{width:48,height:48,borderRadius:15,backgroundColor:C.cream,alignItems:'center',justifyContent:'center'},smallButton:{paddingHorizontal:12,paddingVertical:9,borderRadius:11,backgroundColor:C.mint},smallButtonText:{color:C.green,fontWeight:'800',fontSize:12},secondaryCard:{backgroundColor:C.white,borderRadius:16,borderWidth:1,borderColor:C.green,padding:16,alignItems:'center'},secondaryCardText:{color:C.green,fontWeight:'900'}});
diff --git a/README.md b/README.md
index 8099d04..dc19c42 100644
--- a/README.md
+++ b/README.md
@@ -1,33 +1,38 @@
# MetaBloom
-Compagnon métabolique moderne, francophone et sans culpabilisation pour aider les personnes concernées par le surpoids, le prédiabète ou le diabète de type 2 à suivre leurs habitudes quotidiennes.
+MetaBloom est un compagnon métabolique mobile-first, francophone et non culpabilisant pour les personnes concernées par le surpoids, le prédiabète ou le diabète de type 2. Une seule base Expo cible iOS, Android et le Web.
-> **Statut : prototype éducatif et bien-être.** MetaBloom ne diagnostique aucune maladie, ne calcule aucun traitement et ne remplace pas un professionnel de santé. En cas d’urgence, contactez les services d’urgence.
+> **Statut : produit en développement, usage éducatif et bien-être uniquement.** MetaBloom ne diagnostique pas, n’interprète pas une mesure, ne calcule pas de dose et ne modifie jamais un traitement. En cas d’urgence, contactez les services d’urgence.
-## Le produit
+## Fonctionnalités disponibles
-Une seule application mobile-first pour iOS, Android et le web :
+- onboarding avec objectif, parcours et consentement informatif ;
+- parcours Prévention, Perte de poids, Diabète type 2 et GLP-1 ;
+- dashboard quotidien responsive avec données réellement dynamiques ;
+- journal enrichi : poids, glycémie facultative, eau, pas, sommeil, repas et humeur ;
+- validation des valeurs et gestion mg/dL/mmol/L ;
+- graphiques et moyennes calculés sur les sept derniers jours ;
+- coaching comportemental et micro-leçons sans recommandations thérapeutiques ;
+- préférences, rappels préparés, export CSV et effacement local ;
+- persistance hors ligne via AsyncStorage ;
+- backend FastAPI/PostgreSQL auto-hébergeable avec JWT, consentement, CRUD, export et suppression de compte ;
+- préparation UI pour scan de repas, Apple Santé et Health Connect.
-- tableau de bord quotidien avec score souple et petites actions ;
-- journal rapide : poids, eau, pas et glycémie manuelle facultative ;
-- tendances sur 7 jours et encouragements non culpabilisants ;
-- préférences d’unités et informations de sécurité ;
-- persistance locale sur l’appareil, sans compte ni serveur pour ce MVP ;
-- interface responsive, accessible et utilisable au clavier.
+## Captures
-## Pourquoi MetaBloom ?
-
-Le marché combine trois familles : suivi nutritionnel (MyFitnessPal), coaching comportemental (Noom/WW) et journal du diabète (mySugr). MetaBloom se différencie comme **compagnon métabolique inclusif et francophone**, simple, calme, centré sur la prévention et la confidentialité. Il évite les objectifs extrêmes et la gamification punitive.
+Les captures mobiles sont disponibles dans [`screenshots/`](./screenshots/).
## Stack
-- Expo SDK 57
-- React Native 0.86 + React 19
-- TypeScript
-- AsyncStorage pour les données locales
-- Expo Linear Gradient
+| Couche | Technologie |
+|---|---|
+| Application | Expo SDK 57, React Native 0.86, React 19, TypeScript |
+| Stockage local | AsyncStorage |
+| API | FastAPI, SQLAlchemy, Pydantic |
+| Données serveur | PostgreSQL en production, SQLite en développement |
+| Sécurité API | JWT expirant, Argon2, isolation par utilisateur |
-## Démarrer
+## Démarrer l’application
Prérequis : Node.js 20+ et npm.
@@ -36,41 +41,68 @@ npm install
npm run web
```
-Autres cibles :
-
```bash
-npm run android # appareil/émulateur Android ou Expo Go
-npm run ios # macOS + simulateur iOS, ou Expo Go
+npm run android
+npm run ios
npm run typecheck
npm run build:web
```
+Les cibles Android/iOS fonctionnent avec Expo Go pour ce prototype. HealthKit/Health Connect nécessiteront un development build natif et des comptes développeur Apple/Google.
+
+## Démarrer l’API
+
+```bash
+cd server
+python -m venv .venv
+. .venv/bin/activate
+pip install -r requirements-dev.txt
+uvicorn app.main:app --reload
+```
+
+Documentation OpenAPI : `http://localhost:8000/docs`. Voir [`server/README.md`](./server/README.md) pour PostgreSQL, Docker et les règles de sécurité.
+
## Architecture
```text
-App.tsx Interface, navigation et état du MVP
-index.ts Point d’entrée Expo
-app.json Configuration multiplateforme
-assets/ Icônes et splash screen
+App.tsx Expérience Expo multiplateforme
+assets/ Icônes et splash screen
+server/app/ API, modèles, validation et sécurité
+server/tests/ Tests d’intégration API
+server/docker-compose... PostgreSQL + API auto-hébergeables
+screenshots/ Captures produit
+.gitea/workflows/ Contrôles CI
```
-Le MVP reste volontairement monolithique pour accélérer la validation produit. Une V2 séparera écrans, composants, domaine et accès aux données.
+L’application fonctionne actuellement en mode local-first. L’API est prête séparément ; la prochaine étape d’intégration est une file de synchronisation idempotente avec gestion explicite des conflits.
-## Données et sécurité
+## Sécurité et réglementation
-Les données de démonstration et saisies sont stockées exclusivement via AsyncStorage sur l’appareil. Aucun backend, tracker publicitaire ou transfert de données n’est inclus.
+- aucune donnée n’est envoyée par l’application tant que l’API n’est pas configurée ;
+- l’API impose un consentement avant l’enregistrement de données de santé ;
+- les mots de passe sont hachés avec Argon2 et les données isolées par utilisateur ;
+- les secrets d’exemple ne doivent jamais être utilisés en production ;
+- AsyncStorage n’est pas chiffré : il doit être remplacé par SQLite + SecureStore avant production ;
+- une AIPD/RGPD, une analyse HDS, un audit OWASP et une qualification MDR sont obligatoires avant usage réel ;
+- aucun conseil GLP-1, seuil glycémique ou traitement n’est produit par l’application.
-Avant une mise en production réelle : analyse d’impact RGPD, consentement explicite, chiffrement, gestion de l’effacement/export, évaluation HDS, audit de sécurité, validation clinique et qualification au regard du règlement européen MDR. Les fonctions de diagnostic, prédiction d’hypoglycémie, dosage d’insuline ou adaptation thérapeutique sont explicitement hors périmètre.
+## État des fonctionnalités avancées
-## Roadmap
+- **Backend/auth/export/suppression :** implémentés et isolés dans `server/`.
+- **Synchronisation multi-appareil :** architecture définie, connexion client/API restante.
+- **Notifications :** préférences fonctionnelles ; branchement natif restant.
+- **Scan alimentaire :** expérience préparée ; source nutritionnelle et confirmation utilisateur restantes.
+- **HealthKit/Health Connect :** préparation produit ; dépendances natives et autorisations stores restantes.
+- **IA :** volontairement non branchée avant gouvernance clinique et corpus validé.
-1. Tests utilisateurs et accessibilité WCAG.
-2. Authentification et synchronisation chiffrée auto-hébergeable.
-3. Export PDF/CSV partageable avec consentement.
-4. Rappels locaux et intégrations HealthKit / Health Connect.
-5. Base alimentaire francophone et scan, après validation qualité.
-6. Parcours professionnels de santé et conformité réglementaire.
+## Tests
+
+```bash
+npm run typecheck
+npm run build:web
+cd server && pytest -q
+```
## Licence
-MIT — voir `LICENSE`.
+MIT — voir [`LICENSE`](./LICENSE).
diff --git a/app.json b/app.json
index cbce8ed..43c11f3 100644
--- a/app.json
+++ b/app.json
@@ -3,14 +3,17 @@
"name": "MetaBloom",
"slug": "metabloom",
"version": "1.0.0",
+ "scheme": "metabloom",
"orientation": "portrait",
"icon": "./assets/icon.png",
"userInterfaceStyle": "light",
"description": "Des habitudes bienveillantes pour un meilleur équilibre métabolique.",
"ios": {
- "supportsTablet": true
+ "supportsTablet": true,
+ "bundleIdentifier": "agency.vicode.metabloom"
},
"android": {
+ "package": "agency.vicode.metabloom",
"adaptiveIcon": {
"backgroundColor": "#E6F4FE",
"foregroundImage": "./assets/android-icon-foreground.png",
diff --git a/screenshots/01-aujourdhui.png b/screenshots/01-aujourdhui.png
deleted file mode 100644
index 70db850..0000000
Binary files a/screenshots/01-aujourdhui.png and /dev/null differ
diff --git a/screenshots/01-onboarding.png b/screenshots/01-onboarding.png
new file mode 100644
index 0000000..0cadb6d
Binary files /dev/null and b/screenshots/01-onboarding.png differ
diff --git a/screenshots/02-journal.png b/screenshots/02-journal.png
deleted file mode 100644
index a8ff245..0000000
Binary files a/screenshots/02-journal.png and /dev/null differ
diff --git a/screenshots/02-parcours.png b/screenshots/02-parcours.png
new file mode 100644
index 0000000..49cd3af
Binary files /dev/null and b/screenshots/02-parcours.png differ
diff --git a/screenshots/03-aujourdhui.png b/screenshots/03-aujourdhui.png
new file mode 100644
index 0000000..447435e
Binary files /dev/null and b/screenshots/03-aujourdhui.png differ
diff --git a/screenshots/03-progres.png b/screenshots/03-progres.png
deleted file mode 100644
index e80fa62..0000000
Binary files a/screenshots/03-progres.png and /dev/null differ
diff --git a/screenshots/04-journal.png b/screenshots/04-journal.png
new file mode 100644
index 0000000..a6b4143
Binary files /dev/null and b/screenshots/04-journal.png differ
diff --git a/screenshots/04-profil.png b/screenshots/04-profil.png
deleted file mode 100644
index c14837b..0000000
Binary files a/screenshots/04-profil.png and /dev/null differ
diff --git a/screenshots/05-progres.png b/screenshots/05-progres.png
new file mode 100644
index 0000000..a1d68a7
Binary files /dev/null and b/screenshots/05-progres.png differ
diff --git a/screenshots/06-coach.png b/screenshots/06-coach.png
new file mode 100644
index 0000000..6246d4b
Binary files /dev/null and b/screenshots/06-coach.png differ
diff --git a/screenshots/07-profil.png b/screenshots/07-profil.png
new file mode 100644
index 0000000..1f73405
Binary files /dev/null and b/screenshots/07-profil.png differ
diff --git a/server/.dockerignore b/server/.dockerignore
new file mode 100644
index 0000000..83de0df
--- /dev/null
+++ b/server/.dockerignore
@@ -0,0 +1,7 @@
+.env
+.venv
+__pycache__
+.pytest_cache
+tests
+*.db
+
diff --git a/server/.env.example b/server/.env.example
new file mode 100644
index 0000000..8311a39
--- /dev/null
+++ b/server/.env.example
@@ -0,0 +1,6 @@
+DATABASE_URL=postgresql+psycopg://metabloom:change-me@localhost:5432/metabloom
+JWT_SECRET=generate-a-random-secret-of-at-least-32-characters
+ENVIRONMENT=production
+ALLOWED_ORIGINS=https://app.example.com
+POSTGRES_PASSWORD=change-me
+
diff --git a/server/Dockerfile b/server/Dockerfile
new file mode 100644
index 0000000..a96ce9e
--- /dev/null
+++ b/server/Dockerfile
@@ -0,0 +1,10 @@
+FROM python:3.12-slim
+WORKDIR /app
+COPY requirements.txt .
+RUN pip install --no-cache-dir -r requirements.txt
+COPY app ./app
+RUN useradd --create-home appuser
+USER appuser
+EXPOSE 8000
+CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
+
diff --git a/server/README.md b/server/README.md
new file mode 100644
index 0000000..872ec28
--- /dev/null
+++ b/server/README.md
@@ -0,0 +1,51 @@
+# MetaBloom API
+
+Backend FastAPI isolé pour synchroniser les profils et journaux MetaBloom. Il fournit JWT, Argon2, séparation stricte des données par utilisateur, consentement explicite, CRUD, export JSON et suppression de compte.
+
+> MetaBloom est un outil de bien-être, pas un dispositif médical. L’API ne diagnostique pas, n’interprète pas les mesures et ne recommande aucun traitement. En cas de valeur inquiétante ou de symptômes, contacter un professionnel ou les urgences.
+
+## Démarrage local
+
+```bash
+cd server
+python -m venv .venv && . .venv/bin/activate
+pip install -r requirements-dev.txt
+uvicorn app.main:app --reload
+```
+
+Documentation : `http://localhost:8000/docs`. SQLite est utilisé en développement. Pour PostgreSQL, renseigner `DATABASE_URL` d’après `.env.example`.
+
+## Docker
+
+Copier `.env.example` vers `.env`, remplacer **tous** les exemples de secrets, puis :
+
+```bash
+docker compose -f docker-compose.example.yml --env-file .env up --build
+```
+
+Le port n’écoute que sur localhost ; placer un reverse proxy TLS devant en production.
+
+## API
+
+- `POST /auth/register`, `POST /auth/token`
+- `GET/PUT /profile`
+- `GET/POST /entries`, `PUT/DELETE /entries/{id}`
+- `GET /account/export`, `DELETE /account`
+- `GET /health`
+
+Envoyer `Authorization: Bearer `. La création d’entrées exige `consent_health_data=true`. Les limites de saisie préviennent les erreurs grossières mais ne constituent pas une validation médicale.
+
+## Tests
+
+```bash
+pytest -q
+```
+
+## Avant production
+
+- gérer les schémas avec Alembic plutôt que `create_all` ;
+- utiliser un gestionnaire de secrets, TLS, sauvegardes chiffrées, rotation et révocation des jetons ;
+- ajouter rate limiting, journal d’audit sans donnée médicale, MFA et tests de sécurité ;
+- effectuer AIPD/RGPD, définir conservation/export, contrats de sous-traitance et hébergement HDS si applicable ;
+- faire qualifier juridiquement la finalité et le statut MDR avant toute fonction clinique.
+
diff --git a/server/app/__init__.py b/server/app/__init__.py
new file mode 100644
index 0000000..8b13789
--- /dev/null
+++ b/server/app/__init__.py
@@ -0,0 +1 @@
+
diff --git a/server/app/__pycache__/__init__.cpython-311.pyc b/server/app/__pycache__/__init__.cpython-311.pyc
new file mode 100644
index 0000000..8f5f472
Binary files /dev/null and b/server/app/__pycache__/__init__.cpython-311.pyc differ
diff --git a/server/app/__pycache__/config.cpython-311.pyc b/server/app/__pycache__/config.cpython-311.pyc
new file mode 100644
index 0000000..1017528
Binary files /dev/null and b/server/app/__pycache__/config.cpython-311.pyc differ
diff --git a/server/app/__pycache__/db.cpython-311.pyc b/server/app/__pycache__/db.cpython-311.pyc
new file mode 100644
index 0000000..9808c17
Binary files /dev/null and b/server/app/__pycache__/db.cpython-311.pyc differ
diff --git a/server/app/__pycache__/main.cpython-311.pyc b/server/app/__pycache__/main.cpython-311.pyc
new file mode 100644
index 0000000..7d10209
Binary files /dev/null and b/server/app/__pycache__/main.cpython-311.pyc differ
diff --git a/server/app/__pycache__/models.cpython-311.pyc b/server/app/__pycache__/models.cpython-311.pyc
new file mode 100644
index 0000000..97bf72d
Binary files /dev/null and b/server/app/__pycache__/models.cpython-311.pyc differ
diff --git a/server/app/__pycache__/schemas.cpython-311.pyc b/server/app/__pycache__/schemas.cpython-311.pyc
new file mode 100644
index 0000000..19a0d0b
Binary files /dev/null and b/server/app/__pycache__/schemas.cpython-311.pyc differ
diff --git a/server/app/__pycache__/security.cpython-311.pyc b/server/app/__pycache__/security.cpython-311.pyc
new file mode 100644
index 0000000..2406aa4
Binary files /dev/null and b/server/app/__pycache__/security.cpython-311.pyc differ
diff --git a/server/app/config.py b/server/app/config.py
new file mode 100644
index 0000000..31115a0
--- /dev/null
+++ b/server/app/config.py
@@ -0,0 +1,22 @@
+from functools import lru_cache
+from pydantic_settings import BaseSettings, SettingsConfigDict
+
+
+class Settings(BaseSettings):
+ database_url: str = "sqlite:///./metabloom.db"
+ jwt_secret: str = "change-me-in-production-at-least-32-characters"
+ jwt_algorithm: str = "HS256"
+ access_token_minutes: int = 30
+ allowed_origins: str = "http://localhost:8081,http://localhost:19006"
+ environment: str = "development"
+ model_config = SettingsConfigDict(env_file=".env", extra="ignore")
+
+ @property
+ def origins(self) -> list[str]:
+ return [item.strip() for item in self.allowed_origins.split(",") if item.strip()]
+
+
+@lru_cache
+def get_settings() -> Settings:
+ return Settings()
+
diff --git a/server/app/db.py b/server/app/db.py
new file mode 100644
index 0000000..69ec6fd
--- /dev/null
+++ b/server/app/db.py
@@ -0,0 +1,22 @@
+from sqlalchemy import create_engine
+from sqlalchemy.orm import DeclarativeBase, sessionmaker
+from .config import get_settings
+
+
+class Base(DeclarativeBase):
+ pass
+
+
+settings = get_settings()
+connect_args = {"check_same_thread": False} if settings.database_url.startswith("sqlite") else {}
+engine = create_engine(settings.database_url, pool_pre_ping=True, connect_args=connect_args)
+SessionLocal = sessionmaker(bind=engine, autoflush=False, expire_on_commit=False)
+
+
+def get_db():
+ db = SessionLocal()
+ try:
+ yield db
+ finally:
+ db.close()
+
diff --git a/server/app/main.py b/server/app/main.py
new file mode 100644
index 0000000..b3b3233
--- /dev/null
+++ b/server/app/main.py
@@ -0,0 +1,109 @@
+from datetime import datetime, timezone
+from fastapi import Depends, FastAPI, HTTPException, Response
+from fastapi.middleware.cors import CORSMiddleware
+from fastapi.security import OAuth2PasswordRequestForm
+from sqlalchemy import select
+from sqlalchemy.orm import Session
+from .config import get_settings
+from .db import Base, engine, get_db
+from .models import Entry, Profile, User
+from .schemas import EntryIn, EntryOut, ProfileIn, ProfileOut, Register, Token
+from .security import current_user, hash_password, make_token, verify_password
+
+cfg = get_settings()
+if cfg.environment == "production" and (len(cfg.jwt_secret) < 32 or cfg.jwt_secret.startswith("change-me")):
+ raise RuntimeError("Set a strong JWT_SECRET in production")
+
+app = FastAPI(title="MetaBloom API", version="1.0.0", description="Wellness tracking API — not a medical device.")
+app.add_middleware(CORSMiddleware, allow_origins=cfg.origins, allow_credentials=True, allow_methods=["*"], allow_headers=["*"])
+
+
+@app.on_event("startup")
+def startup():
+ Base.metadata.create_all(engine)
+
+
+@app.get("/health")
+def health():
+ return {"status": "ok", "medical_notice": "MetaBloom does not diagnose or replace medical care."}
+
+
+@app.post("/auth/register", response_model=Token, status_code=201)
+def register(data: Register, db: Session = Depends(get_db)):
+ email = data.email.lower()
+ if db.scalar(select(User).where(User.email == email)):
+ raise HTTPException(409, "Email already registered")
+ user = User(email=email, password_hash=hash_password(data.password))
+ db.add(user); db.flush()
+ db.add(Profile(user_id=user.id)); db.commit()
+ return Token(access_token=make_token(user.id))
+
+
+@app.post("/auth/token", response_model=Token)
+def login(form: OAuth2PasswordRequestForm = Depends(), db: Session = Depends(get_db)):
+ user = db.scalar(select(User).where(User.email == form.username.lower()))
+ if not user or not verify_password(form.password, user.password_hash):
+ raise HTTPException(401, "Invalid credentials", headers={"WWW-Authenticate": "Bearer"})
+ return Token(access_token=make_token(user.id))
+
+
+@app.get("/profile", response_model=ProfileOut)
+def get_profile(user: User = Depends(current_user)):
+ return user.profile
+
+
+@app.put("/profile", response_model=ProfileOut)
+def put_profile(data: ProfileIn, user: User = Depends(current_user), db: Session = Depends(get_db)):
+ profile = user.profile or Profile(user_id=user.id)
+ for key, value in data.model_dump().items(): setattr(profile, key, value)
+ db.add(profile); db.commit(); db.refresh(profile)
+ return profile
+
+
+@app.get("/entries", response_model=list[EntryOut])
+def list_entries(kind: str | None = None, limit: int = 100, user: User = Depends(current_user), db: Session = Depends(get_db)):
+ limit = min(max(limit, 1), 500)
+ query = select(Entry).where(Entry.user_id == user.id)
+ if kind: query = query.where(Entry.kind == kind)
+ return db.scalars(query.order_by(Entry.recorded_at.desc()).limit(limit)).all()
+
+
+@app.post("/entries", response_model=EntryOut, status_code=201)
+def create_entry(data: EntryIn, user: User = Depends(current_user), db: Session = Depends(get_db)):
+ if not user.profile or not user.profile.consent_health_data:
+ raise HTTPException(403, "Health-data consent is required")
+ entry = Entry(user_id=user.id, **data.model_dump(exclude_none=True))
+ db.add(entry); db.commit(); db.refresh(entry)
+ return entry
+
+
+def owned_entry(entry_id: str, user: User, db: Session) -> Entry:
+ entry = db.scalar(select(Entry).where(Entry.id == entry_id, Entry.user_id == user.id))
+ if not entry: raise HTTPException(404, "Entry not found")
+ return entry
+
+
+@app.put("/entries/{entry_id}", response_model=EntryOut)
+def update_entry(entry_id: str, data: EntryIn, user: User = Depends(current_user), db: Session = Depends(get_db)):
+ entry = owned_entry(entry_id, user, db)
+ for key, value in data.model_dump(exclude_none=True).items(): setattr(entry, key, value)
+ db.commit(); db.refresh(entry)
+ return entry
+
+
+@app.delete("/entries/{entry_id}", status_code=204)
+def delete_entry(entry_id: str, user: User = Depends(current_user), db: Session = Depends(get_db)):
+ db.delete(owned_entry(entry_id, user, db)); db.commit()
+ return Response(status_code=204)
+
+
+@app.get("/account/export")
+def export_account(user: User = Depends(current_user)):
+ return {"exported_at": datetime.now(timezone.utc), "medical_notice": "Informational data only; consult a professional for medical interpretation.", "profile": ProfileOut.model_validate(user.profile), "entries": [EntryOut.model_validate(x) for x in user.entries]}
+
+
+@app.delete("/account", status_code=204)
+def delete_account(user: User = Depends(current_user), db: Session = Depends(get_db)):
+ db.delete(user); db.commit()
+ return Response(status_code=204)
+
diff --git a/server/app/models.py b/server/app/models.py
new file mode 100644
index 0000000..25e11f4
--- /dev/null
+++ b/server/app/models.py
@@ -0,0 +1,57 @@
+import enum
+import uuid
+from datetime import date, datetime, timezone
+from sqlalchemy import Boolean, Date, DateTime, Enum, Float, ForeignKey, Integer, String, Text
+from sqlalchemy.orm import Mapped, mapped_column, relationship
+from .db import Base
+
+
+def now() -> datetime:
+ return datetime.now(timezone.utc)
+
+
+class EntryKind(str, enum.Enum):
+ weight = "weight"
+ glucose = "glucose"
+ water = "water"
+ activity = "activity"
+ sleep = "sleep"
+ mood = "mood"
+ meal = "meal"
+
+
+class User(Base):
+ __tablename__ = "users"
+ id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
+ email: Mapped[str] = mapped_column(String(320), unique=True, index=True)
+ password_hash: Mapped[str] = mapped_column(String(255))
+ created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=now)
+ profile: Mapped["Profile | None"] = relationship(back_populates="user", cascade="all, delete-orphan")
+ entries: Mapped[list["Entry"]] = relationship(back_populates="user", cascade="all, delete-orphan")
+
+
+class Profile(Base):
+ __tablename__ = "profiles"
+ user_id: Mapped[str] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), primary_key=True)
+ display_name: Mapped[str | None] = mapped_column(String(80))
+ birth_date: Mapped[date | None] = mapped_column(Date)
+ height_cm: Mapped[float | None] = mapped_column(Float)
+ objective: Mapped[str | None] = mapped_column(String(120))
+ health_context: Mapped[str | None] = mapped_column(Text)
+ consent_health_data: Mapped[bool] = mapped_column(Boolean, default=False)
+ updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=now, onupdate=now)
+ user: Mapped[User] = relationship(back_populates="profile")
+
+
+class Entry(Base):
+ __tablename__ = "entries"
+ id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
+ user_id: Mapped[str] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), index=True)
+ kind: Mapped[EntryKind] = mapped_column(Enum(EntryKind))
+ value: Mapped[float] = mapped_column(Float)
+ unit: Mapped[str] = mapped_column(String(24))
+ note: Mapped[str | None] = mapped_column(String(500))
+ recorded_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=now, index=True)
+ created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=now)
+ user: Mapped[User] = relationship(back_populates="entries")
+
diff --git a/server/app/schemas.py b/server/app/schemas.py
new file mode 100644
index 0000000..a7d0630
--- /dev/null
+++ b/server/app/schemas.py
@@ -0,0 +1,64 @@
+from datetime import date, datetime
+from pydantic import BaseModel, ConfigDict, EmailStr, Field, model_validator
+from .models import EntryKind
+
+
+class Register(BaseModel):
+ email: EmailStr
+ password: str = Field(min_length=12, max_length=128)
+
+
+class Token(BaseModel):
+ access_token: str
+ token_type: str = "bearer"
+
+
+class ProfileIn(BaseModel):
+ display_name: str | None = Field(None, max_length=80)
+ birth_date: date | None = None
+ height_cm: float | None = Field(None, ge=80, le=250)
+ objective: str | None = Field(None, max_length=120)
+ health_context: str | None = Field(None, max_length=1000)
+ consent_health_data: bool = False
+
+
+class ProfileOut(ProfileIn):
+ user_id: str
+ updated_at: datetime
+ model_config = ConfigDict(from_attributes=True)
+
+
+RANGES = {
+ EntryKind.weight: (20, 500), EntryKind.glucose: (20, 600), EntryKind.water: (0, 15000),
+ EntryKind.activity: (0, 1440), EntryKind.sleep: (0, 24), EntryKind.mood: (1, 5),
+ EntryKind.meal: (0, 10000),
+}
+
+
+class EntryIn(BaseModel):
+ kind: EntryKind
+ value: float
+ unit: str = Field(min_length=1, max_length=24)
+ note: str | None = Field(None, max_length=500)
+ recorded_at: datetime | None = None
+
+ @model_validator(mode="after")
+ def sensible_range(self):
+ if self.kind == EntryKind.glucose:
+ glucose_ranges = {"mg/dL": (20, 600), "mmol/L": (1.1, 33.3)}
+ if self.unit not in glucose_ranges:
+ raise ValueError("glucose unit must be mg/dL or mmol/L")
+ low, high = glucose_ranges[self.unit]
+ else:
+ low, high = RANGES[self.kind]
+ if not low <= self.value <= high:
+ raise ValueError(f"value must be between {low} and {high} for {self.kind.value}")
+ return self
+
+
+class EntryOut(EntryIn):
+ id: str
+ user_id: str
+ recorded_at: datetime
+ created_at: datetime
+ model_config = ConfigDict(from_attributes=True)
diff --git a/server/app/security.py b/server/app/security.py
new file mode 100644
index 0000000..6211269
--- /dev/null
+++ b/server/app/security.py
@@ -0,0 +1,43 @@
+from datetime import datetime, timedelta, timezone
+import jwt
+from argon2 import PasswordHasher
+from argon2.exceptions import VerifyMismatchError
+from fastapi import Depends, HTTPException
+from fastapi.security import OAuth2PasswordBearer
+from sqlalchemy.orm import Session
+from .config import get_settings
+from .db import get_db
+from .models import User
+
+hasher = PasswordHasher()
+oauth2 = OAuth2PasswordBearer(tokenUrl="/auth/token")
+
+
+def hash_password(password: str) -> str:
+ return hasher.hash(password)
+
+
+def verify_password(password: str, digest: str) -> bool:
+ try:
+ return hasher.verify(digest, password)
+ except VerifyMismatchError:
+ return False
+
+
+def make_token(user_id: str) -> str:
+ cfg = get_settings()
+ now = datetime.now(timezone.utc)
+ return jwt.encode({"sub": user_id, "iat": now, "exp": now + timedelta(minutes=cfg.access_token_minutes)}, cfg.jwt_secret, algorithm=cfg.jwt_algorithm)
+
+
+def current_user(token: str = Depends(oauth2), db: Session = Depends(get_db)) -> User:
+ cfg = get_settings()
+ try:
+ user_id = jwt.decode(token, cfg.jwt_secret, algorithms=[cfg.jwt_algorithm])["sub"]
+ except (jwt.PyJWTError, KeyError):
+ raise HTTPException(401, "Invalid or expired token", headers={"WWW-Authenticate": "Bearer"})
+ user = db.get(User, user_id)
+ if not user:
+ raise HTTPException(401, "Account no longer exists")
+ return user
+
diff --git a/server/docker-compose.example.yml b/server/docker-compose.example.yml
new file mode 100644
index 0000000..15c26ec
--- /dev/null
+++ b/server/docker-compose.example.yml
@@ -0,0 +1,27 @@
+services:
+ api:
+ build: .
+ environment:
+ DATABASE_URL: postgresql+psycopg://metabloom:${POSTGRES_PASSWORD}@db:5432/metabloom
+ JWT_SECRET: ${JWT_SECRET}
+ ENVIRONMENT: production
+ ALLOWED_ORIGINS: ${ALLOWED_ORIGINS:-http://localhost:8081}
+ depends_on:
+ db:
+ condition: service_healthy
+ ports: ["127.0.0.1:8000:8000"]
+ db:
+ image: postgres:16-alpine
+ environment:
+ POSTGRES_DB: metabloom
+ POSTGRES_USER: metabloom
+ POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
+ volumes: ["postgres_data:/var/lib/postgresql/data"]
+ healthcheck:
+ test: ["CMD-SHELL", "pg_isready -U metabloom"]
+ interval: 5s
+ timeout: 3s
+ retries: 10
+volumes:
+ postgres_data:
+
diff --git a/server/requirements-dev.txt b/server/requirements-dev.txt
new file mode 100644
index 0000000..11dbdcd
--- /dev/null
+++ b/server/requirements-dev.txt
@@ -0,0 +1,4 @@
+-r requirements.txt
+pytest==8.3.4
+httpx==0.28.1
+
diff --git a/server/requirements.txt b/server/requirements.txt
new file mode 100644
index 0000000..3bcbf00
--- /dev/null
+++ b/server/requirements.txt
@@ -0,0 +1,10 @@
+fastapi==0.115.6
+uvicorn[standard]==0.34.0
+sqlalchemy==2.0.36
+psycopg[binary]==3.2.3
+pydantic-settings==2.7.0
+PyJWT==2.10.1
+argon2-cffi==23.1.0
+email-validator==2.2.0
+python-multipart==0.0.20
+
diff --git a/server/tests/__pycache__/test_api.cpython-311-pytest-8.3.4.pyc b/server/tests/__pycache__/test_api.cpython-311-pytest-8.3.4.pyc
new file mode 100644
index 0000000..7523216
Binary files /dev/null and b/server/tests/__pycache__/test_api.cpython-311-pytest-8.3.4.pyc differ
diff --git a/server/tests/test_api.py b/server/tests/test_api.py
new file mode 100644
index 0000000..f28229f
--- /dev/null
+++ b/server/tests/test_api.py
@@ -0,0 +1,46 @@
+import os
+os.environ["DATABASE_URL"] = "sqlite:///./test_metabloom.db"
+os.environ["JWT_SECRET"] = "test-secret-that-is-long-enough-for-tests"
+
+from fastapi.testclient import TestClient
+from app.db import Base, engine
+from app.main import app
+
+
+def setup_function():
+ Base.metadata.drop_all(engine); Base.metadata.create_all(engine)
+
+
+def auth(client):
+ response = client.post("/auth/register", json={"email": "user@example.com", "password": "a-very-safe-password"})
+ assert response.status_code == 201
+ return {"Authorization": f"Bearer {response.json()['access_token']}"}
+
+
+def test_health_auth_crud_export_delete():
+ with TestClient(app) as client:
+ assert client.get("/health").status_code == 200
+ headers = auth(client)
+ assert client.post("/entries", headers=headers, json={"kind": "weight", "value": 90, "unit": "kg"}).status_code == 403
+ profile = client.put("/profile", headers=headers, json={"display_name": "Alex", "height_cm": 175, "consent_health_data": True})
+ assert profile.status_code == 200
+ created = client.post("/entries", headers=headers, json={"kind": "weight", "value": 90, "unit": "kg"})
+ assert created.status_code == 201
+ entry_id = created.json()["id"]
+ assert len(client.get("/entries", headers=headers).json()) == 1
+ assert client.put(f"/entries/{entry_id}", headers=headers, json={"kind": "weight", "value": 89, "unit": "kg"}).json()["value"] == 89
+ assert client.get("/account/export", headers=headers).status_code == 200
+ assert client.delete(f"/entries/{entry_id}", headers=headers).status_code == 204
+ assert client.delete("/account", headers=headers).status_code == 204
+
+
+def test_validation_and_isolation():
+ with TestClient(app) as client:
+ headers = auth(client)
+ client.put("/profile", headers=headers, json={"consent_health_data": True})
+ assert client.post("/entries", headers=headers, json={"kind": "glucose", "value": 999, "unit": "mg/dL"}).status_code == 422
+ assert client.post("/entries", headers=headers, json={"kind": "glucose", "value": 5.5, "unit": "mmol/L"}).status_code == 201
+ assert client.post("/entries", headers=headers, json={"kind": "glucose", "value": 5.5, "unit": "mg/dL"}).status_code == 422
+ entries = client.get("/entries", headers=headers).json()
+ assert len(entries) == 1
+ assert entries[0]["unit"] == "mmol/L"