From 0e8f430378b7797adf4fdad35fee3a383ba15a61 Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Wed, 11 Feb 2026 06:41:08 +0000 Subject: [PATCH 1/7] Add backend for usage stats and backup, integrate with Flutter app. - Created backend/ directory with FastAPI server. - Added MongoDB integration and Pydantic models. - Configured Railway deployment with railway.toml. - Added http dependency to Flutter app. - Implemented ApiService for reporting usage and backing up data. - Added exportAllData method to WorkoutProvider. - Integrated usage reporting in app initialization. - Added Settings screen with Backup functionality. Co-authored-by: Devasy23 <110348311+Devasy23@users.noreply.github.com> --- backend/__init__.py | 0 backend/__pycache__/database.cpython-312.pyc | Bin 0 -> 437 bytes backend/__pycache__/main.cpython-312.pyc | Bin 0 -> 2014 bytes backend/__pycache__/models.cpython-312.pyc | Bin 0 -> 1300 bytes backend/database.py | 10 ++ backend/main.py | 31 ++++ backend/models.py | 19 ++ backend/requirements.txt | 5 + railway.toml | 6 + workout-logger/lib/main.dart | 9 + workout-logger/lib/screens/home_screen.dart | 38 ++-- .../lib/screens/settings_screen.dart | 165 ++++++++++++++++++ workout-logger/lib/services/api_service.dart | 58 ++++++ .../lib/services/workout_provider.dart | 6 + workout-logger/pubspec.lock | 16 +- workout-logger/pubspec.yaml | 1 + 16 files changed, 349 insertions(+), 15 deletions(-) create mode 100644 backend/__init__.py create mode 100644 backend/__pycache__/database.cpython-312.pyc create mode 100644 backend/__pycache__/main.cpython-312.pyc create mode 100644 backend/__pycache__/models.cpython-312.pyc create mode 100644 backend/database.py create mode 100644 backend/main.py create mode 100644 backend/models.py create mode 100644 backend/requirements.txt create mode 100644 railway.toml create mode 100644 workout-logger/lib/screens/settings_screen.dart create mode 100644 workout-logger/lib/services/api_service.dart diff --git a/backend/__init__.py b/backend/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/__pycache__/database.cpython-312.pyc b/backend/__pycache__/database.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..ef0cbffb43de09b29fdd882fc4240817ca6649c2 GIT binary patch literal 437 zcmX@j%ge<81nZT0GG7Df#~=<2Fhd!iyMT=83@HpLj5!QZAet$MF_$TdiIE|dA&U*B z96@JsBG@oi3iE142s?^3m9@+Ja6%p=NDz?mzKomQ%cfGhR+~({1Q$|OwLZtOVNi(>J?Pp;;_lhPbtkwwJQ<;ssZ`D*a1j< zU}j`wyv@LQm%-=(pFn?BXI6h+XWoq1%Y51m+z*-gZt}`6;J(bO)8KZ4lm9xW%tcO_ W3$nVGIrSQy9&qwDIu-E&4FLeBLg{UG$2w$v?-zHku_F8Xu zNgEQBCKUv!65>KQaNxj!DsX}W*POX*lt`(biYTH7atjR-wDiEtZoCPI!au;uJ3H^q z$D23voAGy`^?@JFspy}>YYCApnGIfncE0(a7X^Cmql-MTq5F0(VNm?q{qz>7n z!RFY3|2D_cu}#M{5}%N{k|`KFs8o=IHg39J>B1`_^YX=u7hbwtwOXF-GV{yX*(0kP|bJX?2qMD z`8`#IxR}CJmiSbfB&W#rzqW-H=2Y^XI#js>3XiV3udvb)mU_ zwQ9NUTsAC1KFo1G%6Mp%h^&Ox04*rf&aZGxLE}0E$pOS#Az zi+dHPX^CW9aUr`NFUTCTC(@B_g+auES}qnVpU>a#oB{|~O;&7}cJt}h716P;=;QhP zQBcFWyFQSg$?uEgj+R`UyJhr!AqT^+Z5b1RKCvsQ>gdhxzU}V8jqbtC?vpF&TgH(O zYai6Mje!kg;Is3a#?ZDt6zD@Yj}HVx;~Sl41O4o-Op?#syOYk|B~%>^U4HYzxAg1r zE#pj}pZQ}~hV2J?&%wz{+0rOoeYTV%>q%PbQr1&C@<%CbuXj=8bIH<(yx!kk8kX0G zRpduxkOHFdhJH&Zc=i7Q{SZL&G^Ck#Ai<8{M}W+AM)Ls>TD9!AEyf=~Q5MKyO!6FT z2rWvp%X?6#7YPn9z~5siPDSDhbE>=#HrfE>Cs3MhOQA%30CtX|iVNi5F_$~GTKTB5 znHyY5{S|TNHjU!8UJUeNh`7>5=UAYRA>xMq6LGvBL!!kheBuX(*)qI)7+Ey@aLn0s zl7u5cp5XYuFxUU4!!J3$mw(lnf&R&32BE$3ulvoW-JH2W_#k?Lmqb@rxZ`;IB&@O5 zTy!7a`F2Mx=s6XnPY25BdoK3=lB&^(f+4hN81QDx=Gep0R7iwV!R0tSIrgj25$!md z!r38YvjrW{a$q9hF=38{JYz*ChcWRdQ4p3x9|eDY-FluEVGsWU+*u&dkd*#Fo`T=^ zr1!@}X4zhpZ&T&{-mASUg-x2>m2^7#N$oCy&z?-AT)45DC6f9#opb-F({r_FrEB@b OFA}9AXm5mE7}%e;GrA)H literal 0 HcmV?d00001 diff --git a/backend/__pycache__/models.cpython-312.pyc b/backend/__pycache__/models.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..bdbf69958416c1b4f20905438f33daaa4e5a58ef GIT binary patch literal 1300 zcmZvbzi$*r6vt<8XMgzco$>kS34?@#EEX)IL@6jNOV~sp#h3z7u+6f*eVE1j<1#a6 z`x=Bq(ovi%FG>+dDI&!GK|>SkC{80G3Mx7zn}&)vyJs74M!N65`M%k?_vSPEx>6|< z9Af%ec&|jrdo?*6MnBlUkHHQJNT5X|)?7_faVaXrx~s?3rLo}}vFVz*uSZsFxt2ys z#|G*TaU8`n9Aj z?b}TerimZnN;~ich%kmmv3sHYzasmqINu>aTrD7O=`H~sXrRrbn@6QQGSJhHJgTA+ z0rOF{*z9Y4*`K&JnD~~V*0xQ@lI6SHUxPcs7hG0FD*VXXOxb$c!uil9zkpTzT?qXYvib#3m14|Aq)f zSB?EwDBlwS1I!1dI%d=ltYbnaG6gZDcp`H|@I)pjs(7Es z%V6VLE2oavRhAd=^jgj9zVM}u{J1)DX*0wgCcvd7e6|L71qS05Z$xkdGflh@jTRSa z{L3MBS%xhI8}|WOQ$$)#k3j>%4G276tT?CGX{?D2D^Ue9b)HNCk~$ZRnQA(!L}6y* zN~V=eD9Mqiqh)6fWe@}2MnSgKTIc6X&+cUQ_QGCux=W}3&#DY}E_8Ud)Y;me+pC=K z((}FAct>PQyCd1n?Z6Op4o5ck406gpcSVq?Pfdh6A?C;`gWx$+RZRoJ8rY+(865NVSY*TZochM+VjQ7 xvA||tS`r#IEDZK literal 0 HcmV?d00001 diff --git a/backend/database.py b/backend/database.py new file mode 100644 index 0000000..87294e4 --- /dev/null +++ b/backend/database.py @@ -0,0 +1,10 @@ +import os +from motor.motor_asyncio import AsyncIOMotorClient + +MONGODB_URI = os.environ.get("MONGODB_URI") + +if MONGODB_URI: + client = AsyncIOMotorClient(MONGODB_URI) + db = client.get_database("workout_logger") +else: + db = None diff --git a/backend/main.py b/backend/main.py new file mode 100644 index 0000000..aeb7a5d --- /dev/null +++ b/backend/main.py @@ -0,0 +1,31 @@ +from fastapi import FastAPI, HTTPException +from .models import UsageStats, BackupData +from .database import db + +app = FastAPI() + +@app.post("/report") +async def report_usage(stats: UsageStats): + if db is None: + raise HTTPException(status_code=503, detail="Database not configured") + try: + report_data = stats.model_dump() + await db.reports.insert_one(report_data) + return {"status": "success", "message": "Usage stats reported"} + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + +@app.post("/backup") +async def backup_data(data: BackupData): + if db is None: + raise HTTPException(status_code=503, detail="Database not configured") + try: + backup_doc = data.model_dump() + await db.backups.insert_one(backup_doc) + return {"status": "success", "message": "Backup received"} + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + +@app.get("/") +async def root(): + return {"message": "Workout Logger Backend Running"} diff --git a/backend/models.py b/backend/models.py new file mode 100644 index 0000000..606a3ed --- /dev/null +++ b/backend/models.py @@ -0,0 +1,19 @@ +from pydantic import BaseModel +from typing import List, Dict, Any, Optional +from datetime import datetime + +class UsageStats(BaseModel): + total_workouts: int + weekly_workouts: int + weekly_volume: float + exercises_this_week: int + report_date: datetime = datetime.now() + +class BackupData(BaseModel): + sessions: List[Dict[str, Any]] + routines: List[Dict[str, Any]] + targets: List[Dict[str, Any]] + muscleGroups: List[Dict[str, Any]] + customExercises: List[Dict[str, Any]] + exportDate: str # Since it's ISO string from Dart + backup_received_at: datetime = datetime.now() diff --git a/backend/requirements.txt b/backend/requirements.txt new file mode 100644 index 0000000..144e90f --- /dev/null +++ b/backend/requirements.txt @@ -0,0 +1,5 @@ +fastapi +uvicorn +motor +dnspython +pydantic diff --git a/railway.toml b/railway.toml new file mode 100644 index 0000000..ea09bc6 --- /dev/null +++ b/railway.toml @@ -0,0 +1,6 @@ +[build] +builder = "nixpacks" +buildCommand = "pip install -r backend/requirements.txt" + +[deploy] +startCommand = "uvicorn backend.main:app --host 0.0.0.0 --port $PORT" diff --git a/workout-logger/lib/main.dart b/workout-logger/lib/main.dart index 0df1048..ceb06b4 100644 --- a/workout-logger/lib/main.dart +++ b/workout-logger/lib/main.dart @@ -12,6 +12,7 @@ import 'services/ml_service.dart'; import 'services/interfaces/storage_service_interface.dart'; import 'services/interfaces/ml_service_interface.dart'; import 'services/workout_provider.dart'; +import 'services/api_service.dart'; import 'theme/app_theme.dart'; import 'screens/home_screen.dart'; @@ -93,6 +94,14 @@ class _AppInitializerState extends State { try { final provider = context.read(); await provider.init(); + + // Report usage stats in background + provider.getQuickStats().then((stats) { + ApiService().reportUsage(stats); + }).catchError((e) { + debugPrint('Failed to report usage: $e'); + }); + setState(() => _initialized = true); } catch (e) { setState(() => _error = e.toString()); diff --git a/workout-logger/lib/screens/home_screen.dart b/workout-logger/lib/screens/home_screen.dart index dec4c41..f9ff39f 100644 --- a/workout-logger/lib/screens/home_screen.dart +++ b/workout-logger/lib/screens/home_screen.dart @@ -11,6 +11,7 @@ import 'history_screen.dart'; import 'routines_screen.dart'; import 'analytics_screen.dart'; import 'exercise_library_screen.dart'; +import 'settings_screen.dart'; class HomeScreen extends StatefulWidget { const HomeScreen({super.key}); @@ -134,19 +135,34 @@ class DashboardTab extends StatelessWidget { final now = DateTime.now(); final greeting = now.hour < 12 ? 'Good morning' : (now.hour < 17 ? 'Good afternoon' : 'Good evening'); - return Column( - crossAxisAlignment: CrossAxisAlignment.start, + return Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ - Text( - greeting, - style: Theme.of(context).textTheme.titleMedium?.copyWith( - color: AppTheme.textSecondary, - ), + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + greeting, + style: Theme.of(context).textTheme.titleMedium?.copyWith( + color: AppTheme.textSecondary, + ), + ), + const SizedBox(height: 4), + Text( + 'Ready to crush it? 💪', + style: Theme.of(context).textTheme.headlineMedium, + ), + ], ), - const SizedBox(height: 4), - Text( - 'Ready to crush it? 💪', - style: Theme.of(context).textTheme.headlineMedium, + IconButton( + onPressed: () { + Navigator.push( + context, + MaterialPageRoute(builder: (_) => const SettingsScreen()), + ); + }, + icon: const Icon(Icons.settings_outlined), + color: AppTheme.textPrimary, ), ], ); diff --git a/workout-logger/lib/screens/settings_screen.dart b/workout-logger/lib/screens/settings_screen.dart new file mode 100644 index 0000000..1a550f6 --- /dev/null +++ b/workout-logger/lib/screens/settings_screen.dart @@ -0,0 +1,165 @@ +import 'dart:convert'; +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import '../services/workout_provider.dart'; +import '../services/api_service.dart'; +import '../theme/app_theme.dart'; + +class SettingsScreen extends StatefulWidget { + const SettingsScreen({super.key}); + + @override + State createState() => _SettingsScreenState(); +} + +class _SettingsScreenState extends State { + bool _isBackingUp = false; + + Future _performBackup() async { + setState(() => _isBackingUp = true); + + try { + final provider = context.read(); + final jsonString = await provider.exportAllData(); + final data = jsonDecode(jsonString) as Map; + + final success = await ApiService().backupData(data); + + if (!mounted) return; + + if (success) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Backup successful!'), + backgroundColor: AppTheme.success, + ), + ); + } else { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Backup failed. Please try again.'), + backgroundColor: AppTheme.error, + ), + ); + } + } catch (e) { + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Error: $e'), + backgroundColor: AppTheme.error, + ), + ); + } finally { + if (mounted) { + setState(() => _isBackingUp = false); + } + } + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: const Text('Settings'), + backgroundColor: AppTheme.surfaceColor, + elevation: 0, + ), + body: ListView( + padding: const EdgeInsets.all(16), + children: [ + _buildSectionHeader('Data Management'), + const SizedBox(height: 16), + _buildBackupCard(), + ], + ), + ); + } + + Widget _buildSectionHeader(String title) { + return Text( + title, + style: Theme.of(context).textTheme.titleMedium?.copyWith( + color: AppTheme.primaryColor, + fontWeight: FontWeight.bold, + ), + ); + } + + Widget _buildBackupCard() { + return Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: AppTheme.cardColor, + borderRadius: BorderRadius.circular(12), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Container( + padding: const EdgeInsets.all(10), + decoration: BoxDecoration( + color: AppTheme.primaryColor.withOpacity(0.1), + borderRadius: BorderRadius.circular(8), + ), + child: const Icon( + Icons.cloud_upload_outlined, + color: AppTheme.primaryColor, + ), + ), + const SizedBox(width: 16), + const Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Remote Backup', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.bold, + color: AppTheme.textPrimary, + ), + ), + Text( + 'Securely backup your workout data', + style: TextStyle( + fontSize: 12, + color: AppTheme.textSecondary, + ), + ), + ], + ), + ), + ], + ), + const SizedBox(height: 16), + SizedBox( + width: double.infinity, + child: ElevatedButton( + onPressed: _isBackingUp ? null : _performBackup, + style: ElevatedButton.styleFrom( + backgroundColor: AppTheme.primaryColor, + padding: const EdgeInsets.symmetric(vertical: 12), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + ), + child: _isBackingUp + ? const SizedBox( + height: 20, + width: 20, + child: CircularProgressIndicator( + strokeWidth: 2, + valueColor: AlwaysStoppedAnimation(Colors.white), + ), + ) + : const Text('Backup Now'), + ), + ), + ], + ), + ); + } +} diff --git a/workout-logger/lib/services/api_service.dart b/workout-logger/lib/services/api_service.dart new file mode 100644 index 0000000..71a0277 --- /dev/null +++ b/workout-logger/lib/services/api_service.dart @@ -0,0 +1,58 @@ +import 'dart:convert'; +import 'package:http/http.dart' as http; +import 'package:flutter/foundation.dart'; + +class ApiService { + // TODO: Replace with your Railway URL + static const String _baseUrl = 'http://10.0.2.2:8000'; // For Android Emulator + // static const String _baseUrl = 'http://localhost:8000'; // For iOS Simulator + + final http.Client _client; + + ApiService({http.Client? client}) : _client = client ?? http.Client(); + + Future reportUsage(Map stats) async { + try { + // Map Dart camelCase stats to Python snake_case model + final payload = { + 'total_workouts': stats['totalWorkouts'], + 'weekly_workouts': stats['weeklyWorkouts'], + 'weekly_volume': stats['weeklyVolume'], + 'exercises_this_week': stats['exercisesThisWeek'], + 'report_date': DateTime.now().toIso8601String(), + }; + + final response = await _client.post( + Uri.parse('$_baseUrl/report'), + headers: {'Content-Type': 'application/json'}, + body: jsonEncode(payload), + ); + + if (response.statusCode != 200) { + debugPrint('Failed to report usage: ${response.body}'); + } + } catch (e) { + debugPrint('Error reporting usage: $e'); + } + } + + Future backupData(Map data) async { + try { + final response = await _client.post( + Uri.parse('$_baseUrl/backup'), + headers: {'Content-Type': 'application/json'}, + body: jsonEncode(data), + ); + + if (response.statusCode == 200) { + return true; + } else { + debugPrint('Failed to backup data: ${response.body}'); + return false; + } + } catch (e) { + debugPrint('Error backing up data: $e'); + return false; + } + } +} diff --git a/workout-logger/lib/services/workout_provider.dart b/workout-logger/lib/services/workout_provider.dart index 4929edd..385e438 100644 --- a/workout-logger/lib/services/workout_provider.dart +++ b/workout-logger/lib/services/workout_provider.dart @@ -721,4 +721,10 @@ class WorkoutProvider extends ChangeNotifier { Future> getQuickStats() async { return await _storage.getQuickStats(); } + + // ==================== BACKUP ==================== + + Future exportAllData() async { + return await _storage.exportAllData(); + } } diff --git a/workout-logger/pubspec.lock b/workout-logger/pubspec.lock index 08d743d..07e7c95 100644 --- a/workout-logger/pubspec.lock +++ b/workout-logger/pubspec.lock @@ -283,6 +283,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.1.0" + http: + dependency: "direct main" + description: + name: http + sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412" + url: "https://pub.dev" + source: hosted + version: "1.6.0" http_multi_server: dependency: transitive description: @@ -391,10 +399,10 @@ packages: dependency: transitive description: name: meta - sha256: e3641ec5d63ebf0d9b41bd43201a66e3fc79a65db5f61fc181f04cd27aab950c + sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394" url: "https://pub.dev" source: hosted - version: "1.16.0" + version: "1.17.0" mime: dependency: transitive description: @@ -628,10 +636,10 @@ packages: dependency: transitive description: name: test_api - sha256: "522f00f556e73044315fa4585ec3270f1808a4b186c936e612cab0b565ff1e00" + sha256: ab2726c1a94d3176a45960b6234466ec367179b87dd74f1611adb1f3b5fb9d55 url: "https://pub.dev" source: hosted - version: "0.7.6" + version: "0.7.7" typed_data: dependency: transitive description: diff --git a/workout-logger/pubspec.yaml b/workout-logger/pubspec.yaml index 4b9bfc4..006440f 100644 --- a/workout-logger/pubspec.yaml +++ b/workout-logger/pubspec.yaml @@ -48,6 +48,7 @@ dependencies: # Utilities uuid: ^4.5.1 intl: ^0.19.0 + http: ^1.2.1 dev_dependencies: flutter_test: From cfd4535aee3c97b22530ebcc029b599264ce9ca5 Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Wed, 11 Feb 2026 14:29:35 +0000 Subject: [PATCH 2/7] Add usage stats and backup backend with Flutter integration. - Create backend/ directory with FastAPI and MongoDB logic. - Move requirements.txt to root for Railpack detection. - Configure railway.toml for Railpack deployment. - Implement ApiService in Flutter for reporting and backups. - Update WorkoutProvider to expose exportAllData. - Add SettingsScreen to UI for manual backups. - Fix Pydantic models to use default_factory. - Make API URL configurable via environment variable. Co-authored-by: Devasy23 <110348311+Devasy23@users.noreply.github.com> --- .gitignore | 158 +++---------------- backend/models.py | 6 +- railway.toml | 4 - backend/requirements.txt => requirements.txt | 0 workout-logger/lib/services/api_service.dart | 9 +- 5 files changed, 29 insertions(+), 148 deletions(-) rename backend/requirements.txt => requirements.txt (100%) diff --git a/.gitignore b/.gitignore index 9a5aced..f49ded9 100644 --- a/.gitignore +++ b/.gitignore @@ -1,139 +1,21 @@ -# Logs -logs -*.log -npm-debug.log* -yarn-debug.log* -yarn-error.log* -lerna-debug.log* - -# Diagnostic reports (https://nodejs.org/api/report.html) -report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json - -# Runtime data -pids -*.pid -*.seed -*.pid.lock - -# Directory for instrumented libs generated by jscoverage/JSCover -lib-cov - -# Coverage directory used by tools like istanbul -coverage -*.lcov - -# nyc test coverage -.nyc_output - -# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) -.grunt - -# Bower dependency directory (https://bower.io/) -bower_components - -# node-waf configuration -.lock-wscript - -# Compiled binary addons (https://nodejs.org/api/addons.html) -build/Release - -# Dependency directories -node_modules/ -jspm_packages/ - -# Snowpack dependency directory (https://snowpack.dev/) -web_modules/ - -# TypeScript cache -*.tsbuildinfo - -# Optional npm cache directory -.npm - -# Optional eslint cache -.eslintcache - -# Optional stylelint cache -.stylelintcache - -# Optional REPL history -.node_repl_history - -# Output of 'npm pack' -*.tgz - -# Yarn Integrity file -.yarn-integrity - -# dotenv environment variable files -.env -.env.* -!.env.example - -# parcel-bundler cache (https://parceljs.org/) -.cache -.parcel-cache - -# Next.js build output -.next -out - -# Nuxt.js build / generate output -.nuxt -dist - -# Gatsby files -.cache/ -# Comment in the public line in if your project uses Gatsby and not Next.js -# https://nextjs.org/blog/next-9-1#public-directory-support -# public - -# vuepress build output -.vuepress/dist - -# vuepress v2.x temp and cache directory -.temp +__pycache__/ +*.pyc +*.pyo +*.pyd +.Python +env/ +venv/ +pip-log.txt +pip-delete-this-directory.txt +.tox/ +.coverage +.coverage.* .cache - -# Sveltekit cache directory -.svelte-kit/ - -# vitepress build output -**/.vitepress/dist - -# vitepress cache directory -**/.vitepress/cache - -# Docusaurus cache and generated files -.docusaurus - -# Serverless directories -.serverless/ - -# FuseBox cache -.fusebox/ - -# DynamoDB Local files -.dynamodb/ - -# Firebase cache directory -.firebase/ - -# TernJS port file -.tern-port - -# Stores VSCode versions used for testing VSCode extensions -.vscode-test - -# yarn v3 -.pnp.* -.yarn/* -!.yarn/patches -!.yarn/plugins -!.yarn/releases -!.yarn/sdks -!.yarn/versions - -# Vite logs files -vite.config.js.timestamp-* -vite.config.ts.timestamp-* +nosetests.xml +coverage.xml +*.cover +*.log +.git +.mypy_cache +.pytest_cache +.hypothesise diff --git a/backend/models.py b/backend/models.py index 606a3ed..490d696 100644 --- a/backend/models.py +++ b/backend/models.py @@ -1,4 +1,4 @@ -from pydantic import BaseModel +from pydantic import BaseModel, Field from typing import List, Dict, Any, Optional from datetime import datetime @@ -7,7 +7,7 @@ class UsageStats(BaseModel): weekly_workouts: int weekly_volume: float exercises_this_week: int - report_date: datetime = datetime.now() + report_date: datetime = Field(default_factory=datetime.now) class BackupData(BaseModel): sessions: List[Dict[str, Any]] @@ -16,4 +16,4 @@ class BackupData(BaseModel): muscleGroups: List[Dict[str, Any]] customExercises: List[Dict[str, Any]] exportDate: str # Since it's ISO string from Dart - backup_received_at: datetime = datetime.now() + backup_received_at: datetime = Field(default_factory=datetime.now) diff --git a/railway.toml b/railway.toml index ea09bc6..b2450ff 100644 --- a/railway.toml +++ b/railway.toml @@ -1,6 +1,2 @@ -[build] -builder = "nixpacks" -buildCommand = "pip install -r backend/requirements.txt" - [deploy] startCommand = "uvicorn backend.main:app --host 0.0.0.0 --port $PORT" diff --git a/backend/requirements.txt b/requirements.txt similarity index 100% rename from backend/requirements.txt rename to requirements.txt diff --git a/workout-logger/lib/services/api_service.dart b/workout-logger/lib/services/api_service.dart index 71a0277..801d6e6 100644 --- a/workout-logger/lib/services/api_service.dart +++ b/workout-logger/lib/services/api_service.dart @@ -3,9 +3,12 @@ import 'package:http/http.dart' as http; import 'package:flutter/foundation.dart'; class ApiService { - // TODO: Replace with your Railway URL - static const String _baseUrl = 'http://10.0.2.2:8000'; // For Android Emulator - // static const String _baseUrl = 'http://localhost:8000'; // For iOS Simulator + // TODO: Update this URL with your Railway deployment URL + // Example: https://repforge-backend-production.up.railway.app + static const String _baseUrl = String.fromEnvironment( + 'API_URL', + defaultValue: 'http://10.0.2.2:8000', // Default to Android Emulator for dev + ); final http.Client _client; From 70f19b4bd57f17b382a9fdea49fba5f1d9a0d7cc Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Wed, 11 Feb 2026 14:45:19 +0000 Subject: [PATCH 3/7] Add usage stats and backup backend with Flutter integration. - Create backend/ directory with FastAPI and MongoDB logic. - Move requirements.txt to root for Railpack detection. - Add main.py to root for Railpack detection. - Remove railway.toml to use default Railpack configuration. - Implement ApiService in Flutter for reporting and backups. - Update WorkoutProvider to expose exportAllData. - Add SettingsScreen to UI for manual backups. - Fix Pydantic models to use default_factory. - Make API URL configurable via environment variable. - Update .gitignore to include Python and Flutter artifacts. Co-authored-by: Devasy23 <110348311+Devasy23@users.noreply.github.com> --- .gitignore | 64 ++++++++++++++++++- backend/__pycache__/database.cpython-312.pyc | Bin 437 -> 0 bytes backend/__pycache__/main.cpython-312.pyc | Bin 2014 -> 0 bytes backend/__pycache__/models.cpython-312.pyc | Bin 1300 -> 0 bytes main.py | 1 + railway.toml | 2 - 6 files changed, 62 insertions(+), 5 deletions(-) delete mode 100644 backend/__pycache__/database.cpython-312.pyc delete mode 100644 backend/__pycache__/main.cpython-312.pyc delete mode 100644 backend/__pycache__/models.cpython-312.pyc create mode 100644 main.py delete mode 100644 railway.toml diff --git a/.gitignore b/.gitignore index f49ded9..455a439 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,62 @@ -__pycache__/ +# Miscellaneous +*.class +*.log +*.lock *.pyc +*.swp +.DS_Store +.atom/ +.buildlog/ +.history +.svn/ + +# Flutter/Dart +.dart_tool/ +.flutter-plugins +.flutter-plugins-dependencies +.packages +.pub-cache/ +.pub/ +/build/ + +# Android +android/app/libs/ +android/app/src/main/assets/ +android/app/src/main/res/ +android/app/src/main/jniLibs/ +android/app/src/main/obj/ +android/app/src/main/symbols/ +android/gradle/ +android/gradlew +android/gradlew.bat +android/local.properties +android/.gradle +android/captures/ +android/gradlew.bat +android/local.properties +.gradle +/android/.gradle/ + +# iOS +ios/Flutter/App.framework +ios/Flutter/Flutter.framework +ios/Flutter/Generated.xcconfig +ios/Flutter/app.flx +ios/Flutter/app.zip +ios/Flutter/flutter_assets/ +ios/ServiceDefinitions.json +ios/Runner/GeneratedPluginRegistrant.* +ios/.generated/ + +# Visual Studio Code +.vscode/ + +# IntelliJ +.idea/ +*.iml + +# Python +__pycache__/ *.pyo *.pyd .Python @@ -14,8 +71,9 @@ pip-delete-this-directory.txt nosetests.xml coverage.xml *.cover -*.log -.git .mypy_cache .pytest_cache .hypothesise + +# Environment Variables +.env diff --git a/backend/__pycache__/database.cpython-312.pyc b/backend/__pycache__/database.cpython-312.pyc deleted file mode 100644 index ef0cbffb43de09b29fdd882fc4240817ca6649c2..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 437 zcmX@j%ge<81nZT0GG7Df#~=<2Fhd!iyMT=83@HpLj5!QZAet$MF_$TdiIE|dA&U*B z96@JsBG@oi3iE142s?^3m9@+Ja6%p=NDz?mzKomQ%cfGhR+~({1Q$|OwLZtOVNi(>J?Pp;;_lhPbtkwwJQ<;ssZ`D*a1j< zU}j`wyv@LQm%-=(pFn?BXI6h+XWoq1%Y51m+z*-gZt}`6;J(bO)8KZ4lm9xW%tcO_ W3$nVGIrSQy9&qwDIu-E&4FLeBLg{UG$2w$v?-zHku_F8Xu zNgEQBCKUv!65>KQaNxj!DsX}W*POX*lt`(biYTH7atjR-wDiEtZoCPI!au;uJ3H^q z$D23voAGy`^?@JFspy}>YYCApnGIfncE0(a7X^Cmql-MTq5F0(VNm?q{qz>7n z!RFY3|2D_cu}#M{5}%N{k|`KFs8o=IHg39J>B1`_^YX=u7hbwtwOXF-GV{yX*(0kP|bJX?2qMD z`8`#IxR}CJmiSbfB&W#rzqW-H=2Y^XI#js>3XiV3udvb)mU_ zwQ9NUTsAC1KFo1G%6Mp%h^&Ox04*rf&aZGxLE}0E$pOS#Az zi+dHPX^CW9aUr`NFUTCTC(@B_g+auES}qnVpU>a#oB{|~O;&7}cJt}h716P;=;QhP zQBcFWyFQSg$?uEgj+R`UyJhr!AqT^+Z5b1RKCvsQ>gdhxzU}V8jqbtC?vpF&TgH(O zYai6Mje!kg;Is3a#?ZDt6zD@Yj}HVx;~Sl41O4o-Op?#syOYk|B~%>^U4HYzxAg1r zE#pj}pZQ}~hV2J?&%wz{+0rOoeYTV%>q%PbQr1&C@<%CbuXj=8bIH<(yx!kk8kX0G zRpduxkOHFdhJH&Zc=i7Q{SZL&G^Ck#Ai<8{M}W+AM)Ls>TD9!AEyf=~Q5MKyO!6FT z2rWvp%X?6#7YPn9z~5siPDSDhbE>=#HrfE>Cs3MhOQA%30CtX|iVNi5F_$~GTKTB5 znHyY5{S|TNHjU!8UJUeNh`7>5=UAYRA>xMq6LGvBL!!kheBuX(*)qI)7+Ey@aLn0s zl7u5cp5XYuFxUU4!!J3$mw(lnf&R&32BE$3ulvoW-JH2W_#k?Lmqb@rxZ`;IB&@O5 zTy!7a`F2Mx=s6XnPY25BdoK3=lB&^(f+4hN81QDx=Gep0R7iwV!R0tSIrgj25$!md z!r38YvjrW{a$q9hF=38{JYz*ChcWRdQ4p3x9|eDY-FluEVGsWU+*u&dkd*#Fo`T=^ zr1!@}X4zhpZ&T&{-mASUg-x2>m2^7#N$oCy&z?-AT)45DC6f9#opb-F({r_FrEB@b OFA}9AXm5mE7}%e;GrA)H diff --git a/backend/__pycache__/models.cpython-312.pyc b/backend/__pycache__/models.cpython-312.pyc deleted file mode 100644 index bdbf69958416c1b4f20905438f33daaa4e5a58ef..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 1300 zcmZvbzi$*r6vt<8XMgzco$>kS34?@#EEX)IL@6jNOV~sp#h3z7u+6f*eVE1j<1#a6 z`x=Bq(ovi%FG>+dDI&!GK|>SkC{80G3Mx7zn}&)vyJs74M!N65`M%k?_vSPEx>6|< z9Af%ec&|jrdo?*6MnBlUkHHQJNT5X|)?7_faVaXrx~s?3rLo}}vFVz*uSZsFxt2ys z#|G*TaU8`n9Aj z?b}TerimZnN;~ich%kmmv3sHYzasmqINu>aTrD7O=`H~sXrRrbn@6QQGSJhHJgTA+ z0rOF{*z9Y4*`K&JnD~~V*0xQ@lI6SHUxPcs7hG0FD*VXXOxb$c!uil9zkpTzT?qXYvib#3m14|Aq)f zSB?EwDBlwS1I!1dI%d=ltYbnaG6gZDcp`H|@I)pjs(7Es z%V6VLE2oavRhAd=^jgj9zVM}u{J1)DX*0wgCcvd7e6|L71qS05Z$xkdGflh@jTRSa z{L3MBS%xhI8}|WOQ$$)#k3j>%4G276tT?CGX{?D2D^Ue9b)HNCk~$ZRnQA(!L}6y* zN~V=eD9Mqiqh)6fWe@}2MnSgKTIc6X&+cUQ_QGCux=W}3&#DY}E_8Ud)Y;me+pC=K z((}FAct>PQyCd1n?Z6Op4o5ck406gpcSVq?Pfdh6A?C;`gWx$+RZRoJ8rY+(865NVSY*TZochM+VjQ7 xvA||tS`r#IEDZK diff --git a/main.py b/main.py new file mode 100644 index 0000000..e635c40 --- /dev/null +++ b/main.py @@ -0,0 +1 @@ +from backend.main import app diff --git a/railway.toml b/railway.toml deleted file mode 100644 index b2450ff..0000000 --- a/railway.toml +++ /dev/null @@ -1,2 +0,0 @@ -[deploy] -startCommand = "uvicorn backend.main:app --host 0.0.0.0 --port $PORT" From 2f7cb5016f7b944006534c122eb1c32269888713 Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Wed, 11 Feb 2026 15:03:18 +0000 Subject: [PATCH 4/7] Add usage stats and backup backend with Flutter integration. - Create backend/ directory with FastAPI and MongoDB logic. - Move requirements.txt to root for Railpack detection. - Add main.py to root for Railpack detection. - Remove railway.toml to use default Railpack configuration. - Implement ApiService in Flutter for reporting and backups. - Update WorkoutProvider to expose exportAllData. - Add SettingsScreen to UI for manual backups. - Fix Pydantic models to use default_factory. - Make API URL configurable via environment variable. - Update .gitignore to include Python and Flutter artifacts. - Add CORS middleware to backend for cross-origin requests. Co-authored-by: Devasy23 <110348311+Devasy23@users.noreply.github.com> --- backend/main.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/backend/main.py b/backend/main.py index aeb7a5d..b9ad639 100644 --- a/backend/main.py +++ b/backend/main.py @@ -1,9 +1,18 @@ from fastapi import FastAPI, HTTPException +from fastapi.middleware.cors import CORSMiddleware from .models import UsageStats, BackupData from .database import db app = FastAPI() +app.add_middleware( + CORSMiddleware, + allow_origins=["*"], + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], +) + @app.post("/report") async def report_usage(stats: UsageStats): if db is None: From 8d90de63bcfd234d73010c508bb4bb31177414c2 Mon Sep 17 00:00:00 2001 From: Devasy Patel <110348311+Devasy23@users.noreply.github.com> Date: Wed, 11 Feb 2026 21:03:33 +0530 Subject: [PATCH 5/7] Add analytics dashboard and enhance backend for usage tracking and event logging --- .gitignore | 3 + backend/main.py | 210 ++++++++++++- backend/models.py | 36 ++- dashboard/app.py | 288 ++++++++++++++++++ dashboard/requirements.txt | 5 + workout-logger/lib/main.dart | 18 +- .../screens/edit_workout_session_screen.dart | 1 - .../lib/screens/settings_screen.dart | 9 +- workout-logger/lib/services/api_service.dart | 97 +++++- .../lib/services/storage_service.dart | 29 ++ 10 files changed, 661 insertions(+), 35 deletions(-) create mode 100644 dashboard/app.py create mode 100644 dashboard/requirements.txt diff --git a/.gitignore b/.gitignore index 455a439..3caa03e 100644 --- a/.gitignore +++ b/.gitignore @@ -77,3 +77,6 @@ coverage.xml # Environment Variables .env + +# Streamlit secrets +dashboard/.streamlit/secrets.toml diff --git a/backend/main.py b/backend/main.py index b9ad639..fa266cd 100644 --- a/backend/main.py +++ b/backend/main.py @@ -1,9 +1,13 @@ -from fastapi import FastAPI, HTTPException +from datetime import datetime, timedelta, timezone +from typing import Optional + +from fastapi import FastAPI, HTTPException, Query from fastapi.middleware.cors import CORSMiddleware -from .models import UsageStats, BackupData + +from .models import UsageStats, BackupData, AppEvent, HeartbeatPayload from .database import db -app = FastAPI() +app = FastAPI(title="RepForge Analytics API", version="1.1.0") app.add_middleware( CORSMiddleware, @@ -13,28 +17,210 @@ allow_headers=["*"], ) -@app.post("/report") -async def report_usage(stats: UsageStats): + +# ────────────────────── helpers ────────────────────── + +def _ensure_db(): if db is None: raise HTTPException(status_code=503, detail="Database not configured") + + +# ────────────────────── ingest endpoints ────────────────────── + +@app.post("/report") +async def report_usage(stats: UsageStats): + """Receive periodic usage-stats snapshots from a device.""" + _ensure_db() try: - report_data = stats.model_dump() - await db.reports.insert_one(report_data) + doc = stats.model_dump() + # upsert: latest report per user_app_id replaces older one + await db.reports.update_one( + {"user_app_id": stats.user_app_id}, + {"$set": doc}, + upsert=True, + ) + # also keep a time-series log for trend analysis + await db.report_log.insert_one(doc) return {"status": "success", "message": "Usage stats reported"} except Exception as e: raise HTTPException(status_code=500, detail=str(e)) + @app.post("/backup") async def backup_data(data: BackupData): - if db is None: - raise HTTPException(status_code=503, detail="Database not configured") + """Receive a full data backup from a device.""" + _ensure_db() try: - backup_doc = data.model_dump() - await db.backups.insert_one(backup_doc) + doc = data.model_dump() + # keep only latest backup per user; overwrite previous + await db.backups.update_one( + {"user_app_id": data.user_app_id}, + {"$set": doc}, + upsert=True, + ) return {"status": "success", "message": "Backup received"} except Exception as e: raise HTTPException(status_code=500, detail=str(e)) + +@app.post("/event") +async def track_event(event: AppEvent): + """Record a lightweight analytics event.""" + _ensure_db() + try: + await db.events.insert_one(event.model_dump()) + return {"status": "success"} + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + + +@app.post("/heartbeat") +async def heartbeat(payload: HeartbeatPayload): + """Minimal ping on every app-open for DAU/MAU tracking.""" + _ensure_db() + try: + doc = payload.model_dump() + # upsert user record + await db.users.update_one( + {"user_app_id": payload.user_app_id}, + { + "$set": { + "last_seen": doc["timestamp"], + "app_version": doc.get("app_version"), + "platform": doc.get("platform"), + }, + "$setOnInsert": {"first_seen": doc["timestamp"]}, + "$inc": {"total_opens": 1}, + }, + upsert=True, + ) + # also append to heartbeat log for DAU/MAU queries + await db.heartbeats.insert_one(doc) + return {"status": "success"} + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + + +# ────────────────────── analytics / read endpoints ────────────────────── + +@app.get("/analytics/overview") +async def analytics_overview(): + """High-level numbers: total users, DAU, WAU, MAU, total workouts.""" + _ensure_db() + now = datetime.now(timezone.utc) + day_ago = now - timedelta(days=1) + week_ago = now - timedelta(days=7) + month_ago = now - timedelta(days=30) + + total_users = await db.users.count_documents({}) + dau = await db.heartbeats.count_documents( + {"timestamp": {"$gte": day_ago}}, + ) + # distinct user_app_ids in last 7 / 30 days + wau_ids = await db.heartbeats.distinct( + "user_app_id", {"timestamp": {"$gte": week_ago}} + ) + mau_ids = await db.heartbeats.distinct( + "user_app_id", {"timestamp": {"$gte": month_ago}} + ) + + # aggregate total workouts across latest reports + pipeline = [{"$group": {"_id": None, "total": {"$sum": "$total_workouts"}}}] + cursor = db.reports.aggregate(pipeline) + agg = await cursor.to_list(1) + total_workouts = agg[0]["total"] if agg else 0 + + return { + "total_users": total_users, + "dau": dau, + "wau": len(wau_ids), + "mau": len(mau_ids), + "total_workouts_all_users": total_workouts, + } + + +@app.get("/analytics/retention") +async def analytics_retention(days: int = Query(30, ge=1, le=365)): + """Return daily active-user counts for the last N days (retention curve).""" + _ensure_db() + now = datetime.now(timezone.utc) + start = now - timedelta(days=days) + pipeline = [ + {"$match": {"timestamp": {"$gte": start}}}, + { + "$group": { + "_id": { + "$dateToString": {"format": "%Y-%m-%d", "date": "$timestamp"} + }, + "unique_users": {"$addToSet": "$user_app_id"}, + } + }, + { + "$project": { + "date": "$_id", + "active_users": {"$size": "$unique_users"}, + "_id": 0, + } + }, + {"$sort": {"date": 1}}, + ] + cursor = db.heartbeats.aggregate(pipeline) + results = await cursor.to_list(days + 1) + return {"days": days, "retention": results} + + +@app.get("/analytics/events") +async def analytics_events( + event: Optional[str] = None, + days: int = Query(7, ge=1, le=365), +): + """Aggregate event counts, optionally filtered by event name.""" + _ensure_db() + now = datetime.now(timezone.utc) + start = now - timedelta(days=days) + match: dict = {"timestamp": {"$gte": start}} + if event: + match["event"] = event + + pipeline = [ + {"$match": match}, + {"$group": {"_id": "$event", "count": {"$sum": 1}}}, + {"$sort": {"count": -1}}, + ] + cursor = db.events.aggregate(pipeline) + results = await cursor.to_list(100) + return {"days": days, "events": [{"event": r["_id"], "count": r["count"]} for r in results]} + + +@app.get("/analytics/users") +async def analytics_users( + limit: int = Query(50, ge=1, le=500), + sort_by: str = Query("last_seen", regex="^(last_seen|first_seen|total_opens)$"), +): + """List individual user records for drill-down.""" + _ensure_db() + cursor = db.users.find( + {}, {"_id": 0} + ).sort(sort_by, -1).limit(limit) + users = await cursor.to_list(limit) + return {"count": len(users), "users": users} + + +@app.get("/analytics/user/{user_app_id}") +async def analytics_user_detail(user_app_id: str): + """Full detail for a single installation: profile, latest report, events.""" + _ensure_db() + user = await db.users.find_one({"user_app_id": user_app_id}, {"_id": 0}) + report = await db.reports.find_one({"user_app_id": user_app_id}, {"_id": 0}) + events_cursor = db.events.find( + {"user_app_id": user_app_id}, {"_id": 0} + ).sort("timestamp", -1).limit(50) + events = await events_cursor.to_list(50) + return {"user": user, "latest_report": report, "recent_events": events} + + +# ────────────────────── health ────────────────────── + @app.get("/") async def root(): - return {"message": "Workout Logger Backend Running"} + return {"message": "RepForge Analytics Backend Running", "version": "1.1.0"} diff --git a/backend/models.py b/backend/models.py index 490d696..942eb93 100644 --- a/backend/models.py +++ b/backend/models.py @@ -1,19 +1,47 @@ from pydantic import BaseModel, Field from typing import List, Dict, Any, Optional -from datetime import datetime +from datetime import datetime, timezone + + +def _utcnow() -> datetime: + return datetime.now(timezone.utc) + class UsageStats(BaseModel): + user_app_id: str # unique per-install identifier total_workouts: int weekly_workouts: int weekly_volume: float exercises_this_week: int - report_date: datetime = Field(default_factory=datetime.now) + app_version: Optional[str] = None + platform: Optional[str] = None # android / ios / web + report_date: datetime = Field(default_factory=_utcnow) + class BackupData(BaseModel): + user_app_id: str sessions: List[Dict[str, Any]] routines: List[Dict[str, Any]] targets: List[Dict[str, Any]] muscleGroups: List[Dict[str, Any]] customExercises: List[Dict[str, Any]] - exportDate: str # Since it's ISO string from Dart - backup_received_at: datetime = Field(default_factory=datetime.now) + exportDate: str # ISO string from Dart + backup_received_at: datetime = Field(default_factory=_utcnow) + + +class AppEvent(BaseModel): + """Lightweight event for tracking feature usage, app opens, etc.""" + user_app_id: str + event: str # e.g. "app_open", "workout_started", "backup_triggered" + metadata: Optional[Dict[str, Any]] = None + app_version: Optional[str] = None + platform: Optional[str] = None + timestamp: datetime = Field(default_factory=_utcnow) + + +class HeartbeatPayload(BaseModel): + """Minimal ping sent on every app open for DAU/MAU calculation.""" + user_app_id: str + app_version: Optional[str] = None + platform: Optional[str] = None + timestamp: datetime = Field(default_factory=_utcnow) diff --git a/dashboard/app.py b/dashboard/app.py new file mode 100644 index 0000000..3208b0a --- /dev/null +++ b/dashboard/app.py @@ -0,0 +1,288 @@ +""" +RepForge Analytics Dashboard +──────────────────────────── +Connects to the same MongoDB Atlas used by the FastAPI backend +and visualises usage stats, retention, events, and user drill-downs. +""" + +import streamlit as st +import pandas as pd +import plotly.express as px +import plotly.graph_objects as go +from pymongo import MongoClient +from datetime import datetime, timedelta, timezone + +# ─── page config ─── +st.set_page_config( + page_title="RepForge Analytics", + page_icon="🏋️", + layout="wide", +) + +# ─── MongoDB connection (cached) ─── +@st.cache_resource +def get_db(): + uri = st.secrets["mongo"]["uri"] + client = MongoClient(uri) + return client.get_database("workout_logger") + + +db = get_db() + +# ─── sidebar ─── +st.sidebar.title("🏋️ RepForge Analytics") +page = st.sidebar.radio( + "Navigate", + ["Overview", "Retention", "Events", "Users", "Backups"], +) +st.sidebar.markdown("---") +st.sidebar.caption(f"Data as of {datetime.now(timezone.utc):%Y-%m-%d %H:%M} UTC") + + +# ════════════════════════════════════════════════════════════ +# OVERVIEW +# ════════════════════════════════════════════════════════════ +if page == "Overview": + st.title("📊 Overview") + + now = datetime.now(timezone.utc) + day_ago = now - timedelta(days=1) + week_ago = now - timedelta(days=7) + month_ago = now - timedelta(days=30) + + total_users = db.users.count_documents({}) + total_heartbeats_today = db.heartbeats.count_documents({"timestamp": {"$gte": day_ago}}) + wau = len(db.heartbeats.distinct("user_app_id", {"timestamp": {"$gte": week_ago}})) + mau = len(db.heartbeats.distinct("user_app_id", {"timestamp": {"$gte": month_ago}})) + + # total workouts across all latest reports + agg = list(db.reports.aggregate([{"$group": {"_id": None, "total": {"$sum": "$total_workouts"}}}])) + total_workouts = agg[0]["total"] if agg else 0 + + total_backups = db.backups.count_documents({}) + total_events = db.events.count_documents({}) + + # KPI cards + c1, c2, c3, c4 = st.columns(4) + c1.metric("Total Installs", total_users) + c2.metric("DAU (heartbeats today)", total_heartbeats_today) + c3.metric("WAU", wau) + c4.metric("MAU", mau) + + c5, c6, c7 = st.columns(3) + c5.metric("Total Workouts (all users)", total_workouts) + c6.metric("Total Backups", total_backups) + c7.metric("Total Events Logged", total_events) + + st.markdown("---") + + # Platform distribution + st.subheader("Platform Distribution") + platform_data = list(db.users.aggregate([ + {"$group": {"_id": "$platform", "count": {"$sum": 1}}}, + {"$sort": {"count": -1}}, + ])) + if platform_data: + df_plat = pd.DataFrame(platform_data).rename(columns={"_id": "platform"}) + fig = px.pie(df_plat, names="platform", values="count", hole=0.4) + st.plotly_chart(fig, use_container_width=True) + else: + st.info("No platform data yet.") + + # Daily heartbeats (last 30 days) + st.subheader("Daily Active Heartbeats (30 days)") + hb_pipeline = [ + {"$match": {"timestamp": {"$gte": month_ago}}}, + {"$group": { + "_id": {"$dateToString": {"format": "%Y-%m-%d", "date": "$timestamp"}}, + "unique_users": {"$addToSet": "$user_app_id"}, + }}, + {"$project": {"date": "$_id", "active_users": {"$size": "$unique_users"}, "_id": 0}}, + {"$sort": {"date": 1}}, + ] + hb_data = list(db.heartbeats.aggregate(hb_pipeline)) + if hb_data: + df_hb = pd.DataFrame(hb_data) + fig2 = px.bar(df_hb, x="date", y="active_users", labels={"active_users": "Unique Users"}) + st.plotly_chart(fig2, use_container_width=True) + else: + st.info("No heartbeat data yet.") + + +# ════════════════════════════════════════════════════════════ +# RETENTION +# ════════════════════════════════════════════════════════════ +elif page == "Retention": + st.title("📈 User Retention") + + days = st.slider("Lookback window (days)", 7, 180, 30) + now = datetime.now(timezone.utc) + start = now - timedelta(days=days) + + pipeline = [ + {"$match": {"timestamp": {"$gte": start}}}, + {"$group": { + "_id": {"$dateToString": {"format": "%Y-%m-%d", "date": "$timestamp"}}, + "unique_users": {"$addToSet": "$user_app_id"}, + }}, + {"$project": {"date": "$_id", "active_users": {"$size": "$unique_users"}, "_id": 0}}, + {"$sort": {"date": 1}}, + ] + data = list(db.heartbeats.aggregate(pipeline)) + if data: + df = pd.DataFrame(data) + fig = px.area(df, x="date", y="active_users", title="Daily Active Users") + st.plotly_chart(fig, use_container_width=True) + + # New vs returning users (users whose first_seen is in the window) + st.subheader("New Installs per Day") + new_pipeline = [ + {"$match": {"first_seen": {"$gte": start}}}, + {"$group": { + "_id": {"$dateToString": {"format": "%Y-%m-%d", "date": "$first_seen"}}, + "new_users": {"$sum": 1}, + }}, + {"$project": {"date": "$_id", "new_users": 1, "_id": 0}}, + {"$sort": {"date": 1}}, + ] + new_data = list(db.users.aggregate(new_pipeline)) + if new_data: + df_new = pd.DataFrame(new_data) + fig2 = px.bar(df_new, x="date", y="new_users", title="New Installs") + st.plotly_chart(fig2, use_container_width=True) + else: + st.info("No new-install data yet.") + else: + st.info("No heartbeat data in the selected window.") + + +# ════════════════════════════════════════════════════════════ +# EVENTS +# ════════════════════════════════════════════════════════════ +elif page == "Events": + st.title("⚡ Event Analytics") + + days = st.slider("Lookback (days)", 1, 90, 7) + now = datetime.now(timezone.utc) + start = now - timedelta(days=days) + + # Aggregate event counts + pipeline = [ + {"$match": {"timestamp": {"$gte": start}}}, + {"$group": {"_id": "$event", "count": {"$sum": 1}}}, + {"$sort": {"count": -1}}, + ] + ev_data = list(db.events.aggregate(pipeline)) + if ev_data: + df = pd.DataFrame(ev_data).rename(columns={"_id": "event"}) + fig = px.bar(df, x="event", y="count", color="event", title="Event Counts") + st.plotly_chart(fig, use_container_width=True) + + # Event timeline + st.subheader("Events Over Time") + tl_pipeline = [ + {"$match": {"timestamp": {"$gte": start}}}, + {"$group": { + "_id": { + "date": {"$dateToString": {"format": "%Y-%m-%d", "date": "$timestamp"}}, + "event": "$event", + }, + "count": {"$sum": 1}, + }}, + {"$project": {"date": "$_id.date", "event": "$_id.event", "count": 1, "_id": 0}}, + {"$sort": {"date": 1}}, + ] + tl_data = list(db.events.aggregate(tl_pipeline)) + if tl_data: + df_tl = pd.DataFrame(tl_data) + fig2 = px.line(df_tl, x="date", y="count", color="event", title="Events Over Time") + st.plotly_chart(fig2, use_container_width=True) + else: + st.info("No events in the selected window.") + + +# ════════════════════════════════════════════════════════════ +# USERS +# ════════════════════════════════════════════════════════════ +elif page == "Users": + st.title("👤 User Directory") + + sort_by = st.selectbox("Sort by", ["last_seen", "first_seen", "total_opens"]) + limit = st.number_input("Limit", 10, 500, 50) + + users = list( + db.users.find({}, {"_id": 0}) + .sort(sort_by, -1) + .limit(limit) + ) + + if users: + df = pd.DataFrame(users) + st.dataframe(df, use_container_width=True) + + # Drill-down + st.markdown("---") + st.subheader("User Detail") + app_ids = [u.get("user_app_id", "?") for u in users] + selected = st.selectbox("Select user_app_id", app_ids) + + if selected: + col1, col2 = st.columns(2) + with col1: + st.markdown("**User record**") + user_doc = db.users.find_one({"user_app_id": selected}, {"_id": 0}) + st.json(user_doc or {}) + + with col2: + st.markdown("**Latest Usage Report**") + report = db.reports.find_one({"user_app_id": selected}, {"_id": 0}) + if report: + st.json(report) + else: + st.info("No report yet.") + + st.markdown("**Recent Events**") + evts = list( + db.events.find({"user_app_id": selected}, {"_id": 0}) + .sort("timestamp", -1) + .limit(30) + ) + if evts: + st.dataframe(pd.DataFrame(evts), use_container_width=True) + else: + st.info("No events for this user.") + else: + st.info("No users found.") + + +# ════════════════════════════════════════════════════════════ +# BACKUPS +# ════════════════════════════════════════════════════════════ +elif page == "Backups": + st.title("💾 Backup Explorer") + + backups = list( + db.backups.find({}, {"_id": 0, "sessions": 0, "routines": 0, + "targets": 0, "muscleGroups": 0, "customExercises": 0}) + .sort("backup_received_at", -1) + .limit(100) + ) + + if backups: + df = pd.DataFrame(backups) + st.dataframe(df, use_container_width=True) + + st.markdown("---") + st.subheader("Backup Detail") + ids = [b.get("user_app_id", "?") for b in backups] + sel = st.selectbox("Select user_app_id", ids, key="backup_user") + if sel: + doc = db.backups.find_one({"user_app_id": sel}, {"_id": 0}) + if doc: + st.metric("Sessions", len(doc.get("sessions", []))) + st.metric("Routines", len(doc.get("routines", []))) + st.metric("Custom Exercises", len(doc.get("customExercises", []))) + with st.expander("Raw backup JSON"): + st.json(doc) + else: + st.info("No backups found.") diff --git a/dashboard/requirements.txt b/dashboard/requirements.txt new file mode 100644 index 0000000..8626a1e --- /dev/null +++ b/dashboard/requirements.txt @@ -0,0 +1,5 @@ +streamlit +pymongo[srv] +dnspython +pandas +plotly diff --git a/workout-logger/lib/main.dart b/workout-logger/lib/main.dart index ceb06b4..feb3b86 100644 --- a/workout-logger/lib/main.dart +++ b/workout-logger/lib/main.dart @@ -95,12 +95,18 @@ class _AppInitializerState extends State { final provider = context.read(); await provider.init(); - // Report usage stats in background - provider.getQuickStats().then((stats) { - ApiService().reportUsage(stats); - }).catchError((e) { - debugPrint('Failed to report usage: $e'); - }); + // Fire-and-forget analytics in background + final api = ApiService(); + api.sendHeartbeat(); + api.trackEvent('app_open'); + provider + .getQuickStats() + .then((stats) { + api.reportUsage(stats); + }) + .catchError((e) { + debugPrint('Failed to report usage: $e'); + }); setState(() => _initialized = true); } catch (e) { diff --git a/workout-logger/lib/screens/edit_workout_session_screen.dart b/workout-logger/lib/screens/edit_workout_session_screen.dart index 45ab30a..feea401 100644 --- a/workout-logger/lib/screens/edit_workout_session_screen.dart +++ b/workout-logger/lib/screens/edit_workout_session_screen.dart @@ -1,6 +1,5 @@ // Edit Workout Session Screen - Modify recorded workout sessions -import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:provider/provider.dart'; diff --git a/workout-logger/lib/screens/settings_screen.dart b/workout-logger/lib/screens/settings_screen.dart index 1a550f6..78085de 100644 --- a/workout-logger/lib/screens/settings_screen.dart +++ b/workout-logger/lib/screens/settings_screen.dart @@ -23,7 +23,9 @@ class _SettingsScreenState extends State { final jsonString = await provider.exportAllData(); final data = jsonDecode(jsonString) as Map; - final success = await ApiService().backupData(data); + final api = ApiService(); + api.trackEvent('backup_triggered'); + final success = await api.backupData(data); if (!mounted) return; @@ -45,10 +47,7 @@ class _SettingsScreenState extends State { } catch (e) { if (!mounted) return; ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text('Error: $e'), - backgroundColor: AppTheme.error, - ), + SnackBar(content: Text('Error: $e'), backgroundColor: AppTheme.error), ); } finally { if (mounted) { diff --git a/workout-logger/lib/services/api_service.dart b/workout-logger/lib/services/api_service.dart index 801d6e6..77e2c7e 100644 --- a/workout-logger/lib/services/api_service.dart +++ b/workout-logger/lib/services/api_service.dart @@ -1,28 +1,108 @@ import 'dart:convert'; +import 'dart:io' show Platform; import 'package:http/http.dart' as http; import 'package:flutter/foundation.dart'; +import 'package:hive/hive.dart'; +import 'package:uuid/uuid.dart'; +/// Singleton service for communicating with the RepForge analytics backend. class ApiService { - // TODO: Update this URL with your Railway deployment URL - // Example: https://repforge-backend-production.up.railway.app static const String _baseUrl = String.fromEnvironment( 'API_URL', - defaultValue: 'http://10.0.2.2:8000', // Default to Android Emulator for dev + defaultValue: 'https://workout-logger-production-1e93.up.railway.app', ); - final http.Client _client; + static final ApiService _instance = ApiService._internal(); + factory ApiService({http.Client? client}) { + if (client != null) _instance._client = client; + return _instance; + } + ApiService._internal(); + + http.Client _client = http.Client(); + + String? _cachedAppId; + + /// Returns a stable installation ID (UUID v4) persisted in Hive. + Future get userAppId async { + if (_cachedAppId != null) return _cachedAppId!; + final box = Hive.box('settings'); + var id = box.get('user_app_id'); + if (id == null) { + id = const Uuid().v4(); + await box.put('user_app_id', id); + } + _cachedAppId = id; + return id; + } + + String get _platform { + if (kIsWeb) return 'web'; + if (Platform.isAndroid) return 'android'; + if (Platform.isIOS) return 'ios'; + return 'unknown'; + } - ApiService({http.Client? client}) : _client = client ?? http.Client(); + // ───────── ingest helpers ───────── + + Future sendHeartbeat() async { + try { + final id = await userAppId; + final body = { + 'user_app_id': id, + 'platform': _platform, + 'timestamp': DateTime.now().toUtc().toIso8601String(), + }; + final res = await _client.post( + Uri.parse('$_baseUrl/heartbeat'), + headers: {'Content-Type': 'application/json'}, + body: jsonEncode(body), + ); + if (res.statusCode != 200) { + debugPrint('Heartbeat failed: ${res.body}'); + } + } catch (e) { + debugPrint('Heartbeat error: $e'); + } + } + + Future trackEvent( + String event, { + Map? metadata, + }) async { + try { + final id = await userAppId; + final body = { + 'user_app_id': id, + 'event': event, + 'platform': _platform, + 'timestamp': DateTime.now().toUtc().toIso8601String(), + if (metadata != null) 'metadata': metadata, + }; + final res = await _client.post( + Uri.parse('$_baseUrl/event'), + headers: {'Content-Type': 'application/json'}, + body: jsonEncode(body), + ); + if (res.statusCode != 200) { + debugPrint('Event tracking failed: ${res.body}'); + } + } catch (e) { + debugPrint('Event tracking error: $e'); + } + } Future reportUsage(Map stats) async { try { - // Map Dart camelCase stats to Python snake_case model + final id = await userAppId; final payload = { + 'user_app_id': id, 'total_workouts': stats['totalWorkouts'], 'weekly_workouts': stats['weeklyWorkouts'], 'weekly_volume': stats['weeklyVolume'], 'exercises_this_week': stats['exercisesThisWeek'], - 'report_date': DateTime.now().toIso8601String(), + 'platform': _platform, + 'report_date': DateTime.now().toUtc().toIso8601String(), }; final response = await _client.post( @@ -41,6 +121,9 @@ class ApiService { Future backupData(Map data) async { try { + final id = await userAppId; + data['user_app_id'] = id; + final response = await _client.post( Uri.parse('$_baseUrl/backup'), headers: {'Content-Type': 'application/json'}, diff --git a/workout-logger/lib/services/storage_service.dart b/workout-logger/lib/services/storage_service.dart index dbf1ede..192a828 100644 --- a/workout-logger/lib/services/storage_service.dart +++ b/workout-logger/lib/services/storage_service.dart @@ -33,6 +33,7 @@ class StorageService implements IStorageService { bool _initialized = false; /// Initialize Hive and open boxes + @override Future init() async { if (_initialized) return; @@ -64,10 +65,12 @@ class StorageService implements IStorageService { // ==================== WORKOUT SESSIONS ==================== + @override Future saveWorkoutSession(WorkoutSession session) async { await _sessionsBox.put(session.id, jsonEncode(session.toJson())); } + @override Future> getAllWorkoutSessions() async { final sessions = []; for (var json in _sessionsBox.values) { @@ -77,16 +80,19 @@ class StorageService implements IStorageService { return sessions; } + @override Future getWorkoutSession(String id) async { final json = _sessionsBox.get(id); if (json == null) return null; return WorkoutSession.fromJson(jsonDecode(json)); } + @override Future deleteWorkoutSession(String id) async { await _sessionsBox.delete(id); } + @override Future> getSessionsForExercise(String exerciseId) async { final allSessions = await getAllWorkoutSessions(); return allSessions @@ -96,6 +102,7 @@ class StorageService implements IStorageService { .toList(); } + @override Future> getSessionsInDateRange( DateTime start, DateTime end, @@ -111,10 +118,12 @@ class StorageService implements IStorageService { // ==================== ROUTINES ==================== + @override Future saveRoutine(Routine routine) async { await _routinesBoxInstance.put(routine.id, jsonEncode(routine.toJson())); } + @override Future> getAllRoutines() async { final routines = []; for (var json in _routinesBoxInstance.values) { @@ -123,22 +132,26 @@ class StorageService implements IStorageService { return routines; } + @override Future getRoutine(String id) async { final json = _routinesBoxInstance.get(id); if (json == null) return null; return Routine.fromJson(jsonDecode(json)); } + @override Future deleteRoutine(String id) async { await _routinesBoxInstance.delete(id); } // ==================== TARGETS ==================== + @override Future saveTarget(Target target) async { await _targetsBoxInstance.put(target.id, jsonEncode(target.toJson())); } + @override Future> getAllTargets() async { final targets = []; for (var json in _targetsBoxInstance.values) { @@ -147,16 +160,19 @@ class StorageService implements IStorageService { return targets; } + @override Future getTarget(String id) async { final json = _targetsBoxInstance.get(id); if (json == null) return null; return Target.fromJson(jsonDecode(json)); } + @override Future deleteTarget(String id) async { await _targetsBoxInstance.delete(id); } + @override Future> getTargetsForExercise(String exerciseId) async { final allTargets = await getAllTargets(); return allTargets.where((t) => t.exerciseId == exerciseId).toList(); @@ -164,6 +180,7 @@ class StorageService implements IStorageService { // ==================== MUSCLE GROUPS ==================== + @override Future updateMuscleGroupGrowthRate( String muscleGroupId, double rate, @@ -180,6 +197,7 @@ class StorageService implements IStorageService { } } + @override Future> getAllMuscleGroups() async { final groups = []; for (var json in _muscleGroupsBoxInstance.values) { @@ -188,6 +206,7 @@ class StorageService implements IStorageService { return groups; } + @override Future getMuscleGroup(String id) async { final json = _muscleGroupsBoxInstance.get(id); if (json == null) return null; @@ -196,6 +215,7 @@ class StorageService implements IStorageService { // ==================== CUSTOM EXERCISES ==================== + @override Future saveCustomExercise(Exercise exercise) async { await _customExercisesBoxInstance.put( exercise.id, @@ -203,6 +223,7 @@ class StorageService implements IStorageService { ); } + @override Future> getCustomExercises() async { final exercises = []; for (var json in _customExercisesBoxInstance.values) { @@ -211,11 +232,13 @@ class StorageService implements IStorageService { return exercises; } + @override Future deleteCustomExercise(String id) async { await _customExercisesBoxInstance.delete(id); } /// Get all exercises (built-in + custom) + @override Future> getAllExercises() async { final builtIn = ExerciseDatabase.getAll(); final custom = await getCustomExercises(); @@ -223,6 +246,7 @@ class StorageService implements IStorageService { } /// Get exercise by ID (built-in or custom) + @override Future getExercise(String id) async { // Check built-in first final builtIn = ExerciseDatabase.getById(id); @@ -239,16 +263,19 @@ class StorageService implements IStorageService { // ==================== SETTINGS ==================== + @override Future saveSetting(String key, String value) async { await _settingsBoxInstance.put(key, value); } + @override Future getSetting(String key) async { return _settingsBoxInstance.get(key); } // ==================== EXPORT / IMPORT ==================== + @override Future exportAllData() async { final data = { 'sessions': _sessionsBox.values.toList(), @@ -261,6 +288,7 @@ class StorageService implements IStorageService { return jsonEncode(data); } + @override Future importData(String jsonData) async { final data = jsonDecode(jsonData) as Map; @@ -291,6 +319,7 @@ class StorageService implements IStorageService { // ==================== STATS ==================== + @override Future> getQuickStats() async { final sessions = await getAllWorkoutSessions(); final now = DateTime.now(); From a67bb7a034a7e6b829c3739686fb73e8a3b19e9f Mon Sep 17 00:00:00 2001 From: Devasy Patel <110348311+Devasy23@users.noreply.github.com> Date: Wed, 11 Feb 2026 21:33:32 +0530 Subject: [PATCH 6/7] fix: decode Hive string items in backup payload & accept List[Any] in backend - Flutter: decode JSON-string list items to Maps before sending to /backup - Backend: BackupData model accepts List[Any] with parsed_backup() helper - Fixes 'Input should be a valid dictionary' validation error --- backend/main.py | 2 +- backend/models.py | 30 ++++++++++++++++---- workout-logger/lib/services/api_service.dart | 23 +++++++++++++-- 3 files changed, 47 insertions(+), 8 deletions(-) diff --git a/backend/main.py b/backend/main.py index fa266cd..e1eeb68 100644 --- a/backend/main.py +++ b/backend/main.py @@ -51,7 +51,7 @@ async def backup_data(data: BackupData): """Receive a full data backup from a device.""" _ensure_db() try: - doc = data.model_dump() + doc = data.parsed_backup() # decode any JSON-string list items # keep only latest backup per user; overwrite previous await db.backups.update_one( {"user_app_id": data.user_app_id}, diff --git a/backend/models.py b/backend/models.py index 942eb93..dda2e3c 100644 --- a/backend/models.py +++ b/backend/models.py @@ -20,14 +20,34 @@ class UsageStats(BaseModel): class BackupData(BaseModel): user_app_id: str - sessions: List[Dict[str, Any]] - routines: List[Dict[str, Any]] - targets: List[Dict[str, Any]] - muscleGroups: List[Dict[str, Any]] - customExercises: List[Dict[str, Any]] + sessions: List[Any] # May arrive as List[str] or List[dict] + routines: List[Any] + targets: List[Any] + muscleGroups: List[Any] + customExercises: List[Any] exportDate: str # ISO string from Dart backup_received_at: datetime = Field(default_factory=_utcnow) + def parsed_backup(self) -> dict: + """Return a copy with any JSON-string items decoded to dicts.""" + import json + def _parse_list(items: list) -> list: + out = [] + for item in items: + if isinstance(item, str): + try: + out.append(json.loads(item)) + except (json.JSONDecodeError, TypeError): + out.append(item) + else: + out.append(item) + return out + + data = self.model_dump() + for key in ('sessions', 'routines', 'targets', 'muscleGroups', 'customExercises'): + data[key] = _parse_list(data.get(key, [])) + return data + class AppEvent(BaseModel): """Lightweight event for tracking feature usage, app opens, etc.""" diff --git a/workout-logger/lib/services/api_service.dart b/workout-logger/lib/services/api_service.dart index 77e2c7e..e0f2b29 100644 --- a/workout-logger/lib/services/api_service.dart +++ b/workout-logger/lib/services/api_service.dart @@ -122,12 +122,31 @@ class ApiService { Future backupData(Map data) async { try { final id = await userAppId; - data['user_app_id'] = id; + + // Hive stores items as JSON strings – decode them to Maps for the API + final decoded = {'user_app_id': id}; + for (final key in data.keys) { + final value = data[key]; + if (value is List) { + decoded[key] = value.map((item) { + if (item is String) { + try { + return jsonDecode(item); + } catch (_) { + return item; + } + } + return item; + }).toList(); + } else { + decoded[key] = value; + } + } final response = await _client.post( Uri.parse('$_baseUrl/backup'), headers: {'Content-Type': 'application/json'}, - body: jsonEncode(data), + body: jsonEncode(decoded), ); if (response.statusCode == 200) { From 4a50b8bf59b5b92e4ec363c95c59fd3f70c0866e Mon Sep 17 00:00:00 2001 From: Devasy Patel <110348311+Devasy@users.noreply.github.com> Date: Wed, 11 Feb 2026 22:12:16 +0530 Subject: [PATCH 7/7] Added Dev Container Folder --- .devcontainer/devcontainer.json | 33 +++++++++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) create mode 100644 .devcontainer/devcontainer.json diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json new file mode 100644 index 0000000..6168cc4 --- /dev/null +++ b/.devcontainer/devcontainer.json @@ -0,0 +1,33 @@ +{ + "name": "Python 3", + // Or use a Dockerfile or Docker Compose file. More info: https://containers.dev/guide/dockerfile + "image": "mcr.microsoft.com/devcontainers/python:1-3.11-bookworm", + "customizations": { + "codespaces": { + "openFiles": [ + "README.md", + "dashboard/app.py" + ] + }, + "vscode": { + "settings": {}, + "extensions": [ + "ms-python.python", + "ms-python.vscode-pylance" + ] + } + }, + "updateContentCommand": "[ -f packages.txt ] && sudo apt update && sudo apt upgrade -y && sudo xargs apt install -y