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 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 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 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 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 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 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 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 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 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 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 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 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 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 pub async fn log_food(
402 &mut self,
403 logged_at: DateTime<Local>,
404 name: &str,
405 calories: f64,
406 protein: f64,
407 carbs: f64,
408 fat: f64,
409 ) -> Result<()> {
410 let uid = self.get_user_id().await?;
411 let date_str = logged_at.format("%Y-%m-%d").to_string();
412 let path = format!("users/{}/food/{}", uid, date_str);
413
414 let ts = logged_at.timestamp_millis();
415 let entry_id = format!("{}", ts * 1000);
416 let food_id = format!("{}", ts * 1000 + 10);
417
418 let hour = logged_at.hour().to_string();
419 let minute = logged_at.minute().to_string();
420
421 let entry = json!({
422 "t": name,
423 "b": "Quick Add",
424 "c": format!("{:.1}", calories),
425 "p": format!("{:.1}", protein),
426 "e": format!("{:.1}", carbs),
427 "f": format!("{:.1}", fat),
428 "w": "100.0",
429 "g": "100.0",
430 "q": "1.0",
431 "y": "1.0",
432 "s": "serving",
433 "u": "serving",
434 "h": hour,
435 "mi": minute,
436 "k": "n",
437 "id": food_id,
438 "ca": &entry_id,
439 "ua": &entry_id,
440 "ef": Value::Null,
441 "d": false,
442 "x": "13",
443 "m": [{"m": "serving", "q": "1.0", "w": "100.0"}]
444 });
445
446 let fields = to_firestore_fields(&json!({ &entry_id: entry }));
447
448 let field_mask = format!("`{}`", entry_id);
450 self.firestore
451 .patch_document(&path, fields, &[&field_mask])
452 .await?;
453
454 Ok(())
455 }
456
457 pub async fn log_weight(
460 &mut self,
461 date: NaiveDate,
462 weight_kg: f64,
463 body_fat: Option<f64>,
464 ) -> Result<()> {
465 let uid = self.get_user_id().await?;
466 let year = date.format("%Y").to_string();
467 let mmdd = date.format("%m%d").to_string();
468 let path = format!("users/{}/scale/{}", uid, year);
469
470 let mut entry = json!({
471 "w": weight_kg,
472 "s": "m",
473 "do": null
474 });
475 if let Some(bf) = body_fat {
476 entry["f"] = json!(bf);
477 } else {
478 entry["f"] = Value::Null;
479 }
480
481 let fields = to_firestore_fields(&json!({ &mmdd: entry }));
482
483 let field_mask = format!("`{}`", mmdd);
484 self.firestore
485 .patch_document(&path, fields, &[&field_mask])
486 .await?;
487
488 Ok(())
489 }
490
491 pub async fn delete_weight_entry(&mut self, date: NaiveDate) -> Result<()> {
495 let uid = self.get_user_id().await?;
496 let year = date.format("%Y").to_string();
497 let mmdd = date.format("%m%d").to_string();
498 let path = format!("users/{}/scale/{}", uid, year);
499
500 let fields = serde_json::Map::new();
501 let field_mask = format!("`{}`", mmdd);
502 self.firestore
503 .patch_document(&path, fields, &[&field_mask])
504 .await?;
505
506 Ok(())
507 }
508
509 pub async fn log_nutrition(
516 &mut self,
517 date: NaiveDate,
518 calories: f64,
519 protein: Option<f64>,
520 carbs: Option<f64>,
521 fat: Option<f64>,
522 ) -> Result<()> {
523 let uid = self.get_user_id().await?;
524 let year = date.format("%Y").to_string();
525 let mmdd = date.format("%m%d").to_string();
526 let path = format!("users/{}/nutrition/{}", uid, year);
527
528 let entry = json!({
529 "k": format!("{:.0}", calories),
530 "p": protein.map(|v| format!("{:.0}", v)).unwrap_or_default(),
531 "c": carbs.map(|v| format!("{:.0}", v)).unwrap_or_default(),
532 "f": fat.map(|v| format!("{:.0}", v)).unwrap_or_default(),
533 "s": "m",
534 "do": null
535 });
536
537 let fields = to_firestore_fields(&json!({ &mmdd: entry }));
538
539 let field_mask = format!("`{}`", mmdd);
540 self.firestore
541 .patch_document(&path, fields, &[&field_mask])
542 .await?;
543
544 Ok(())
545 }
546
547 pub async fn search_foods(&self, query: &str) -> Result<Vec<SearchFoodResult>> {
552 let client = Client::new();
553 let url = format!("{}/multi_search", TYPESENSE_HOST);
554
555 let body = json!({
556 "searches": [
557 {
558 "collection": "common_foods",
559 "q": query,
560 "query_by": "foodDesc",
561 "per_page": 10
562 },
563 {
564 "collection": "branded_foods",
565 "q": query,
566 "query_by": "foodDesc,brandName",
567 "per_page": 10
568 }
569 ]
570 });
571
572 let resp = client
573 .post(&url)
574 .header("x-typesense-api-key", TYPESENSE_API_KEY)
575 .json(&body)
576 .send()
577 .await?;
578
579 if !resp.status().is_success() {
580 let status = resp.status();
581 let text = resp.text().await.unwrap_or_default();
582 return Err(anyhow!("Typesense search failed: {} - {}", status, text));
583 }
584
585 let data: Value = resp.json().await?;
586 let mut results = Vec::new();
587
588 if let Some(searches) = data.get("results").and_then(|v| v.as_array()) {
589 for (idx, search) in searches.iter().enumerate() {
590 let branded = idx == 1;
591 if let Some(hits) = search.get("hits").and_then(|v| v.as_array()) {
592 for hit in hits {
593 if let Some(doc) = hit.get("document") {
594 if let Some(result) = parse_typesense_hit(doc, branded) {
595 results.push(result);
596 }
597 }
598 }
599 }
600 }
601 }
602
603 Ok(results)
604 }
605
606 pub async fn log_searched_food(
611 &mut self,
612 logged_at: DateTime<Local>,
613 food: &SearchFoodResult,
614 serving: &FoodServing,
615 quantity: f64,
616 ) -> Result<()> {
617 let uid = self.get_user_id().await?;
618 let date_str = logged_at.format("%Y-%m-%d").to_string();
619 let path = format!("users/{}/food/{}", uid, date_str);
620
621 let ts = logged_at.timestamp_millis();
622 let entry_id = format!("{}", ts * 1000);
623
624 let hour = logged_at.hour().to_string();
625 let minute = logged_at.minute().to_string();
626
627 let serving_grams = serving.gram_weight;
629 let scale = serving_grams / 100.0;
631
632 let unit_weight = serving.gram_weight / serving.amount;
634 let total_units = quantity * serving.amount;
636
637 let measurements: Vec<Value> = food
638 .servings
639 .iter()
640 .map(|s| {
641 json!({
642 "m": s.description,
643 "q": format!("{:.1}", s.amount),
644 "w": format!("{}", s.gram_weight)
645 })
646 })
647 .collect();
648
649 let mut entry = json!({
650 "t": food.name,
651 "b": food.brand.as_deref().unwrap_or(""),
652 "c": format!("{}", food.calories_per_100g * scale),
653 "p": format!("{}", food.protein_per_100g * scale),
654 "e": format!("{}", food.carbs_per_100g * scale),
655 "f": format!("{}", food.fat_per_100g * scale),
656 "g": format!("{}", serving_grams),
657 "w": format!("{}", unit_weight),
658 "y": format!("{}", total_units),
659 "q": format!("{}", serving.amount),
660 "s": serving.description,
661 "u": serving.description,
662 "h": hour,
663 "mi": minute,
664 "k": "t",
665 "id": food.food_id,
666 "ca": &entry_id,
667 "ua": &entry_id,
668 "ef": Value::Null,
669 "d": false,
670 "o": false,
671 "fav": false,
672 "x": food.image_id.as_deref().unwrap_or("13"),
673 "m": measurements
674 });
675
676 if let Some(obj) = entry.as_object_mut() {
678 for (code, val_per_100g) in &food.nutrients_per_100g {
679 if matches!(code.as_str(), "203" | "204" | "205" | "208") {
681 continue;
682 }
683 obj.insert(code.clone(), json!(format!("{}", val_per_100g * scale)));
684 }
685 }
686
687 let fields = to_firestore_fields(&json!({ &entry_id: entry }));
688
689 let field_mask = format!("`{}`", entry_id);
690 self.firestore
691 .patch_document(&path, fields, &[&field_mask])
692 .await?;
693
694 Ok(())
695 }
696
697 pub async fn delete_food_entry(&mut self, date: NaiveDate, entry_id: &str) -> Result<()> {
701 let uid = self.get_user_id().await?;
702 let date_str = date.format("%Y-%m-%d").to_string();
703 let path = format!("users/{}/food/{}", uid, date_str);
704
705 let fields = to_firestore_fields(&json!({
706 entry_id: { "d": true }
707 }));
708
709 let field_mask = format!("`{}`.d", entry_id);
710 self.firestore
711 .patch_document(&path, fields, &[&field_mask])
712 .await?;
713
714 Ok(())
715 }
716}
717
718fn parse_typesense_hit(doc: &Value, branded: bool) -> Option<SearchFoodResult> {
720 let food_id = doc.get("id").and_then(|v| v.as_str())?.to_string();
721 let name = doc.get("foodDesc").and_then(|v| v.as_str())?.to_string();
722
723 let brand = doc
724 .get("brandName")
725 .and_then(|v| v.as_str())
726 .filter(|s| !s.is_empty())
727 .map(String::from);
728
729 let nutrient = |code: &str| -> f64 {
730 doc.get(code)
731 .and_then(|v| {
732 v.as_f64()
733 .or_else(|| v.as_str().and_then(|s| s.parse().ok()))
734 })
735 .unwrap_or(0.0)
736 };
737
738 let calories_per_100g = nutrient("208");
739 let protein_per_100g = nutrient("203");
740 let fat_per_100g = nutrient("204");
741 let carbs_per_100g = nutrient("205");
742
743 let default_serving = doc.get("dfSrv").and_then(|ds| {
744 let desc = ds.get("msreDesc").and_then(|v| v.as_str())?.to_string();
745 let amount = ds.get("amount").and_then(|v| {
746 v.as_f64()
747 .or_else(|| v.as_str().and_then(|s| s.parse().ok()))
748 })?;
749 let gram_weight = ds.get("gmWgt").and_then(|v| {
750 v.as_f64()
751 .or_else(|| v.as_str().and_then(|s| s.parse().ok()))
752 })?;
753 Some(FoodServing {
754 description: desc,
755 amount,
756 gram_weight,
757 })
758 });
759
760 let servings = doc
761 .get("weights")
762 .and_then(|v| v.as_array())
763 .map(|arr| {
764 arr.iter()
765 .filter_map(|w| {
766 let desc = w.get("msreDesc").and_then(|v| v.as_str())?.to_string();
767 let amount = w.get("amount").and_then(|v| {
768 v.as_f64()
769 .or_else(|| v.as_str().and_then(|s| s.parse().ok()))
770 })?;
771 let gram_weight = w.get("gmWgt").and_then(|v| {
772 v.as_f64()
773 .or_else(|| v.as_str().and_then(|s| s.parse().ok()))
774 })?;
775 Some(FoodServing {
776 description: desc,
777 amount,
778 gram_weight,
779 })
780 })
781 .collect()
782 })
783 .unwrap_or_default();
784
785 let image_id = doc
786 .get("imageId")
787 .and_then(|v| {
788 v.as_str()
789 .map(String::from)
790 .or_else(|| v.as_i64().map(|n| n.to_string()))
791 })
792 .filter(|s| !s.is_empty());
793
794 let mut nutrients_per_100g = HashMap::new();
796 if let Some(obj) = doc.as_object() {
797 for (key, val) in obj {
798 if key.chars().all(|c| c.is_ascii_digit()) {
799 if let Some(v) = val
800 .as_f64()
801 .or_else(|| val.as_str().and_then(|s| s.parse().ok()))
802 {
803 nutrients_per_100g.insert(key.clone(), v);
804 }
805 }
806 }
807 }
808
809 let source = doc
810 .get("source")
811 .and_then(|v| v.as_str())
812 .filter(|s| !s.is_empty())
813 .map(String::from);
814
815 Some(SearchFoodResult {
816 food_id,
817 name,
818 brand,
819 calories_per_100g,
820 protein_per_100g,
821 fat_per_100g,
822 carbs_per_100g,
823 default_serving,
824 servings,
825 image_id,
826 nutrients_per_100g,
827 source,
828 branded,
829 })
830}