Skip to main content

macro_factor_api/
client.rs

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