Skip to main content

macro_factor_api/
client.rs

1use std::collections::HashMap;
2
3use anyhow::{anyhow, Result};
4use chrono::{DateTime, Local, NaiveDate, Timelike};
5use reqwest::Client;
6use serde_json::{json, Value};
7
8use crate::auth::FirebaseAuth;
9use crate::firestore::{
10    parse_document, parse_firestore_fields, to_firestore_fields, FirestoreClient,
11};
12use crate::models::*;
13
14const TYPESENSE_HOST: &str = "https://oewdzs50x93n2c4mp.a1.typesense.net";
15const TYPESENSE_API_KEY: &str = "4tKoPwBN6YaPXZDeQ7AyDfZbrjPbGMmG";
16
17#[derive(Clone)]
18pub struct MacroFactorClient {
19    pub auth: FirebaseAuth,
20    pub firestore: FirestoreClient,
21    user_id: Option<String>,
22}
23
24impl MacroFactorClient {
25    pub fn new(refresh_token: String) -> Self {
26        let auth = FirebaseAuth::new(refresh_token);
27        let firestore = FirestoreClient::new(auth.clone());
28        Self {
29            auth,
30            firestore,
31            user_id: None,
32        }
33    }
34
35    /// Sign in with email and password.
36    pub async fn login(email: &str, password: &str) -> Result<Self> {
37        let auth = FirebaseAuth::sign_in_with_email(email, password).await?;
38        let firestore = FirestoreClient::new(auth.clone());
39        Ok(Self {
40            auth,
41            firestore,
42            user_id: None,
43        })
44    }
45
46    pub async fn get_user_id(&mut self) -> Result<String> {
47        if let Some(ref uid) = self.user_id {
48            return Ok(uid.clone());
49        }
50        let uid = self.auth.get_user_id().await?;
51        self.user_id = Some(uid.clone());
52        Ok(uid)
53    }
54
55    /// Get the user profile document.
56    pub async fn get_profile(&mut self) -> Result<Value> {
57        let uid = self.get_user_id().await?;
58        let doc = self
59            .firestore
60            .get_document(&format!("users/{}", uid))
61            .await?;
62        Ok(parse_document(&doc))
63    }
64
65    /// List sub-collections under the user document.
66    pub async fn list_subcollections(&self, document_path: &str) -> Result<Vec<String>> {
67        self.firestore
68            .list_collection_ids(Some(document_path))
69            .await
70    }
71
72    /// Get a few documents from a collection for schema discovery.
73    pub async fn sample_collection(&self, collection_path: &str, limit: u32) -> Result<Vec<Value>> {
74        let (docs, _) = self
75            .firestore
76            .list_documents(collection_path, Some(limit), None)
77            .await?;
78        Ok(docs.iter().map(parse_document).collect())
79    }
80
81    /// Get a raw document by path and return parsed fields.
82    pub async fn get_raw_document(&self, path: &str) -> Result<Value> {
83        let doc = self.firestore.get_document(path).await?;
84        Ok(parse_document(&doc))
85    }
86
87    /// Get scale/weight entries for a date range.
88    /// Data is stored in `scale/{year}` docs with MMDD keys.
89    pub async fn get_weight_entries(
90        &mut self,
91        start: NaiveDate,
92        end: NaiveDate,
93    ) -> Result<Vec<ScaleEntry>> {
94        let uid = self.get_user_id().await?;
95        let mut entries = Vec::new();
96
97        // Collect all years in the range
98        let start_year = start.format("%Y").to_string().parse::<i32>()?;
99        let end_year = end.format("%Y").to_string().parse::<i32>()?;
100
101        for year in start_year..=end_year {
102            let path = format!("users/{}/scale/{}", uid, year);
103            let doc = match self.firestore.get_document(&path).await {
104                Ok(d) => d,
105                Err(_) => continue,
106            };
107
108            if let Some(ref fields) = doc.fields {
109                let parsed = parse_firestore_fields(&Value::Object(fields.clone()));
110                if let Some(map) = parsed.as_object() {
111                    for (key, val) in map {
112                        if key.starts_with('_') || key.len() != 4 {
113                            continue;
114                        }
115                        // Parse MMDD key
116                        let month: u32 = key[..2].parse().unwrap_or(0);
117                        let day: u32 = key[2..].parse().unwrap_or(0);
118                        if let Some(date) = NaiveDate::from_ymd_opt(year, month, day) {
119                            if date >= start && date <= end {
120                                if let Some(obj) = val.as_object() {
121                                    let weight =
122                                        obj.get("w").and_then(|v| v.as_f64()).unwrap_or(0.0);
123                                    let body_fat = obj.get("f").and_then(|v| v.as_f64());
124                                    let source =
125                                        obj.get("s").and_then(|v| v.as_str()).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()
180                                                .or_else(|| v.as_str().and_then(|s| s.parse().ok()))
181                                        })
182                                    };
183
184                                    entries.push(NutritionSummary {
185                                        date,
186                                        calories: parse_num("k"),
187                                        protein: parse_num("p"),
188                                        carbs: parse_num("c"),
189                                        fat: parse_num("f"),
190                                        sugar: parse_num("269"),
191                                        fiber: parse_num("291"),
192                                        source: obj
193                                            .get("s")
194                                            .and_then(|v| v.as_str())
195                                            .map(String::from),
196                                    });
197                                }
198                            }
199                        }
200                    }
201                }
202            }
203        }
204
205        entries.sort_by_key(|e| e.date);
206        Ok(entries)
207    }
208
209    /// Get food log entries for a specific date.
210    /// Data is stored in `food/{YYYY-MM-DD}` docs.
211    pub async fn get_food_log(&mut self, date: NaiveDate) -> Result<Vec<FoodEntry>> {
212        let uid = self.get_user_id().await?;
213        let date_str = date.format("%Y-%m-%d").to_string();
214        let path = format!("users/{}/food/{}", uid, date_str);
215
216        let doc = match self.firestore.get_document(&path).await {
217            Ok(d) => d,
218            Err(e) if e.to_string().contains("404") => return Ok(Vec::new()),
219            Err(e) => return Err(e),
220        };
221        let mut entries = Vec::new();
222
223        if let Some(ref fields) = doc.fields {
224            let parsed = parse_firestore_fields(&Value::Object(fields.clone()));
225            if let Some(map) = parsed.as_object() {
226                for (key, val) in map {
227                    if key.starts_with('_') {
228                        continue;
229                    }
230                    if let Some(obj) = val.as_object() {
231                        let parse_num = |k: &str| -> Option<f64> {
232                            obj.get(k).and_then(|v| {
233                                v.as_f64()
234                                    .or_else(|| v.as_str().and_then(|s| s.parse().ok()))
235                            })
236                        };
237                        let parse_str =
238                            |k: &str| obj.get(k).and_then(|v| v.as_str()).map(String::from);
239
240                        let serving_grams = parse_num("g");
241                        let user_qty = parse_num("y");
242                        let unit_weight = parse_num("w");
243
244                        let deleted = obj.get("d").and_then(|v| v.as_bool());
245
246                        entries.push(FoodEntry {
247                            date,
248                            entry_id: key.clone(),
249                            name: parse_str("t"),
250                            brand: parse_str("b"),
251                            calories_raw: parse_num("c"),
252                            protein_raw: parse_num("p"),
253                            carbs_raw: parse_num("e"),
254                            fat_raw: parse_num("f"),
255                            serving_grams,
256                            user_qty,
257                            unit_weight,
258                            quantity: parse_num("q"),
259                            serving_unit: parse_str("s"),
260                            hour: parse_str("h"),
261                            minute: parse_str("mi"),
262                            source_type: parse_str("k"),
263                            food_id: parse_str("id"),
264                            deleted,
265                        });
266                    }
267                }
268            }
269        }
270
271        // Sort by hour:minute
272        entries.sort_by(|a, b| {
273            let time_a = (
274                a.hour.as_deref().unwrap_or("0").parse::<u32>().unwrap_or(0),
275                a.minute
276                    .as_deref()
277                    .unwrap_or("0")
278                    .parse::<u32>()
279                    .unwrap_or(0),
280            );
281            let time_b = (
282                b.hour.as_deref().unwrap_or("0").parse::<u32>().unwrap_or(0),
283                b.minute
284                    .as_deref()
285                    .unwrap_or("0")
286                    .parse::<u32>()
287                    .unwrap_or(0),
288            );
289            time_a.cmp(&time_b)
290        });
291
292        Ok(entries)
293    }
294
295    /// Get step counts for a date range.
296    /// Data is stored in `steps/{year}` docs with MMDD keys.
297    pub async fn get_steps(&mut self, start: NaiveDate, end: NaiveDate) -> Result<Vec<StepEntry>> {
298        let uid = self.get_user_id().await?;
299        let mut entries = Vec::new();
300
301        let start_year = start.format("%Y").to_string().parse::<i32>()?;
302        let end_year = end.format("%Y").to_string().parse::<i32>()?;
303
304        for year in start_year..=end_year {
305            let path = format!("users/{}/steps/{}", uid, year);
306            let doc = match self.firestore.get_document(&path).await {
307                Ok(d) => d,
308                Err(_) => continue,
309            };
310
311            if let Some(ref fields) = doc.fields {
312                let parsed = parse_firestore_fields(&Value::Object(fields.clone()));
313                if let Some(map) = parsed.as_object() {
314                    for (key, val) in map {
315                        if key.starts_with('_') || key.len() != 4 {
316                            continue;
317                        }
318                        let month: u32 = key[..2].parse().unwrap_or(0);
319                        let day: u32 = key[2..].parse().unwrap_or(0);
320                        if let Some(date) = NaiveDate::from_ymd_opt(year, month, day) {
321                            if date >= start && date <= end {
322                                if let Some(obj) = val.as_object() {
323                                    let steps = obj
324                                        .get("st")
325                                        .and_then(|v| {
326                                            v.as_u64()
327                                                .or_else(|| v.as_str().and_then(|s| s.parse().ok()))
328                                        })
329                                        .unwrap_or(0);
330                                    let source =
331                                        obj.get("s").and_then(|v| v.as_str()).map(String::from);
332
333                                    entries.push(StepEntry {
334                                        date,
335                                        steps,
336                                        source,
337                                    });
338                                }
339                            }
340                        }
341                    }
342                }
343            }
344        }
345
346        entries.sort_by_key(|e| e.date);
347        Ok(entries)
348    }
349
350    /// Get the current macro/calorie goals from the user's planner.
351    pub async fn get_goals(&mut self) -> Result<Goals> {
352        let profile = self.get_profile().await?;
353
354        let planner = profile
355            .get("planner")
356            .ok_or_else(|| anyhow!("No planner field in user profile"))?;
357
358        let parse_vec = |key: &str| -> Vec<f64> {
359            planner
360                .get(key)
361                .and_then(|v| v.as_array())
362                .map(|arr| {
363                    arr.iter()
364                        .filter_map(|v| {
365                            v.as_f64()
366                                .or_else(|| v.as_str().and_then(|s| s.parse().ok()))
367                        })
368                        .collect()
369                })
370                .unwrap_or_default()
371        };
372
373        Ok(Goals {
374            calories: parse_vec("calories"),
375            protein: parse_vec("protein"),
376            carbs: parse_vec("carbs"),
377            fat: parse_vec("fat"),
378            tdee: planner.get("tdeeValue").and_then(|v| {
379                v.as_f64()
380                    .or_else(|| v.as_str().and_then(|s| s.parse().ok()))
381            }),
382            program_style: planner
383                .get("programStyle")
384                .and_then(|v| v.as_str())
385                .map(String::from),
386            program_type: planner
387                .get("programType")
388                .and_then(|v| v.as_str())
389                .map(String::from),
390        })
391    }
392
393    /// Write a food entry to Firestore.
394    ///
395    /// This is the shared implementation used by `log_food` and `log_searched_food`.
396    async fn write_food_entry(&mut self, logged_at: DateTime<Local>, entry: Value) -> Result<()> {
397        let uid = self.get_user_id().await?;
398        let date_str = logged_at.format("%Y-%m-%d").to_string();
399        let path = format!("users/{}/food/{}", uid, date_str);
400
401        let ts = logged_at.timestamp_millis();
402        let entry_id = format!("{}", ts * 1000);
403
404        let fields = to_firestore_fields(&json!({ &entry_id: entry }));
405        let field_mask = format!("`{}`", entry_id);
406        self.firestore
407            .patch_document(&path, fields, &[&field_mask])
408            .await?;
409
410        Ok(())
411    }
412
413    /// Log a food entry for a given date and time (quick add).
414    ///
415    /// After logging, call [`sync_day`](Self::sync_day) to update the app's daily summary.
416    pub async fn log_food(
417        &mut self,
418        logged_at: DateTime<Local>,
419        name: &str,
420        calories: f64,
421        protein: f64,
422        carbs: f64,
423        fat: f64,
424    ) -> Result<()> {
425        let ts = logged_at.timestamp_millis();
426        let food_id = format!("{}", ts * 1000 + 10);
427        let entry_id = format!("{}", ts * 1000);
428        let ua_id = format!("{}", ts * 1000 + 1);
429        let hour = logged_at.hour().to_string();
430        let minute = logged_at.minute().to_string();
431
432        let entry = json!({
433            "t": name,
434            "b": "Quick Add",
435            "c": format!("{:.1}", calories),
436            "p": format!("{:.1}", protein),
437            "e": format!("{:.1}", carbs),
438            "f": format!("{:.1}", fat),
439            "w": "100.0",
440            "g": "100.0",
441            "q": "1.0",
442            "y": "1.0",
443            "s": "serving",
444            "u": "serving",
445            "h": hour,
446            "mi": minute,
447            "k": "n",
448            "id": food_id,
449            "ca": &entry_id,
450            "ua": &ua_id,
451            "ef": false,
452            "d": false,
453            "x": "13",
454            "m": [{"m": "serving", "q": "1.0", "w": "100.0"}]
455        });
456
457        self.write_food_entry(logged_at, entry).await
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        let field_mask = format!("`{}`", mmdd);
487        self.firestore
488            .patch_document(&path, fields, &[&field_mask])
489            .await?;
490
491        Ok(())
492    }
493
494    /// Delete a weight entry for a given date.
495    ///
496    /// Removes the MMDD field from the `scale/{year}` document.
497    pub async fn delete_weight_entry(&mut self, date: NaiveDate) -> Result<()> {
498        let uid = self.get_user_id().await?;
499        let year = date.format("%Y").to_string();
500        let mmdd = date.format("%m%d").to_string();
501        let path = format!("users/{}/scale/{}", uid, year);
502
503        let fields = serde_json::Map::new();
504        let field_mask = format!("`{}`", mmdd);
505        self.firestore
506            .patch_document(&path, fields, &[&field_mask])
507            .await?;
508
509        Ok(())
510    }
511
512    /// Import a manual nutrition summary for a given date.
513    ///
514    /// This writes to the `nutrition/{year}` collection, which is used for
515    /// **externally imported** nutrition data (e.g. Apple Health syncs).
516    /// The app computes daily totals from individual food entries automatically —
517    /// you do NOT need to call this after logging food.
518    pub async fn log_nutrition(
519        &mut self,
520        date: NaiveDate,
521        calories: f64,
522        protein: Option<f64>,
523        carbs: Option<f64>,
524        fat: Option<f64>,
525    ) -> Result<()> {
526        let uid = self.get_user_id().await?;
527        let year = date.format("%Y").to_string();
528        let mmdd = date.format("%m%d").to_string();
529        let path = format!("users/{}/nutrition/{}", uid, year);
530
531        let entry = json!({
532            "k": format!("{:.0}", calories),
533            "p": protein.map(|v| format!("{:.0}", v)).unwrap_or_default(),
534            "c": carbs.map(|v| format!("{:.0}", v)).unwrap_or_default(),
535            "f": fat.map(|v| format!("{:.0}", v)).unwrap_or_default(),
536            "s": "m",
537            "do": null
538        });
539
540        let fields = to_firestore_fields(&json!({ &mmdd: entry }));
541
542        let field_mask = format!("`{}`", mmdd);
543        self.firestore
544            .patch_document(&path, fields, &[&field_mask])
545            .await?;
546
547        Ok(())
548    }
549
550    /// Search the food database using Typesense.
551    ///
552    /// Searches both `common_foods` and `branded_foods` collections.
553    /// No authentication required — uses the Typesense API key directly.
554    pub async fn search_foods(&self, query: &str) -> Result<Vec<SearchFoodResult>> {
555        let client = Client::new();
556        let url = format!("{}/multi_search", TYPESENSE_HOST);
557
558        let body = json!({
559            "searches": [
560                {
561                    "collection": "common_foods",
562                    "q": query,
563                    "query_by": "foodDesc",
564                    "per_page": 10
565                },
566                {
567                    "collection": "branded_foods",
568                    "q": query,
569                    "query_by": "foodDesc,brandName",
570                    "per_page": 10
571                }
572            ]
573        });
574
575        let resp = client
576            .post(&url)
577            .header("x-typesense-api-key", TYPESENSE_API_KEY)
578            .json(&body)
579            .send()
580            .await?;
581
582        if !resp.status().is_success() {
583            let status = resp.status();
584            let text = resp.text().await.unwrap_or_default();
585            return Err(anyhow!("Typesense search failed: {} - {}", status, text));
586        }
587
588        let data: Value = resp.json().await?;
589        let mut results = Vec::new();
590
591        if let Some(searches) = data.get("results").and_then(|v| v.as_array()) {
592            for (idx, search) in searches.iter().enumerate() {
593                let branded = idx == 1;
594                if let Some(hits) = search.get("hits").and_then(|v| v.as_array()) {
595                    for hit in hits {
596                        if let Some(doc) = hit.get("document") {
597                            if let Some(result) = parse_typesense_hit(doc, branded) {
598                                results.push(result);
599                            }
600                        }
601                    }
602                }
603            }
604        }
605
606        Ok(results)
607    }
608
609    /// Log a food entry from a search result.
610    ///
611    /// `serving` specifies which serving option to use (from `food.servings` or `food.default_serving`).
612    /// `quantity` is how many of that serving (e.g. 1.0 for one serving).
613    ///
614    /// After logging, call [`sync_day`](Self::sync_day) to update the app's daily summary.
615    pub async fn log_searched_food(
616        &mut self,
617        logged_at: DateTime<Local>,
618        food: &SearchFoodResult,
619        serving: &FoodServing,
620        quantity: f64,
621    ) -> Result<()> {
622        let ts = logged_at.timestamp_millis();
623        let entry_id = format!("{}", ts * 1000);
624        let ua_id = format!("{}", ts * 1000 + 1);
625        let hour = logged_at.hour().to_string();
626        let minute = logged_at.minute().to_string();
627
628        // Serving gram weight (this becomes the "g" field — the base for macro values)
629        let serving_grams = serving.gram_weight;
630        // Scale factor from per-100g to per-serving
631        let scale = serving_grams / 100.0;
632
633        // Grams per one display unit
634        let unit_weight = serving.gram_weight / serving.amount;
635        // Total display units
636        let total_units = quantity * serving.amount;
637
638        let measurements: Vec<Value> = food
639            .servings
640            .iter()
641            .map(|s| {
642                json!({
643                    "m": s.description,
644                    "q": format!("{:.1}", s.amount),
645                    "w": format!("{}", s.gram_weight)
646                })
647            })
648            .collect();
649
650        let mut entry = json!({
651            "t": food.name,
652            "b": food.brand.as_deref().unwrap_or(""),
653            "c": format!("{}", food.calories_per_100g * scale),
654            "p": format!("{}", food.protein_per_100g * scale),
655            "e": format!("{}", food.carbs_per_100g * scale),
656            "f": format!("{}", food.fat_per_100g * scale),
657            "g": format!("{}", serving_grams),
658            "w": format!("{}", unit_weight),
659            "y": format!("{}", total_units),
660            "q": format!("{}", serving.amount),
661            "s": serving.description,
662            "u": serving.description,
663            "h": hour,
664            "mi": minute,
665            "k": "t",
666            "id": food.food_id,
667            "ca": &entry_id,
668            "ua": &ua_id,
669            "ef": false,
670            "d": false,
671            "o": false,
672            "fav": false,
673            "x": food.image_id.as_deref().unwrap_or("13"),
674            "m": measurements
675        });
676
677        // Copy all micronutrient values, scaled to serving size
678        if let Some(obj) = entry.as_object_mut() {
679            for (code, val_per_100g) in &food.nutrients_per_100g {
680                // Skip the main macro codes — already handled above
681                if matches!(code.as_str(), "203" | "204" | "205" | "208") {
682                    continue;
683                }
684                obj.insert(code.clone(), json!(format!("{}", val_per_100g * scale)));
685            }
686        }
687
688        self.write_food_entry(logged_at, entry).await
689    }
690
691    /// Delete a food entry by removing it from the document.
692    ///
693    /// After deleting, call [`sync_day`](Self::sync_day) to update the app's daily summary.
694    pub async fn delete_food_entry(&mut self, date: NaiveDate, entry_id: &str) -> Result<()> {
695        let uid = self.get_user_id().await?;
696        let date_str = date.format("%Y-%m-%d").to_string();
697        let path = format!("users/{}/food/{}", uid, date_str);
698
699        // Hard delete: include field in mask but not in body → Firestore removes it
700        let fields = serde_json::Map::new();
701        let field_mask = format!("`{}`", entry_id);
702        self.firestore
703            .patch_document(&path, fields, &[&field_mask])
704            .await?;
705
706        Ok(())
707    }
708
709    /// Sync the daily micro-nutrition summary for a given date.
710    ///
711    /// Reads all food entries, filters out deleted ones, sums macros and
712    /// micronutrients, and writes the totals to `micro/{year}`. The app's
713    /// daily summary reads from this collection.
714    pub async fn sync_day(&mut self, date: NaiveDate) -> Result<()> {
715        let uid = self.get_user_id().await?;
716        let entries = self.get_food_log(date).await?;
717
718        let mut total_k = 0.0;
719        let mut total_p = 0.0;
720        let mut total_c = 0.0;
721        let mut total_f = 0.0;
722        let mut micros: HashMap<String, f64> = HashMap::new();
723
724        for entry in &entries {
725            if entry.deleted == Some(true) {
726                continue;
727            }
728            total_k += entry.calories().unwrap_or(0.0);
729            total_p += entry.protein().unwrap_or(0.0);
730            total_c += entry.carbs().unwrap_or(0.0);
731            total_f += entry.fat().unwrap_or(0.0);
732        }
733
734        // Re-read raw document to get micronutrient fields
735        let date_str = date.format("%Y-%m-%d").to_string();
736        let food_path = format!("users/{}/food/{}", uid, date_str);
737        if let Ok(raw) = self.get_raw_document(&food_path).await {
738            if let Some(map) = raw.as_object() {
739                for (key, val) in map {
740                    if key.starts_with('_') {
741                        continue;
742                    }
743                    if let Some(obj) = val.as_object() {
744                        // Skip deleted entries
745                        if obj.get("d").and_then(|v| v.as_bool()) == Some(true) {
746                            continue;
747                        }
748                        let multiplier = Self::compute_multiplier(obj);
749                        for (field, fval) in obj {
750                            if !field.chars().all(|c| c.is_ascii_digit()) {
751                                continue;
752                            }
753                            // Skip main macro codes already handled
754                            if matches!(field.as_str(), "208" | "203" | "204" | "205") {
755                                continue;
756                            }
757                            if let Some(v) = fval
758                                .as_f64()
759                                .or_else(|| fval.as_str().and_then(|s| s.parse().ok()))
760                            {
761                                let scaled = v * multiplier;
762                                *micros.entry(field.clone()).or_default() += scaled;
763                            }
764                        }
765                    }
766                }
767            }
768        }
769
770        // All micro nutrient codes the app expects
771        let all_codes = [
772            "209", "221", "255", "262", "269", "291", "301", "303", "304", "305", "306", "307",
773            "309", "312", "315", "317", "320", "323", "328", "401", "404", "405", "406", "410",
774            "415", "417", "418", "421", "430", "501", "502", "503", "504", "505", "506", "507",
775            "508", "509", "510", "512", "539", "601", "606", "621", "629", "645", "646", "693",
776            "851", "901", "902",
777        ];
778
779        let mut entry = serde_json::Map::new();
780        entry.insert("k".to_string(), json!(format!("{}", total_k)));
781        entry.insert("p".to_string(), json!(format!("{}", total_p)));
782        entry.insert("c".to_string(), json!(format!("{}", total_c)));
783        entry.insert("f".to_string(), json!(format!("{}", total_f)));
784
785        for code in &all_codes {
786            let code_str = code.to_string();
787            if let Some(v) = micros.get(&code_str) {
788                entry.insert(code_str, json!(format!("{}", v)));
789            } else {
790                entry.insert(code_str, Value::Null);
791            }
792        }
793
794        let year = date.format("%Y").to_string();
795        let mmdd = date.format("%m%d").to_string();
796        let path = format!("users/{}/micro/{}", uid, year);
797
798        let fields = to_firestore_fields(&json!({ &mmdd: Value::Object(entry) }));
799        let field_mask = format!("`{}`", mmdd);
800        self.firestore
801            .patch_document(&path, fields, &[&field_mask])
802            .await?;
803
804        Ok(())
805    }
806
807    /// Compute the multiplier for a raw food entry object.
808    fn compute_multiplier(obj: &serde_json::Map<String, Value>) -> f64 {
809        let parse = |k: &str| -> Option<f64> {
810            obj.get(k).and_then(|v| {
811                v.as_f64()
812                    .or_else(|| v.as_str().and_then(|s| s.parse().ok()))
813            })
814        };
815        match (parse("g"), parse("y"), parse("w")) {
816            (Some(g), Some(y), Some(w)) if g > 0.0 => (y * w) / g,
817            _ => 1.0,
818        }
819    }
820}
821
822/// Parse a Typesense document hit into a SearchFoodResult.
823fn parse_typesense_hit(doc: &Value, branded: bool) -> Option<SearchFoodResult> {
824    let food_id = doc.get("id").and_then(|v| v.as_str())?.to_string();
825    let name = doc.get("foodDesc").and_then(|v| v.as_str())?.to_string();
826
827    let brand = doc
828        .get("brandName")
829        .and_then(|v| v.as_str())
830        .filter(|s| !s.is_empty())
831        .map(String::from);
832
833    let nutrient = |code: &str| -> f64 {
834        doc.get(code)
835            .and_then(|v| {
836                v.as_f64()
837                    .or_else(|| v.as_str().and_then(|s| s.parse().ok()))
838            })
839            .unwrap_or(0.0)
840    };
841
842    let calories_per_100g = nutrient("208");
843    let protein_per_100g = nutrient("203");
844    let fat_per_100g = nutrient("204");
845    let carbs_per_100g = nutrient("205");
846
847    let default_serving = doc.get("dfSrv").and_then(|ds| {
848        let desc = ds.get("msreDesc").and_then(|v| v.as_str())?.to_string();
849        let amount = ds.get("amount").and_then(|v| {
850            v.as_f64()
851                .or_else(|| v.as_str().and_then(|s| s.parse().ok()))
852        })?;
853        let gram_weight = ds.get("gmWgt").and_then(|v| {
854            v.as_f64()
855                .or_else(|| v.as_str().and_then(|s| s.parse().ok()))
856        })?;
857        Some(FoodServing {
858            description: desc,
859            amount,
860            gram_weight,
861        })
862    });
863
864    let servings = doc
865        .get("weights")
866        .and_then(|v| v.as_array())
867        .map(|arr| {
868            arr.iter()
869                .filter_map(|w| {
870                    let desc = w.get("msreDesc").and_then(|v| v.as_str())?.to_string();
871                    let amount = w.get("amount").and_then(|v| {
872                        v.as_f64()
873                            .or_else(|| v.as_str().and_then(|s| s.parse().ok()))
874                    })?;
875                    let gram_weight = w.get("gmWgt").and_then(|v| {
876                        v.as_f64()
877                            .or_else(|| v.as_str().and_then(|s| s.parse().ok()))
878                    })?;
879                    Some(FoodServing {
880                        description: desc,
881                        amount,
882                        gram_weight,
883                    })
884                })
885                .collect()
886        })
887        .unwrap_or_default();
888
889    let image_id = doc
890        .get("imageId")
891        .and_then(|v| {
892            v.as_str()
893                .map(String::from)
894                .or_else(|| v.as_i64().map(|n| n.to_string()))
895        })
896        .filter(|s| !s.is_empty());
897
898    // Collect all numeric-keyed nutrient values (USDA nutrient codes)
899    let mut nutrients_per_100g = HashMap::new();
900    if let Some(obj) = doc.as_object() {
901        for (key, val) in obj {
902            if key.chars().all(|c| c.is_ascii_digit()) {
903                if let Some(v) = val
904                    .as_f64()
905                    .or_else(|| val.as_str().and_then(|s| s.parse().ok()))
906                {
907                    nutrients_per_100g.insert(key.clone(), v);
908                }
909            }
910        }
911    }
912
913    let source = doc
914        .get("source")
915        .and_then(|v| v.as_str())
916        .filter(|s| !s.is_empty())
917        .map(String::from);
918
919    Some(SearchFoodResult {
920        food_id,
921        name,
922        brand,
923        calories_per_100g,
924        protein_per_100g,
925        fat_per_100g,
926        carbs_per_100g,
927        default_serving,
928        servings,
929        image_id,
930        nutrients_per_100g,
931        source,
932        branded,
933    })
934}