Skip to main content

macro_factor_api/
client.rs

1use anyhow::{anyhow, Result};
2use chrono::{NaiveDate, Timelike, Utc};
3use serde_json::{json, Value};
4
5use crate::auth::FirebaseAuth;
6use crate::firestore::{
7    parse_document, parse_firestore_fields, to_firestore_fields,
8    FirestoreClient,
9};
10use crate::models::*;
11
12#[derive(Clone)]
13pub struct MacroFactorClient {
14    pub auth: FirebaseAuth,
15    pub firestore: FirestoreClient,
16    user_id: Option<String>,
17}
18
19impl MacroFactorClient {
20    pub fn new(refresh_token: String) -> Self {
21        let auth = FirebaseAuth::new(refresh_token);
22        let firestore = FirestoreClient::new(auth.clone());
23        Self {
24            auth,
25            firestore,
26            user_id: None,
27        }
28    }
29
30    /// Sign in with email and password.
31    pub async fn login(email: &str, password: &str) -> Result<Self> {
32        let auth = FirebaseAuth::sign_in_with_email(email, password).await?;
33        let firestore = FirestoreClient::new(auth.clone());
34        Ok(Self {
35            auth,
36            firestore,
37            user_id: None,
38        })
39    }
40
41    pub async fn get_user_id(&mut self) -> Result<String> {
42        if let Some(ref uid) = self.user_id {
43            return Ok(uid.clone());
44        }
45        let uid = self.auth.get_user_id().await?;
46        self.user_id = Some(uid.clone());
47        Ok(uid)
48    }
49
50    /// Get the user profile document.
51    pub async fn get_profile(&mut self) -> Result<Value> {
52        let uid = self.get_user_id().await?;
53        let doc = self.firestore.get_document(&format!("users/{}", uid)).await?;
54        Ok(parse_document(&doc))
55    }
56
57    /// List sub-collections under the user document.
58    pub async fn list_subcollections(&self, document_path: &str) -> Result<Vec<String>> {
59        self.firestore
60            .list_collection_ids(Some(document_path))
61            .await
62    }
63
64    /// Get a few documents from a collection for schema discovery.
65    pub async fn sample_collection(
66        &self,
67        collection_path: &str,
68        limit: u32,
69    ) -> Result<Vec<Value>> {
70        let (docs, _) = self
71            .firestore
72            .list_documents(collection_path, Some(limit), None)
73            .await?;
74        Ok(docs.iter().map(parse_document).collect())
75    }
76
77    /// Get a raw document by path and return parsed fields.
78    pub async fn get_raw_document(&self, path: &str) -> Result<Value> {
79        let doc = self.firestore.get_document(path).await?;
80        Ok(parse_document(&doc))
81    }
82
83    /// Get scale/weight entries for a date range.
84    /// Data is stored in `scale/{year}` docs with MMDD keys.
85    pub async fn get_weight_entries(
86        &mut self,
87        start: NaiveDate,
88        end: NaiveDate,
89    ) -> Result<Vec<ScaleEntry>> {
90        let uid = self.get_user_id().await?;
91        let mut entries = Vec::new();
92
93        // Collect all years in the range
94        let start_year = start.format("%Y").to_string().parse::<i32>()?;
95        let end_year = end.format("%Y").to_string().parse::<i32>()?;
96
97        for year in start_year..=end_year {
98            let path = format!("users/{}/scale/{}", uid, year);
99            let doc = match self.firestore.get_document(&path).await {
100                Ok(d) => d,
101                Err(_) => continue,
102            };
103
104            if let Some(ref fields) = doc.fields {
105                let parsed = parse_firestore_fields(&Value::Object(fields.clone()));
106                if let Some(map) = parsed.as_object() {
107                    for (key, val) in map {
108                        if key.starts_with('_') || key.len() != 4 {
109                            continue;
110                        }
111                        // Parse MMDD key
112                        let month: u32 = key[..2].parse().unwrap_or(0);
113                        let day: u32 = key[2..].parse().unwrap_or(0);
114                        if let Some(date) = NaiveDate::from_ymd_opt(year, month, day) {
115                            if date >= start && date <= end {
116                                if let Some(obj) = val.as_object() {
117                                    let weight = obj
118                                        .get("w")
119                                        .and_then(|v| v.as_f64())
120                                        .unwrap_or(0.0);
121                                    let body_fat = obj.get("f").and_then(|v| v.as_f64());
122                                    let source = obj
123                                        .get("s")
124                                        .and_then(|v| v.as_str())
125                                        .map(String::from);
126
127                                    entries.push(ScaleEntry {
128                                        date,
129                                        weight,
130                                        body_fat,
131                                        source,
132                                    });
133                                }
134                            }
135                        }
136                    }
137                }
138            }
139        }
140
141        entries.sort_by_key(|e| e.date);
142        Ok(entries)
143    }
144
145    /// Get nutrition summaries for a date range.
146    /// Data is stored in `nutrition/{year}` docs with MMDD keys.
147    pub async fn get_nutrition(
148        &mut self,
149        start: NaiveDate,
150        end: NaiveDate,
151    ) -> Result<Vec<NutritionSummary>> {
152        let uid = self.get_user_id().await?;
153        let mut entries = Vec::new();
154
155        let start_year = start.format("%Y").to_string().parse::<i32>()?;
156        let end_year = end.format("%Y").to_string().parse::<i32>()?;
157
158        for year in start_year..=end_year {
159            let path = format!("users/{}/nutrition/{}", uid, year);
160            let doc = match self.firestore.get_document(&path).await {
161                Ok(d) => d,
162                Err(_) => continue,
163            };
164
165            if let Some(ref fields) = doc.fields {
166                let parsed = parse_firestore_fields(&Value::Object(fields.clone()));
167                if let Some(map) = parsed.as_object() {
168                    for (key, val) in map {
169                        if key.starts_with('_') || key.len() != 4 {
170                            continue;
171                        }
172                        let month: u32 = key[..2].parse().unwrap_or(0);
173                        let day: u32 = key[2..].parse().unwrap_or(0);
174                        if let Some(date) = NaiveDate::from_ymd_opt(year, month, day) {
175                            if date >= start && date <= end {
176                                if let Some(obj) = val.as_object() {
177                                    let parse_num = |k: &str| -> Option<f64> {
178                                        obj.get(k).and_then(|v| {
179                                            v.as_f64().or_else(|| {
180                                                v.as_str().and_then(|s| s.parse().ok())
181                                            })
182                                        })
183                                    };
184
185                                    entries.push(NutritionSummary {
186                                        date,
187                                        calories: parse_num("k"),
188                                        protein: parse_num("p"),
189                                        carbs: parse_num("c"),
190                                        fat: parse_num("f"),
191                                        sugar: parse_num("269"),
192                                        fiber: parse_num("291"),
193                                        source: obj
194                                            .get("s")
195                                            .and_then(|v| v.as_str())
196                                            .map(String::from),
197                                    });
198                                }
199                            }
200                        }
201                    }
202                }
203            }
204        }
205
206        entries.sort_by_key(|e| e.date);
207        Ok(entries)
208    }
209
210    /// Get food log entries for a specific date.
211    /// Data is stored in `food/{YYYY-MM-DD}` docs.
212    pub async fn get_food_log(&mut self, date: NaiveDate) -> Result<Vec<FoodEntry>> {
213        let uid = self.get_user_id().await?;
214        let date_str = date.format("%Y-%m-%d").to_string();
215        let path = format!("users/{}/food/{}", uid, date_str);
216
217        let doc = match self.firestore.get_document(&path).await {
218            Ok(d) => d,
219            Err(e) if e.to_string().contains("404") => return Ok(Vec::new()),
220            Err(e) => return Err(e),
221        };
222        let mut entries = Vec::new();
223
224        if let Some(ref fields) = doc.fields {
225            let parsed = parse_firestore_fields(&Value::Object(fields.clone()));
226            if let Some(map) = parsed.as_object() {
227                for (key, val) in map {
228                    if key.starts_with('_') {
229                        continue;
230                    }
231                    if let Some(obj) = val.as_object() {
232                        let parse_num = |k: &str| -> Option<f64> {
233                            obj.get(k).and_then(|v| {
234                                v.as_f64()
235                                    .or_else(|| v.as_str().and_then(|s| s.parse().ok()))
236                            })
237                        };
238                        let parse_str =
239                            |k: &str| obj.get(k).and_then(|v| v.as_str()).map(String::from);
240
241                        let serving_grams = parse_num("g");
242                        let user_qty = parse_num("y");
243                        let unit_weight = parse_num("w");
244
245                        entries.push(FoodEntry {
246                            date,
247                            entry_id: key.clone(),
248                            name: parse_str("t"),
249                            brand: parse_str("b"),
250                            calories_raw: parse_num("c"),
251                            protein_raw: parse_num("p"),
252                            carbs_raw: parse_num("e"),
253                            fat_raw: parse_num("f"),
254                            serving_grams,
255                            user_qty,
256                            unit_weight,
257                            quantity: parse_num("q"),
258                            serving_unit: parse_str("s"),
259                            hour: parse_str("h"),
260                            minute: parse_str("mi"),
261                            source_type: parse_str("k"),
262                            food_id: parse_str("id"),
263                        });
264                    }
265                }
266            }
267        }
268
269        // Sort by hour:minute
270        entries.sort_by(|a, b| {
271            let time_a = (
272                a.hour.as_deref().unwrap_or("0").parse::<u32>().unwrap_or(0),
273                a.minute
274                    .as_deref()
275                    .unwrap_or("0")
276                    .parse::<u32>()
277                    .unwrap_or(0),
278            );
279            let time_b = (
280                b.hour.as_deref().unwrap_or("0").parse::<u32>().unwrap_or(0),
281                b.minute
282                    .as_deref()
283                    .unwrap_or("0")
284                    .parse::<u32>()
285                    .unwrap_or(0),
286            );
287            time_a.cmp(&time_b)
288        });
289
290        Ok(entries)
291    }
292
293    /// Get step counts for a date range.
294    /// Data is stored in `steps/{year}` docs with MMDD keys.
295    pub async fn get_steps(
296        &mut self,
297        start: NaiveDate,
298        end: NaiveDate,
299    ) -> Result<Vec<StepEntry>> {
300        let uid = self.get_user_id().await?;
301        let mut entries = Vec::new();
302
303        let start_year = start.format("%Y").to_string().parse::<i32>()?;
304        let end_year = end.format("%Y").to_string().parse::<i32>()?;
305
306        for year in start_year..=end_year {
307            let path = format!("users/{}/steps/{}", uid, year);
308            let doc = match self.firestore.get_document(&path).await {
309                Ok(d) => d,
310                Err(_) => continue,
311            };
312
313            if let Some(ref fields) = doc.fields {
314                let parsed = parse_firestore_fields(&Value::Object(fields.clone()));
315                if let Some(map) = parsed.as_object() {
316                    for (key, val) in map {
317                        if key.starts_with('_') || key.len() != 4 {
318                            continue;
319                        }
320                        let month: u32 = key[..2].parse().unwrap_or(0);
321                        let day: u32 = key[2..].parse().unwrap_or(0);
322                        if let Some(date) = NaiveDate::from_ymd_opt(year, month, day) {
323                            if date >= start && date <= end {
324                                if let Some(obj) = val.as_object() {
325                                    let steps = obj
326                                        .get("st")
327                                        .and_then(|v| {
328                                            v.as_u64().or_else(|| {
329                                                v.as_str().and_then(|s| s.parse().ok())
330                                            })
331                                        })
332                                        .unwrap_or(0);
333                                    let source = obj
334                                        .get("s")
335                                        .and_then(|v| v.as_str())
336                                        .map(String::from);
337
338                                    entries.push(StepEntry {
339                                        date,
340                                        steps,
341                                        source,
342                                    });
343                                }
344                            }
345                        }
346                    }
347                }
348            }
349        }
350
351        entries.sort_by_key(|e| e.date);
352        Ok(entries)
353    }
354
355    /// Get the current macro/calorie goals from the user's planner.
356    pub async fn get_goals(&mut self) -> Result<Goals> {
357        let profile = self.get_profile().await?;
358
359        let planner = profile
360            .get("planner")
361            .ok_or_else(|| anyhow!("No planner field in user profile"))?;
362
363        let parse_vec = |key: &str| -> Vec<f64> {
364            planner
365                .get(key)
366                .and_then(|v| v.as_array())
367                .map(|arr| {
368                    arr.iter()
369                        .filter_map(|v| v.as_f64().or_else(|| v.as_str().and_then(|s| s.parse().ok())))
370                        .collect()
371                })
372                .unwrap_or_default()
373        };
374
375        Ok(Goals {
376            calories: parse_vec("calories"),
377            protein: parse_vec("protein"),
378            carbs: parse_vec("carbs"),
379            fat: parse_vec("fat"),
380            tdee: planner
381                .get("tdeeValue")
382                .and_then(|v| v.as_f64().or_else(|| v.as_str().and_then(|s| s.parse().ok()))),
383            program_style: planner
384                .get("programStyle")
385                .and_then(|v| v.as_str())
386                .map(String::from),
387            program_type: planner
388                .get("programType")
389                .and_then(|v| v.as_str())
390                .map(String::from),
391        })
392    }
393
394    /// Log a food entry for a given date.
395    ///
396    /// Fields like `calories`, `protein`, `carbs`, `fat` are required.
397    /// The entry will be created with a timestamp-based ID.
398    pub async fn log_food(
399        &mut self,
400        date: NaiveDate,
401        name: &str,
402        calories: f64,
403        protein: f64,
404        carbs: f64,
405        fat: f64,
406    ) -> Result<()> {
407        let uid = self.get_user_id().await?;
408        let date_str = date.format("%Y-%m-%d").to_string();
409        let path = format!("users/{}/food/{}", uid, date_str);
410
411        let now = Utc::now();
412        let entry_id = format!(
413            "{}",
414            now.timestamp_millis() * 1000 + now.timestamp_subsec_micros() as i64 % 1000
415        );
416        let food_id = format!(
417            "{}",
418            now.timestamp_millis() * 1000 + (now.timestamp_subsec_micros() as i64 % 1000) + 10
419        );
420
421        let hour = now.hour().to_string();
422        let minute = now.minute().to_string();
423
424        let entry = json!({
425            "t": name,
426            "b": "Quick Add",
427            "c": format!("{:.1}", calories),
428            "p": format!("{:.1}", protein),
429            "e": format!("{:.1}", carbs),
430            "f": format!("{:.1}", fat),
431            "w": "100.0",
432            "g": "100.0",
433            "q": "1.0",
434            "y": "1.0",
435            "s": "serving",
436            "u": "serving",
437            "h": hour,
438            "mi": minute,
439            "k": "n",
440            "id": food_id,
441            "ca": &entry_id,
442            "ua": &entry_id,
443            "ef": true,
444            "d": false,
445            "x": "13",
446            "m": [{"m": "serving", "q": "1.0", "w": "100.0"}]
447        });
448
449        let fields = to_firestore_fields(&json!({ &entry_id: entry }));
450
451        // Firestore rejects field paths that start with a digit unless backtick-quoted
452        let field_mask = format!("`{}`", entry_id);
453        self.firestore
454            .patch_document(&path, fields, &[&field_mask])
455            .await?;
456
457        Ok(())
458    }
459
460    /// Log a weight entry for a given date.
461    /// Weight should be in kg.
462    pub async fn log_weight(
463        &mut self,
464        date: NaiveDate,
465        weight_kg: f64,
466        body_fat: Option<f64>,
467    ) -> Result<()> {
468        let uid = self.get_user_id().await?;
469        let year = date.format("%Y").to_string();
470        let mmdd = date.format("%m%d").to_string();
471        let path = format!("users/{}/scale/{}", uid, year);
472
473        let mut entry = json!({
474            "w": weight_kg,
475            "s": "m",
476            "do": null
477        });
478        if let Some(bf) = body_fat {
479            entry["f"] = json!(bf);
480        } else {
481            entry["f"] = Value::Null;
482        }
483
484        let fields = to_firestore_fields(&json!({ &mmdd: entry }));
485
486        self.firestore
487            .patch_document(&path, fields, &[&mmdd])
488            .await?;
489
490        Ok(())
491    }
492
493    /// Log a nutrition summary for a given date.
494    pub async fn log_nutrition(
495        &mut self,
496        date: NaiveDate,
497        calories: f64,
498        protein: Option<f64>,
499        carbs: Option<f64>,
500        fat: Option<f64>,
501    ) -> Result<()> {
502        let uid = self.get_user_id().await?;
503        let year = date.format("%Y").to_string();
504        let mmdd = date.format("%m%d").to_string();
505        let path = format!("users/{}/nutrition/{}", uid, year);
506
507        let entry = json!({
508            "k": format!("{:.0}", calories),
509            "p": protein.map(|v| format!("{:.0}", v)).unwrap_or_default(),
510            "c": carbs.map(|v| format!("{:.0}", v)).unwrap_or_default(),
511            "f": fat.map(|v| format!("{:.0}", v)).unwrap_or_default(),
512            "s": "m",
513            "do": null
514        });
515
516        let fields = to_firestore_fields(&json!({ &mmdd: entry }));
517
518        self.firestore
519            .patch_document(&path, fields, &[&mmdd])
520            .await?;
521
522        Ok(())
523    }
524}