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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 let field_mask = format!("`{}`", entry_id);
441 self.firestore
442 .patch_document(&path, fields, &[&field_mask])
443 .await?;
444
445 Ok(())
446 }
447
448 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 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}