├── docs ├── .nvmrc ├── images │ ├── .keep │ ├── data-usage.png │ ├── data-roadmap.png │ ├── current-data-model.png │ └── providers │ │ ├── provider-strategy.png │ │ └── custom-providers-architecture.png ├── logo │ ├── .keep │ ├── dark.png │ └── light.png ├── api-reference │ └── endpoint │ │ └── .keep ├── provider-setup │ ├── polar.mdx │ ├── garmin.mdx │ ├── suunto.mdx │ ├── apple-health.mdx │ └── index.mdx ├── snippets │ └── snippet-intro.mdx ├── LICENSE ├── README.md ├── support.mdx └── favicon.svg ├── .aider.md ├── GEMINI.md ├── frontend ├── .nvmrc ├── src │ ├── lib │ │ ├── constants │ │ │ ├── index.ts │ │ │ └── providers.ts │ │ ├── utils │ │ │ ├── index.ts │ │ │ ├── url.ts │ │ │ ├── format.ts │ │ │ └── health.ts │ │ ├── api │ │ │ ├── index.ts │ │ │ └── services │ │ │ │ ├── developers.service.ts │ │ │ │ ├── credentials.service.ts │ │ │ │ ├── invitations.service.ts │ │ │ │ ├── auth.service.ts │ │ │ │ ├── users.service.ts │ │ │ │ ├── oauth.service.ts │ │ │ │ ├── automations.service.ts │ │ │ │ └── dashboard.service.ts │ │ ├── utils.ts │ │ ├── auth │ │ │ └── types.ts │ │ ├── query │ │ │ └── client.ts │ │ ├── errors │ │ │ └── handler.ts │ │ └── validation │ │ │ └── auth.schemas.ts │ ├── routes │ │ ├── users │ │ │ └── $userId │ │ │ │ └── pair.tsx │ │ ├── _authenticated │ │ │ ├── users.tsx │ │ │ └── settings.tsx │ │ ├── _authenticated.tsx │ │ └── index.tsx │ ├── router.tsx │ ├── components │ │ ├── ui │ │ │ ├── skeleton.tsx │ │ │ ├── separator.tsx │ │ │ ├── label.tsx │ │ │ ├── input.tsx │ │ │ ├── switch.tsx │ │ │ ├── badge.tsx │ │ │ ├── sonner.tsx │ │ │ ├── number-ticker.tsx │ │ │ ├── tooltip.tsx │ │ │ └── card.tsx │ │ └── common │ │ │ ├── error-state.tsx │ │ │ └── loading-spinner.tsx │ ├── hooks │ │ ├── use-mobile.ts │ │ └── api │ │ │ ├── use-developers.ts │ │ │ ├── use-oauth-providers.ts │ │ │ ├── use-dashboard.ts │ │ │ └── use-credentials.ts │ └── logo.svg ├── public │ ├── robots.txt │ ├── favicon.ico │ ├── apple-touch-icon.png │ ├── favicon-dark-16x16.png │ ├── favicon-dark-32x32.png │ ├── favicon-light-16x16.png │ ├── favicon-light-32x32.png │ ├── tanstack-circle-logo.png │ ├── android-chrome-192x192.png │ ├── android-chrome-512x512.png │ └── manifest.json ├── .gitignore ├── .env.example ├── .dockerignore ├── prettier.config.mjs ├── .vscode │ └── settings.json ├── .cta.json ├── .prettierignore ├── components.json ├── Dockerfile.dev ├── tsconfig.json ├── .oxlintrc.json ├── vite.config.ts └── Dockerfile ├── .windsurfrules ├── backend ├── .python-version ├── app │ ├── integrations │ │ ├── sqladmin │ │ │ ├── auth.py │ │ │ ├── __init__.py │ │ │ ├── views │ │ │ │ ├── __init__.py │ │ │ │ └── user.py │ │ │ └── view_models.py │ │ ├── celery │ │ │ ├── __init__.py │ │ │ ├── tasks │ │ │ │ ├── __init__.py │ │ │ │ └── periodic_sync_task.py │ │ │ └── core.py │ │ ├── sentry.py │ │ └── redis_client.py │ ├── services │ │ ├── providers │ │ │ ├── apple │ │ │ │ ├── __init__.py │ │ │ │ ├── handlers │ │ │ │ │ ├── healthkit.py │ │ │ │ │ ├── auto_export.py │ │ │ │ │ └── base.py │ │ │ │ └── strategy.py │ │ │ ├── polar │ │ │ │ ├── __init__.py │ │ │ │ ├── strategy.py │ │ │ │ └── oauth.py │ │ │ ├── garmin │ │ │ │ ├── __init__.py │ │ │ │ ├── strategy.py │ │ │ │ └── oauth.py │ │ │ ├── __init__.py │ │ │ ├── templates │ │ │ │ └── __init__.py │ │ │ ├── suunto │ │ │ │ ├── __init__.py │ │ │ │ ├── strategy.py │ │ │ │ └── oauth.py │ │ │ ├── factory.py │ │ │ └── base_strategy.py │ │ ├── apple │ │ │ └── apple_xml │ │ │ │ └── aws_service.py │ │ ├── __init__.py │ │ ├── user_connection_service.py │ │ └── developer_service.py │ ├── schemas │ │ ├── response.py │ │ ├── sync.py │ │ ├── apple │ │ │ ├── healthkit │ │ │ │ ├── workout_import.py │ │ │ │ └── record_import.py │ │ │ ├── apple_xml │ │ │ │ └── aws.py │ │ │ └── auto_export │ │ │ │ └── json_schemas.py │ │ ├── error_codes.py │ │ ├── api_key.py │ │ ├── garmin │ │ │ └── activity_import.py │ │ ├── external_mapping.py │ │ ├── personal_record.py │ │ ├── system_info.py │ │ ├── polar │ │ │ └── exercise_import.py │ │ ├── provider_setting.py │ │ ├── filter_params.py │ │ ├── common_types.py │ │ ├── developer.py │ │ ├── event_record_detail.py │ │ ├── invitation.py │ │ ├── events.py │ │ └── suunto │ │ │ └── workout_import.py │ ├── __init__.py │ ├── api │ │ ├── __init__.py │ │ └── routes │ │ │ └── v1 │ │ │ ├── dashboard.py │ │ │ ├── workouts.py │ │ │ ├── heart_rate.py │ │ │ ├── import_xml.py │ │ │ ├── connections.py │ │ │ ├── records.py │ │ │ ├── events.py │ │ │ ├── developers.py │ │ │ ├── timeseries.py │ │ │ ├── users.py │ │ │ ├── api_keys.py │ │ │ ├── sdk_sync.py │ │ │ ├── invitations.py │ │ │ └── summaries.py │ ├── constants │ │ ├── series_types │ │ │ └── __init__.py │ │ └── workout_types │ │ │ └── __init__.py │ ├── models │ │ ├── provider_setting.py │ │ ├── device_software.py │ │ ├── series_type_definition.py │ │ ├── api_key.py │ │ ├── device.py │ │ ├── event_record_detail.py │ │ ├── developer.py │ │ ├── personal_record.py │ │ ├── user.py │ │ ├── sleep_details.py │ │ ├── invitation.py │ │ ├── data_point_series.py │ │ ├── external_device_mapping.py │ │ ├── workout_details.py │ │ ├── __init__.py │ │ ├── event_record.py │ │ └── user_connection.py │ ├── repositories │ │ ├── developer_repository.py │ │ ├── api_key_repository.py │ │ ├── __init__.py │ │ ├── event_record_detail_repository.py │ │ └── repositories.py │ ├── utils │ │ ├── requests_extensions.py │ │ ├── conversion.py │ │ ├── healthcheck.py │ │ ├── api_utils.py │ │ ├── security.py │ │ └── auth.py │ ├── middlewares.py │ └── main.py ├── migrations │ ├── README │ ├── script.py.mako │ ├── env.py │ └── versions │ │ └── c1a2b3c4d5e6_add_invitation_table.py ├── scripts │ ├── start │ │ ├── worker.sh │ │ ├── beat.sh │ │ ├── flower.sh │ │ └── app.sh │ ├── cryptography │ │ ├── generate_master_key.py │ │ ├── decrypt_setting.py │ │ └── encrypt_setting.py │ ├── healthchecks │ │ └── db_up_check.py │ ├── init_provider_settings.py │ └── init │ │ ├── seed_admin.py │ │ └── seed_series_types.py ├── .copier-answers.yml ├── alembic.ini ├── Dockerfile ├── config │ └── .env.example └── pyproject.toml ├── .gemini └── settings.json ├── .cursor └── rules │ └── main.mdc ├── CLAUDE.md ├── .github ├── copilot-instructions.md ├── CODEOWNERS └── PULL_REQUEST_TEMPLATE.md ├── .vscode └── settings.json ├── prestart.sh ├── .dockerignore ├── .pre-commit-config.yaml ├── LICENSE └── Makefile /docs/.nvmrc: -------------------------------------------------------------------------------- 1 | 22 -------------------------------------------------------------------------------- /.aider.md: -------------------------------------------------------------------------------- 1 | AGENTS.md -------------------------------------------------------------------------------- /GEMINI.md: -------------------------------------------------------------------------------- 1 | AGENTS.md -------------------------------------------------------------------------------- /docs/images/.keep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /docs/logo/.keep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /frontend/.nvmrc: -------------------------------------------------------------------------------- 1 | 22 -------------------------------------------------------------------------------- /.windsurfrules: -------------------------------------------------------------------------------- 1 | AGENTS.md -------------------------------------------------------------------------------- /backend/.python-version: -------------------------------------------------------------------------------- 1 | 3.13 2 | -------------------------------------------------------------------------------- /docs/api-reference/endpoint/.keep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /backend/app/integrations/sqladmin/auth.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /backend/migrations/README: -------------------------------------------------------------------------------- 1 | Generic single-database configuration. 2 | -------------------------------------------------------------------------------- /frontend/src/lib/constants/index.ts: -------------------------------------------------------------------------------- 1 | export * from './providers'; 2 | -------------------------------------------------------------------------------- /.gemini/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "contextFileName": "AGENTS.md" 3 | } 4 | -------------------------------------------------------------------------------- /docs/logo/dark.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/the-momentum/open-wearables/HEAD/docs/logo/dark.png -------------------------------------------------------------------------------- /docs/logo/light.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/the-momentum/open-wearables/HEAD/docs/logo/light.png -------------------------------------------------------------------------------- /frontend/public/robots.txt: -------------------------------------------------------------------------------- 1 | # https://www.robotstxt.org/robotstxt.html 2 | User-agent: * 3 | Disallow: 4 | -------------------------------------------------------------------------------- /backend/app/integrations/celery/__init__.py: -------------------------------------------------------------------------------- 1 | from .core import create_celery 2 | 3 | __all__ = ["create_celery"] 4 | -------------------------------------------------------------------------------- /docs/images/data-usage.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/the-momentum/open-wearables/HEAD/docs/images/data-usage.png -------------------------------------------------------------------------------- /frontend/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/the-momentum/open-wearables/HEAD/frontend/public/favicon.ico -------------------------------------------------------------------------------- /frontend/src/lib/utils/index.ts: -------------------------------------------------------------------------------- 1 | export * from './format'; 2 | export * from './health'; 3 | export * from './url'; 4 | -------------------------------------------------------------------------------- /backend/app/integrations/sqladmin/__init__.py: -------------------------------------------------------------------------------- 1 | from .views import add_admin_views 2 | 3 | __all__ = ["add_admin_views"] 4 | -------------------------------------------------------------------------------- /docs/images/data-roadmap.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/the-momentum/open-wearables/HEAD/docs/images/data-roadmap.png -------------------------------------------------------------------------------- /backend/scripts/start/worker.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -e -x 3 | 4 | uv run celery -A app.main:celery_app worker --loglevel=info 5 | -------------------------------------------------------------------------------- /docs/images/current-data-model.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/the-momentum/open-wearables/HEAD/docs/images/current-data-model.png -------------------------------------------------------------------------------- /docs/provider-setup/polar.mdx: -------------------------------------------------------------------------------- 1 | --- 2 | title: "Polar" 3 | description: "Setup guide for Polar integration" 4 | --- 5 | 6 | TODO 7 | 8 | -------------------------------------------------------------------------------- /frontend/public/apple-touch-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/the-momentum/open-wearables/HEAD/frontend/public/apple-touch-icon.png -------------------------------------------------------------------------------- /docs/provider-setup/garmin.mdx: -------------------------------------------------------------------------------- 1 | --- 2 | title: "Garmin" 3 | description: "Setup guide for Garmin integration" 4 | --- 5 | 6 | TODO 7 | 8 | -------------------------------------------------------------------------------- /docs/provider-setup/suunto.mdx: -------------------------------------------------------------------------------- 1 | --- 2 | title: "Suunto" 3 | description: "Setup guide for Suunto integration" 4 | --- 5 | 6 | TODO 7 | 8 | -------------------------------------------------------------------------------- /frontend/public/favicon-dark-16x16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/the-momentum/open-wearables/HEAD/frontend/public/favicon-dark-16x16.png -------------------------------------------------------------------------------- /frontend/public/favicon-dark-32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/the-momentum/open-wearables/HEAD/frontend/public/favicon-dark-32x32.png -------------------------------------------------------------------------------- /frontend/public/favicon-light-16x16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/the-momentum/open-wearables/HEAD/frontend/public/favicon-light-16x16.png -------------------------------------------------------------------------------- /frontend/public/favicon-light-32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/the-momentum/open-wearables/HEAD/frontend/public/favicon-light-32x32.png -------------------------------------------------------------------------------- /frontend/public/tanstack-circle-logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/the-momentum/open-wearables/HEAD/frontend/public/tanstack-circle-logo.png -------------------------------------------------------------------------------- /backend/scripts/start/beat.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -e -x 3 | 4 | rm -f './celerybeat.pid' 5 | uv run celery -A app.main:celery_app beat -l info 6 | -------------------------------------------------------------------------------- /frontend/public/android-chrome-192x192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/the-momentum/open-wearables/HEAD/frontend/public/android-chrome-192x192.png -------------------------------------------------------------------------------- /frontend/public/android-chrome-512x512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/the-momentum/open-wearables/HEAD/frontend/public/android-chrome-512x512.png -------------------------------------------------------------------------------- /backend/app/services/providers/apple/__init__.py: -------------------------------------------------------------------------------- 1 | from app.services.providers.apple.strategy import AppleStrategy 2 | 3 | __all__ = ["AppleStrategy"] 4 | -------------------------------------------------------------------------------- /backend/app/services/providers/polar/__init__.py: -------------------------------------------------------------------------------- 1 | from app.services.providers.polar.strategy import PolarStrategy 2 | 3 | __all__ = ["PolarStrategy"] 4 | -------------------------------------------------------------------------------- /docs/images/providers/provider-strategy.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/the-momentum/open-wearables/HEAD/docs/images/providers/provider-strategy.png -------------------------------------------------------------------------------- /backend/app/services/providers/garmin/__init__.py: -------------------------------------------------------------------------------- 1 | from app.services.providers.garmin.strategy import GarminStrategy 2 | 3 | __all__ = ["GarminStrategy"] 4 | -------------------------------------------------------------------------------- /docs/provider-setup/apple-health.mdx: -------------------------------------------------------------------------------- 1 | --- 2 | title: "Apple Health" 3 | description: "Setup guide for Apple Health integration" 4 | --- 5 | 6 | TODO 7 | 8 | -------------------------------------------------------------------------------- /.cursor/rules/main.mdc: -------------------------------------------------------------------------------- 1 | --- 2 | description: Main project rules - imports AGENTS.md 3 | globs: 4 | - "**/*" 5 | alwaysApply: true 6 | --- 7 | 8 | @../AGENTS.md 9 | -------------------------------------------------------------------------------- /backend/app/schemas/response.py: -------------------------------------------------------------------------------- 1 | from pydantic import BaseModel 2 | 3 | 4 | class UploadDataResponse(BaseModel): 5 | status_code: int 6 | response: str 7 | -------------------------------------------------------------------------------- /backend/scripts/cryptography/generate_master_key.py: -------------------------------------------------------------------------------- 1 | from cryptography.fernet import Fernet 2 | 3 | if __name__ == "__main__": 4 | print(Fernet.generate_key()) 5 | -------------------------------------------------------------------------------- /backend/app/__init__.py: -------------------------------------------------------------------------------- 1 | import traceback 2 | 3 | try: 4 | from app.models import * # noqa: F403 5 | except ImportError: 6 | traceback.print_exc() 7 | raise 8 | -------------------------------------------------------------------------------- /docs/images/providers/custom-providers-architecture.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/the-momentum/open-wearables/HEAD/docs/images/providers/custom-providers-architecture.png -------------------------------------------------------------------------------- /frontend/.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | dist 3 | dist-ssr 4 | *.local 5 | count.txt 6 | .env 7 | .nitro 8 | .tanstack 9 | .wrangler 10 | .output 11 | .vinxi 12 | todos.json 13 | .cursor -------------------------------------------------------------------------------- /CLAUDE.md: -------------------------------------------------------------------------------- 1 | # CLAUDE.md 2 | 3 | Please follow the guidelines and project structure defined in ./AGENTS.md 4 | 5 | For Cursor and other agents: Refer to .cursor/rules/ for detailed configuration. 6 | -------------------------------------------------------------------------------- /.github/copilot-instructions.md: -------------------------------------------------------------------------------- 1 | 2 | See [AGENTS.md](../AGENTS.md) for complete project guidelines. 3 | 4 | Include @AGENTS.md in context for all responses. 5 | -------------------------------------------------------------------------------- /frontend/.env.example: -------------------------------------------------------------------------------- 1 | # API Configuration 2 | VITE_API_URL=http://localhost:8000 3 | 4 | # Authentication (if needed) 5 | # VITE_AUTH_DOMAIN= 6 | # VITE_AUTH_CLIENT_ID= 7 | 8 | # Environment 9 | NODE_ENV=development 10 | -------------------------------------------------------------------------------- /backend/app/services/providers/__init__.py: -------------------------------------------------------------------------------- 1 | from app.services.providers.base_strategy import BaseProviderStrategy 2 | from app.services.providers.factory import ProviderFactory 3 | 4 | __all__ = ["BaseProviderStrategy", "ProviderFactory"] 5 | -------------------------------------------------------------------------------- /frontend/.dockerignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | dist 3 | .git 4 | .gitignore 5 | README.md 6 | IMPLEMENTATION_SUMMARY.md 7 | LOGIN_PAGE_GUIDE.md 8 | *.log 9 | .DS_Store 10 | coverage 11 | .env.local 12 | .env.*.local 13 | pnpm-debug.log* 14 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "python.defaultInterpreterPath": "${workspaceFolder}/backend/.venv/bin/python", 3 | "python.analysis.extraPaths": [ 4 | "${workspaceFolder}/backend" 5 | ], 6 | "makefile.configureOnOpen": false 7 | } 8 | 9 | -------------------------------------------------------------------------------- /backend/scripts/cryptography/decrypt_setting.py: -------------------------------------------------------------------------------- 1 | import sys 2 | 3 | from cryptography.fernet import Fernet 4 | 5 | if __name__ == "__main__": 6 | fernet = Fernet(sys.argv[1].encode("utf-8")) 7 | print(fernet.decrypt(sys.argv[2].encode("utf-8"))) 8 | -------------------------------------------------------------------------------- /backend/scripts/cryptography/encrypt_setting.py: -------------------------------------------------------------------------------- 1 | import sys 2 | 3 | from cryptography.fernet import Fernet 4 | 5 | if __name__ == "__main__": 6 | fernet = Fernet(sys.argv[1].encode("utf-8")) 7 | print(fernet.encrypt(sys.argv[2].encode("utf-8"))) 8 | -------------------------------------------------------------------------------- /prestart.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -e -x 3 | 4 | until uv run python -u scripts/healthchecks/db_up_check.py 5 | do 6 | echo 'Waiting for db services to become available...' 7 | sleep 1 8 | done 9 | echo 'DB containers UP, proceeding...' 10 | 11 | exec "$@" 12 | -------------------------------------------------------------------------------- /frontend/prettier.config.mjs: -------------------------------------------------------------------------------- 1 | /** @type {import("prettier").Config} */ 2 | const config = { 3 | semi: true, 4 | singleQuote: true, 5 | trailingComma: 'es5', 6 | tabWidth: 2, 7 | printWidth: 80, 8 | plugins: [], 9 | }; 10 | 11 | export default config; 12 | -------------------------------------------------------------------------------- /backend/app/services/providers/templates/__init__.py: -------------------------------------------------------------------------------- 1 | from app.services.providers.templates.base_oauth import BaseOAuthTemplate 2 | from app.services.providers.templates.base_workouts import BaseWorkoutsTemplate 3 | 4 | __all__ = ["BaseOAuthTemplate", "BaseWorkoutsTemplate"] 5 | -------------------------------------------------------------------------------- /frontend/.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "files.watcherExclude": { 3 | "**/routeTree.gen.ts": true 4 | }, 5 | "search.exclude": { 6 | "**/routeTree.gen.ts": true 7 | }, 8 | "files.readonlyInclude": { 9 | "**/routeTree.gen.ts": true 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /frontend/src/routes/users/$userId/pair.tsx: -------------------------------------------------------------------------------- 1 | import { createFileRoute, Outlet } from '@tanstack/react-router'; 2 | 3 | export const Route = createFileRoute('/users/$userId/pair')({ 4 | component: PairLayout, 5 | }); 6 | 7 | function PairLayout() { 8 | return ; 9 | } 10 | -------------------------------------------------------------------------------- /frontend/src/routes/_authenticated/users.tsx: -------------------------------------------------------------------------------- 1 | import { createFileRoute, Outlet } from '@tanstack/react-router'; 2 | 3 | export const Route = createFileRoute('/_authenticated/users')({ 4 | component: UsersLayout, 5 | }); 6 | 7 | function UsersLayout() { 8 | return ; 9 | } 10 | -------------------------------------------------------------------------------- /backend/app/integrations/sqladmin/views/__init__.py: -------------------------------------------------------------------------------- 1 | from sqladmin import Admin 2 | 3 | from .user import UserAdminView 4 | 5 | 6 | def add_admin_views(admin: Admin) -> None: 7 | views = [ 8 | UserAdminView, 9 | ] 10 | for view in views: 11 | admin.add_view(view) 12 | -------------------------------------------------------------------------------- /backend/app/api/__init__.py: -------------------------------------------------------------------------------- 1 | from fastapi import APIRouter 2 | 3 | from app.api.routes.v1 import v1_router 4 | from app.config import settings 5 | 6 | head_router = APIRouter() 7 | head_router.include_router(v1_router, prefix=settings.api_v1) 8 | 9 | __all__ = [ 10 | "head_router", 11 | ] 12 | -------------------------------------------------------------------------------- /backend/app/services/providers/suunto/__init__.py: -------------------------------------------------------------------------------- 1 | from app.services.providers.suunto.data_247 import Suunto247Data 2 | from app.services.providers.suunto.strategy import SuuntoStrategy 3 | from app.services.providers.suunto.workouts import SuuntoWorkouts 4 | 5 | __all__ = ["SuuntoStrategy", "SuuntoWorkouts", "Suunto247Data"] 6 | -------------------------------------------------------------------------------- /docs/snippets/snippet-intro.mdx: -------------------------------------------------------------------------------- 1 | One of the core principles of software development is DRY (Don't Repeat 2 | Yourself). This is a principle that applies to documentation as 3 | well. If you find yourself repeating the same content in multiple places, you 4 | should consider creating a custom snippet to keep your content in sync. 5 | -------------------------------------------------------------------------------- /.github/CODEOWNERS: -------------------------------------------------------------------------------- 1 | # CODEOWNERS - Automatically request reviews from the right people 2 | # https://docs.github.com/en/repositories/managing-your-repositorys-settings-and-features/customizing-your-repository/about-code-owners 3 | 4 | # Backend 5 | /backend/ @KaliszS @bartmichalak @czajkub 6 | 7 | # Frontend 8 | /frontend/ @farce1 9 | -------------------------------------------------------------------------------- /frontend/.cta.json: -------------------------------------------------------------------------------- 1 | { 2 | "projectName": "open-wearables-frontend", 3 | "mode": "file-router", 4 | "typescript": true, 5 | "tailwind": true, 6 | "packageManager": "npm", 7 | "git": true, 8 | "addOnOptions": {}, 9 | "version": 1, 10 | "framework": "react-cra", 11 | "chosenAddOns": ["start", "nitro"] 12 | } 13 | -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | .venv/ 2 | __pycache__/ 3 | *.pyc 4 | *.pyo 5 | 6 | *.log 7 | *.tmp 8 | *.bak 9 | 10 | .git/ 11 | .gitignore 12 | 13 | docker-compose.yml 14 | docker-compose.override.yml 15 | Dockerfile 16 | Dockerfile.* 17 | 18 | tests/ 19 | *.test.* 20 | *.spec.* 21 | 22 | setup.cfg 23 | Makefile 24 | 25 | .* 26 | 27 | *.xml 28 | *.db -------------------------------------------------------------------------------- /frontend/src/lib/api/index.ts: -------------------------------------------------------------------------------- 1 | // API services barrel export 2 | 3 | export * from './client'; 4 | export * from './config'; 5 | export * from './types'; 6 | 7 | export { authService } from './services/auth.service'; 8 | export { usersService } from './services/users.service'; 9 | export { dashboardService } from './services/dashboard.service'; 10 | -------------------------------------------------------------------------------- /backend/app/constants/series_types/__init__.py: -------------------------------------------------------------------------------- 1 | from .apple import ( 2 | get_series_type_from_healthion_type, 3 | ) 4 | from .apple import ( 5 | get_series_type_from_metric_type as get_series_type_from_apple_metric_type, 6 | ) 7 | 8 | __all__ = [ 9 | "get_series_type_from_apple_metric_type", 10 | "get_series_type_from_healthion_type", 11 | ] 12 | -------------------------------------------------------------------------------- /backend/app/integrations/sentry.py: -------------------------------------------------------------------------------- 1 | import sentry_sdk 2 | 3 | from app.config import settings 4 | 5 | 6 | def init_sentry() -> None: 7 | if settings.SENTRY_ENABLED: 8 | sentry_sdk.init( 9 | dsn=settings.SENTRY_DSN, 10 | environment=settings.SENTRY_ENV, 11 | traces_sample_rate=settings.SENTRY_SAMPLES_RATE, 12 | ) 13 | -------------------------------------------------------------------------------- /backend/.copier-answers.yml: -------------------------------------------------------------------------------- 1 | # Changes here will be overwritten by Copier 2 | _commit: 0.3.0 3 | _src_path: https://github.com/the-momentum/python-ai-kit 4 | default_python_version: '3.13' 5 | plugins: 6 | - sqladmin 7 | - celery 8 | - sentry 9 | project_description: '' 10 | project_name: open-wearables 11 | project_type: api-microservice 12 | python_versions: 13 | - '3.13' 14 | - '3.14' 15 | -------------------------------------------------------------------------------- /backend/app/models/provider_setting.py: -------------------------------------------------------------------------------- 1 | from sqlalchemy.orm import Mapped 2 | 3 | from app.database import BaseDbModel 4 | from app.mappings import PrimaryKey, str_64 5 | 6 | 7 | class ProviderSetting(BaseDbModel): 8 | """Configuration for providers (enabled/disabled).""" 9 | 10 | __tablename__ = "provider_settings" 11 | 12 | provider: Mapped[PrimaryKey[str_64]] 13 | is_enabled: Mapped[bool] 14 | -------------------------------------------------------------------------------- /frontend/.prettierignore: -------------------------------------------------------------------------------- 1 | # Dependencies 2 | node_modules 3 | 4 | # Build outputs 5 | dist 6 | build 7 | .output 8 | .nitro 9 | 10 | # Generated files 11 | *.gen.ts 12 | routeTree.gen.ts 13 | 14 | # Cache 15 | .vite 16 | .cache 17 | 18 | # Logs 19 | *.log 20 | 21 | # Environment files 22 | .env 23 | .env.local 24 | .env.*.local 25 | 26 | # IDE 27 | .vscode 28 | .idea 29 | 30 | # Test coverage 31 | coverage 32 | -------------------------------------------------------------------------------- /frontend/src/router.tsx: -------------------------------------------------------------------------------- 1 | import { createRouter } from '@tanstack/react-router'; 2 | 3 | // Import the generated route tree 4 | import { routeTree } from './routeTree.gen'; 5 | 6 | // Create a new router instance 7 | export const getRouter = () => { 8 | const router = createRouter({ 9 | routeTree, 10 | scrollRestoration: true, 11 | defaultPreloadStaleTime: 0, 12 | }); 13 | 14 | return router; 15 | }; 16 | -------------------------------------------------------------------------------- /backend/app/integrations/sqladmin/views/user.py: -------------------------------------------------------------------------------- 1 | from app.integrations.sqladmin.base_view import BaseAdminView 2 | from app.models.user import User 3 | from app.schemas.user import UserCreate, UserUpdate 4 | 5 | 6 | class UserAdminView( 7 | BaseAdminView, 8 | model=User, 9 | create_schema=UserCreate, 10 | update_schema=UserUpdate, 11 | column={"searchable": ["username", "email"]}, 12 | ): 13 | pass 14 | -------------------------------------------------------------------------------- /backend/app/repositories/developer_repository.py: -------------------------------------------------------------------------------- 1 | from app.models import Developer 2 | from app.repositories.repositories import CrudRepository 3 | from app.schemas.developer import DeveloperCreateInternal, DeveloperUpdateInternal 4 | 5 | 6 | class DeveloperRepository(CrudRepository[Developer, DeveloperCreateInternal, DeveloperUpdateInternal]): 7 | def __init__(self, model: type[Developer]): 8 | super().__init__(model) 9 | -------------------------------------------------------------------------------- /frontend/src/lib/utils/url.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Appends non-null, non-empty values to URLSearchParams 3 | */ 4 | export function appendSearchParams( 5 | searchParams: URLSearchParams, 6 | params: Record 7 | ): void { 8 | for (const [key, value] of Object.entries(params)) { 9 | if (value != null && value !== '') { 10 | searchParams.set(key, String(value)); 11 | } 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /backend/scripts/start/flower.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -e -x 3 | 4 | worker_ready() { 5 | uv run celery -A app.main:celery_app inspect ping 6 | } 7 | 8 | until worker_ready; do 9 | echo 'Celery workers not available...' 10 | sleep 1 11 | done 12 | echo 'Celery workers are available, proceeding...' 13 | 14 | # Flower will use the broker URL from Celery app configuration (settings.redis_url) 15 | uv run celery --app=app.main:celery_app flower 16 | -------------------------------------------------------------------------------- /backend/app/models/device_software.py: -------------------------------------------------------------------------------- 1 | from uuid import UUID 2 | 3 | from sqlalchemy.orm import Mapped 4 | 5 | from app.database import BaseDbModel 6 | from app.mappings import FKDevice, PrimaryKey, str_100 7 | 8 | 9 | class DeviceSoftware(BaseDbModel): 10 | """A version is a specific version of a device.""" 11 | 12 | __tablename__ = "device_software" 13 | 14 | id: Mapped[PrimaryKey[UUID]] 15 | device_id: Mapped[FKDevice] 16 | version: Mapped[str_100] 17 | -------------------------------------------------------------------------------- /backend/app/utils/requests_extensions.py: -------------------------------------------------------------------------------- 1 | from requests import PreparedRequest 2 | from requests.auth import AuthBase 3 | from requests.structures import CaseInsensitiveDict 4 | 5 | 6 | class BearerAuth(AuthBase): 7 | def __init__(self, token: str): 8 | self.token = token 9 | 10 | def __call__(self, r: PreparedRequest): 11 | r.headers = r.headers or CaseInsensitiveDict() 12 | r.headers["Authorization"] = f"Bearer {self.token}" 13 | return r 14 | -------------------------------------------------------------------------------- /backend/app/models/series_type_definition.py: -------------------------------------------------------------------------------- 1 | from sqlalchemy.orm import Mapped 2 | 3 | from app.database import BaseDbModel 4 | from app.mappings import PrimaryKey, Unique, str_10, str_64 5 | 6 | 7 | class SeriesTypeDefinition(BaseDbModel): 8 | """Defines the available time-series types and their canonical units.""" 9 | 10 | __tablename__ = "series_type_definition" 11 | 12 | id: Mapped[PrimaryKey[int]] 13 | code: Mapped[Unique[str_64]] 14 | unit: Mapped[str_10] 15 | -------------------------------------------------------------------------------- /backend/app/integrations/celery/tasks/__init__.py: -------------------------------------------------------------------------------- 1 | from .periodic_sync_task import sync_all_users 2 | from .poll_sqs_task import poll_sqs_task 3 | from .process_upload_task import process_uploaded_file 4 | from .send_email_task import send_invitation_email_task 5 | from .sync_vendor_data_task import sync_vendor_data 6 | 7 | __all__ = [ 8 | "poll_sqs_task", 9 | "process_uploaded_file", 10 | "sync_vendor_data", 11 | "sync_all_users", 12 | "send_invitation_email_task", 13 | ] 14 | -------------------------------------------------------------------------------- /backend/scripts/healthchecks/db_up_check.py: -------------------------------------------------------------------------------- 1 | import os 2 | import sys 3 | 4 | import psycopg 5 | 6 | try: 7 | psycopg.connect( 8 | dbname=os.getenv("DB_NAME", ""), 9 | user=os.getenv("DB_USER", ""), 10 | password=os.getenv("DB_PASSWORD", ""), 11 | host=os.getenv("DB_HOST", ""), 12 | port=os.getenv("DB_PORT", ""), 13 | ) 14 | except psycopg.OperationalError: 15 | print("- PostgreSQL unavaliable - waiting") 16 | sys.exit(-1) 17 | sys.exit(0) 18 | -------------------------------------------------------------------------------- /frontend/src/lib/utils.ts: -------------------------------------------------------------------------------- 1 | import { clsx, type ClassValue } from 'clsx'; 2 | import { twMerge } from 'tailwind-merge'; 3 | 4 | export function cn(...inputs: ClassValue[]) { 5 | return twMerge(clsx(inputs)); 6 | } 7 | 8 | export function isValidEmail(email: string) { 9 | return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email); 10 | } 11 | 12 | export function truncateId(id: string, maxLength = 12) { 13 | if (id.length <= maxLength) return id; 14 | return `${id.slice(0, 8)}...${id.slice(-4)}`; 15 | } 16 | -------------------------------------------------------------------------------- /frontend/src/lib/api/services/developers.service.ts: -------------------------------------------------------------------------------- 1 | import { apiClient } from '../client'; 2 | import { API_ENDPOINTS } from '../config'; 3 | import type { Developer } from '../types'; 4 | 5 | export const developersService = { 6 | async getDevelopers(): Promise { 7 | return apiClient.get(API_ENDPOINTS.developers); 8 | }, 9 | 10 | async deleteDeveloper(id: string): Promise { 11 | return apiClient.delete(API_ENDPOINTS.developerDetail(id)); 12 | }, 13 | }; 14 | -------------------------------------------------------------------------------- /backend/app/models/api_key.py: -------------------------------------------------------------------------------- 1 | from sqlalchemy.orm import Mapped 2 | 3 | from app.database import BaseDbModel 4 | from app.mappings import FKDeveloper, PrimaryKey, datetime_tz, str_64 5 | 6 | 7 | class ApiKey(BaseDbModel): 8 | """Global API key for external service access.""" 9 | 10 | __tablename__ = "api_key" 11 | 12 | id: Mapped[PrimaryKey[str_64]] # The actual key value (sk-...) 13 | name: Mapped[str] 14 | created_by: Mapped[FKDeveloper | None] 15 | created_at: Mapped[datetime_tz] 16 | -------------------------------------------------------------------------------- /backend/app/api/routes/v1/dashboard.py: -------------------------------------------------------------------------------- 1 | from fastapi import APIRouter 2 | 3 | from app.database import DbSession 4 | from app.schemas.system_info import SystemInfoResponse 5 | from app.services import DeveloperDep, system_info_service 6 | 7 | router = APIRouter() 8 | 9 | 10 | @router.get("/stats", response_model=SystemInfoResponse, tags=["dashboard"]) 11 | async def get_stats(db: DbSession, _developer: DeveloperDep): 12 | """Get system dashboard statistics.""" 13 | return system_info_service.get_system_info(db) 14 | -------------------------------------------------------------------------------- /frontend/components.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://ui.shadcn.com/schema.json", 3 | "style": "default", 4 | "rsc": false, 5 | "tsx": true, 6 | "tailwind": { 7 | "config": "tailwind.config.ts", 8 | "css": "src/styles.css", 9 | "baseColor": "slate", 10 | "cssVariables": true, 11 | "prefix": "" 12 | }, 13 | "aliases": { 14 | "components": "@/components", 15 | "utils": "@/lib/utils", 16 | "ui": "@/components/ui", 17 | "lib": "@/lib", 18 | "hooks": "@/hooks" 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /frontend/public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "short_name": "Open Wearables Platform", 3 | "name": "Open Wearables Platform", 4 | "icons": [ 5 | { 6 | "src": "/android-chrome-192x192.png", 7 | "sizes": "192x192", 8 | "type": "image/png" 9 | }, 10 | { 11 | "src": "/android-chrome-512x512.png", 12 | "sizes": "512x512", 13 | "type": "image/png" 14 | } 15 | ], 16 | "start_url": ".", 17 | "display": "standalone", 18 | "theme_color": "#000000", 19 | "background_color": "#ffffff" 20 | } 21 | -------------------------------------------------------------------------------- /backend/app/models/device.py: -------------------------------------------------------------------------------- 1 | from uuid import UUID 2 | 3 | from sqlalchemy.orm import Mapped 4 | 5 | from app.database import BaseDbModel 6 | from app.mappings import OneToMany, PrimaryKey, str_10, str_100 7 | 8 | 9 | class Device(BaseDbModel): 10 | """A device is a physical device that can be used to collect data.""" 11 | 12 | id: Mapped[PrimaryKey[UUID]] 13 | 14 | serial_number: Mapped[str_100] 15 | provider_name: Mapped[str_10] 16 | name: Mapped[str_100] 17 | 18 | versions: Mapped[OneToMany["DeviceSoftware"]] 19 | -------------------------------------------------------------------------------- /backend/scripts/start/app.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -e -x 3 | 4 | # Init database 5 | echo 'Applying migrations...' 6 | uv run alembic upgrade head 7 | 8 | # Initialize provider settings 9 | echo 'Initializing provider settings...' 10 | uv run python scripts/init_provider_settings.py 11 | 12 | # Init app 13 | echo "Starting the FastAPI application..." 14 | if [ "$ENVIRONMENT" = "local" ]; then 15 | uv run fastapi dev app/main.py --host 0.0.0.0 --port 8000 16 | else 17 | uv run fastapi run app/main.py --host 0.0.0.0 --port 8000 18 | fi 19 | -------------------------------------------------------------------------------- /backend/app/services/apple/apple_xml/aws_service.py: -------------------------------------------------------------------------------- 1 | import boto3 2 | from botocore.exceptions import NoCredentialsError 3 | 4 | from app.config import settings 5 | 6 | AWS_BUCKET_NAME = settings.aws_bucket_name 7 | AWS_REGION = settings.aws_region 8 | 9 | try: 10 | s3_client = boto3.client( 11 | "s3", 12 | region_name=AWS_REGION, 13 | aws_access_key_id=settings.aws_access_key_id, 14 | aws_secret_access_key=settings.aws_secret_access_key, 15 | ) 16 | except NoCredentialsError: 17 | raise Exception("AWS credentials not configured") 18 | -------------------------------------------------------------------------------- /backend/app/models/event_record_detail.py: -------------------------------------------------------------------------------- 1 | from sqlalchemy.orm import Mapped 2 | 3 | from app.database import BaseDbModel 4 | from app.mappings import FKEventRecord, str_32 5 | 6 | 7 | class EventRecordDetail(BaseDbModel): 8 | """Base polymorphic detail model used by specific aggregates (workout, sleep, etc.).""" 9 | 10 | __tablename__ = "event_record_detail" 11 | 12 | record_id: Mapped[FKEventRecord] 13 | detail_type: Mapped[str_32] 14 | 15 | __mapper_args__ = { 16 | "polymorphic_on": "detail_type", 17 | "polymorphic_identity": "base", 18 | } 19 | -------------------------------------------------------------------------------- /backend/app/schemas/sync.py: -------------------------------------------------------------------------------- 1 | from typing import Any 2 | from uuid import UUID 3 | 4 | from pydantic import BaseModel 5 | 6 | 7 | class ProviderSyncResult(BaseModel): 8 | success: bool 9 | params: dict[str, Any] 10 | 11 | 12 | class SyncVendorDataResult(BaseModel): 13 | user_id: UUID | str 14 | start_date: str | None = None 15 | end_date: str | None = None 16 | providers_synced: dict[str, ProviderSyncResult] = {} 17 | errors: dict[str, str] = {} 18 | message: str | None = None 19 | 20 | 21 | class SyncAllUsersResult(BaseModel): 22 | users_for_sync: int 23 | -------------------------------------------------------------------------------- /backend/app/utils/conversion.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | 3 | from sqlalchemy.inspection import inspect 4 | 5 | from app.database import BaseDbModel 6 | 7 | 8 | def base_to_dict(instance: BaseDbModel) -> dict[str, str | None]: 9 | """Function to convert SQLALchemy Base model into dict.""" 10 | b2d = {} 11 | for column in inspect(instance).mapper.column_attrs: 12 | value = getattr(instance, column.key) 13 | 14 | if isinstance(value, (datetime)): 15 | value = value.isoformat() 16 | 17 | b2d[column.key] = value 18 | 19 | return b2d 20 | -------------------------------------------------------------------------------- /backend/app/models/developer.py: -------------------------------------------------------------------------------- 1 | from uuid import UUID 2 | 3 | from sqlalchemy.orm import Mapped 4 | 5 | from app.database import BaseDbModel 6 | from app.mappings import PrimaryKey, Unique, datetime_tz, str_100, str_255 7 | 8 | 9 | class Developer(BaseDbModel): 10 | """Admin of the portal model""" 11 | 12 | id: Mapped[PrimaryKey[UUID]] 13 | created_at: Mapped[datetime_tz] 14 | updated_at: Mapped[datetime_tz] 15 | 16 | first_name: Mapped[str_100 | None] 17 | last_name: Mapped[str_100 | None] 18 | email: Mapped[Unique[str_255]] 19 | hashed_password: Mapped[str_255] 20 | -------------------------------------------------------------------------------- /frontend/src/components/ui/skeleton.tsx: -------------------------------------------------------------------------------- 1 | import { cn } from '@/lib/utils'; 2 | 3 | function Skeleton({ 4 | className, 5 | ...props 6 | }: React.HTMLAttributes) { 7 | return ( 8 | 18 | ); 19 | } 20 | 21 | export { Skeleton }; 22 | -------------------------------------------------------------------------------- /backend/app/middlewares.py: -------------------------------------------------------------------------------- 1 | from fastapi import FastAPI 2 | from fastapi.middleware.cors import CORSMiddleware 3 | 4 | from app.config import settings 5 | 6 | 7 | def add_cors_middleware(app: FastAPI) -> None: 8 | cors_origins = [str(origin).rstrip("/") for origin in settings.cors_origins] 9 | if settings.cors_allow_all: 10 | cors_origins = ["*"] 11 | 12 | app.add_middleware( 13 | CORSMiddleware, # type: ignore[arg-type] 14 | allow_origins=cors_origins, 15 | allow_credentials=True, 16 | allow_methods=["*"], 17 | allow_headers=["*"], 18 | ) 19 | -------------------------------------------------------------------------------- /backend/app/schemas/apple/healthkit/workout_import.py: -------------------------------------------------------------------------------- 1 | # ruff: noqa: N815 2 | 3 | from __future__ import annotations 4 | 5 | from datetime import datetime 6 | 7 | from pydantic import BaseModel 8 | 9 | 10 | class WorkoutJSON(BaseModel): 11 | uuid: str | None = None 12 | user_id: str | None = None 13 | type: str | None = None 14 | startDate: datetime 15 | endDate: datetime 16 | sourceName: str | None = None 17 | workoutStatistics: list[WorkoutStatisticJSON] | None = None 18 | 19 | 20 | class WorkoutStatisticJSON(BaseModel): 21 | type: str 22 | unit: str 23 | value: float | int 24 | -------------------------------------------------------------------------------- /backend/app/schemas/error_codes.py: -------------------------------------------------------------------------------- 1 | from enum import Enum 2 | 3 | 4 | class ErrorCode(str, Enum): 5 | AUTHENTICATION_ERROR = "authentication_failed" 6 | VALIDATION_ERRROR = "validation_failed" 7 | OBJECT_NOT_FOUND = "object_not_found" 8 | WORKFLOW_TIMED_OUT = "workflow_timed_out" 9 | WORKFLOW_RUNTIME_ERROR = "workflow_runtime_error" 10 | RATE_LIMIT_EXCEEDED = "rate_limit_exceeded" 11 | MAX_REQUESTS_EXCEEDED = "max_requests_exceeded" 12 | ACTIVE_SESSION_DROPPED = "active_session_dropped" 13 | INACTIVE_SESSION_ACCESSED = "inactive_session_accessed" 14 | OPENAI_ERROR = "openai_error" 15 | -------------------------------------------------------------------------------- /frontend/src/lib/auth/types.ts: -------------------------------------------------------------------------------- 1 | export interface LoginCredentials { 2 | email: string; 3 | password: string; 4 | } 5 | 6 | export interface RegisterData { 7 | email: string; 8 | password: string; 9 | name?: string; 10 | } 11 | 12 | export interface AuthResponse { 13 | access_token: string; 14 | token_type: string; 15 | developer_id: string; 16 | } 17 | 18 | export interface AuthUser { 19 | id: string; 20 | email: string; 21 | name?: string; 22 | } 23 | 24 | export interface AuthState { 25 | isAuthenticated: boolean; 26 | user: AuthUser | null; 27 | isLoading: boolean; 28 | error: string | null; 29 | } 30 | -------------------------------------------------------------------------------- /backend/app/schemas/api_key.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime, timezone 2 | from uuid import UUID 3 | 4 | from pydantic import BaseModel, ConfigDict, Field 5 | 6 | 7 | class ApiKeyRead(BaseModel): 8 | model_config = ConfigDict(from_attributes=True) 9 | 10 | id: str 11 | name: str 12 | created_by: UUID | None 13 | created_at: datetime 14 | 15 | 16 | class ApiKeyCreate(BaseModel): 17 | id: str 18 | name: str 19 | created_by: UUID | None = None 20 | created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc)) 21 | 22 | 23 | class ApiKeyUpdate(BaseModel): 24 | name: str | None = None 25 | -------------------------------------------------------------------------------- /frontend/Dockerfile.dev: -------------------------------------------------------------------------------- 1 | # Development Dockerfile for hot reload 2 | FROM node:22-alpine 3 | 4 | # Install pnpm 5 | RUN corepack enable && corepack prepare pnpm@10.13.1 --activate 6 | 7 | WORKDIR /app 8 | 9 | # Copy package files 10 | COPY package.json pnpm-lock.yaml ./ 11 | 12 | # Install dependencies 13 | RUN pnpm install --frozen-lockfile 14 | 15 | # Copy source files 16 | COPY . . 17 | 18 | # Expose port 19 | EXPOSE 3000 20 | 21 | # Set environment for development 22 | ENV NODE_ENV=development 23 | ENV VITE_API_URL=http://localhost:8000 24 | 25 | # Run the dev server with host binding for Docker 26 | CMD ["pnpm", "dev", "--host", "0.0.0.0"] 27 | -------------------------------------------------------------------------------- /backend/app/integrations/redis_client.py: -------------------------------------------------------------------------------- 1 | """Centralized Redis client for the application.""" 2 | 3 | from functools import lru_cache 4 | 5 | import redis 6 | 7 | from app.config import settings 8 | 9 | 10 | @lru_cache() 11 | def get_redis_client() -> redis.Redis: 12 | """ 13 | Get a singleton Redis client instance. 14 | 15 | Uses LRU cache to ensure only one Redis client instance is created 16 | and reused across the application. 17 | 18 | Returns: 19 | redis.Redis: Configured Redis client instance 20 | """ 21 | return redis.from_url( 22 | settings.redis_url, 23 | decode_responses=True, 24 | ) 25 | -------------------------------------------------------------------------------- /backend/app/repositories/api_key_repository.py: -------------------------------------------------------------------------------- 1 | from app.database import DbSession 2 | from app.models import ApiKey 3 | from app.repositories.repositories import CrudRepository 4 | from app.schemas.api_key import ApiKeyCreate, ApiKeyUpdate 5 | 6 | 7 | class ApiKeyRepository(CrudRepository[ApiKey, ApiKeyCreate, ApiKeyUpdate]): 8 | def __init__(self, model: type[ApiKey]): 9 | super().__init__(model) 10 | 11 | def get_all_ordered(self, db_session: DbSession) -> list[ApiKey]: 12 | """Get all API keys ordered by creation date descending.""" 13 | return db_session.query(self.model).order_by(self.model.created_at.desc()).all() 14 | -------------------------------------------------------------------------------- /backend/app/constants/workout_types/__init__.py: -------------------------------------------------------------------------------- 1 | from .apple import get_healthkit_activity_name 2 | from .apple import get_unified_workout_type as get_unified_apple_workout_type 3 | from .garmin import get_unified_workout_type as get_unified_garmin_workout_type 4 | from .polar import get_unified_workout_type as get_unified_polar_workout_type 5 | from .suunto import get_unified_workout_type as get_unified_suunto_workout_type 6 | 7 | __all__ = [ 8 | "get_healthkit_activity_name", 9 | "get_unified_apple_workout_type", 10 | "get_unified_garmin_workout_type", 11 | "get_unified_polar_workout_type", 12 | "get_unified_suunto_workout_type", 13 | ] 14 | -------------------------------------------------------------------------------- /backend/app/schemas/garmin/activity_import.py: -------------------------------------------------------------------------------- 1 | from pydantic import BaseModel, ConfigDict 2 | 3 | 4 | class ActivityJSON(BaseModel): 5 | model_config = ConfigDict(populate_by_name=True) 6 | 7 | userId: str 8 | activityId: str 9 | summaryId: str 10 | activityType: str 11 | startTimeInSeconds: int 12 | durationInSeconds: int 13 | deviceName: str 14 | 15 | distanceInMeters: int 16 | steps: int 17 | activeKilocalories: int 18 | averageHeartRateInBeatsPerMinute: int 19 | maxHeartRateInBeatsPerMinute: int 20 | 21 | 22 | class RootJSON(BaseModel): 23 | activities: list[ActivityJSON] 24 | error: str | None = None 25 | -------------------------------------------------------------------------------- /backend/app/services/providers/apple/handlers/healthkit.py: -------------------------------------------------------------------------------- 1 | from typing import Any 2 | 3 | from app.schemas.event_record import EventRecordCreate 4 | from app.schemas.event_record_detail import EventRecordDetailCreate 5 | from app.services.providers.apple.handlers.base import AppleSourceHandler 6 | 7 | 8 | class HealthKitHandler(AppleSourceHandler): 9 | """Handler for direct HealthKit export data.""" 10 | 11 | def normalize(self, data: Any) -> list[tuple[EventRecordCreate, EventRecordDetailCreate]]: 12 | # TODO: Implement HealthKit specific normalization logic 13 | # This is where we parse the payload from our HealthKit integration 14 | return [] 15 | -------------------------------------------------------------------------------- /backend/app/models/personal_record.py: -------------------------------------------------------------------------------- 1 | from uuid import UUID 2 | 3 | from sqlalchemy.orm import Mapped, relationship 4 | 5 | from app.database import BaseDbModel 6 | from app.mappings import PrimaryKey, UniqueFkUser, date_col, str_32 7 | 8 | 9 | class PersonalRecord(BaseDbModel): 10 | """Slow-changing physical attributes linked to a user.""" 11 | 12 | __tablename__ = "personal_record" 13 | 14 | id: Mapped[PrimaryKey[UUID]] 15 | user_id: Mapped[UniqueFkUser] 16 | 17 | birth_date: Mapped[date_col | None] 18 | sex: Mapped[bool | None] 19 | gender: Mapped[str_32 | None] 20 | 21 | user: Mapped["User"] = relationship(back_populates="personal_record") 22 | -------------------------------------------------------------------------------- /backend/app/services/providers/apple/handlers/auto_export.py: -------------------------------------------------------------------------------- 1 | from typing import Any 2 | 3 | from app.schemas.event_record import EventRecordCreate 4 | from app.schemas.event_record_detail import EventRecordDetailCreate 5 | from app.services.providers.apple.handlers.base import AppleSourceHandler 6 | 7 | 8 | class AutoExportHandler(AppleSourceHandler): 9 | """Handler for Apple Health 'Auto Export' app data.""" 10 | 11 | def normalize(self, data: Any) -> list[tuple[EventRecordCreate, EventRecordDetailCreate]]: 12 | # TODO: Implement Auto Export specific normalization logic 13 | # This is where we parse the JSON structure from Auto Export 14 | return [] 15 | -------------------------------------------------------------------------------- /frontend/src/hooks/use-mobile.ts: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | 3 | const MOBILE_BREAKPOINT = 768; 4 | 5 | export function useIsMobile() { 6 | const [isMobile, setIsMobile] = React.useState( 7 | undefined 8 | ); 9 | 10 | React.useEffect(() => { 11 | const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`); 12 | const onChange = () => { 13 | setIsMobile(window.innerWidth < MOBILE_BREAKPOINT); 14 | }; 15 | mql.addEventListener('change', onChange); 16 | setIsMobile(window.innerWidth < MOBILE_BREAKPOINT); 17 | return () => mql.removeEventListener('change', onChange); 18 | }, []); 19 | 20 | return !!isMobile; 21 | } 22 | -------------------------------------------------------------------------------- /backend/app/schemas/external_mapping.py: -------------------------------------------------------------------------------- 1 | from uuid import UUID 2 | 3 | from pydantic import BaseModel 4 | 5 | 6 | class ExternalMappingBase(BaseModel): 7 | """Shared fields for mapping external provider/device identifiers.""" 8 | 9 | user_id: UUID 10 | provider_name: str 11 | device_id: str | None = None 12 | 13 | 14 | class ExternalMappingCreate(ExternalMappingBase): 15 | """Payload used when persisting a new mapping.""" 16 | 17 | id: UUID 18 | 19 | 20 | class ExternalMappingUpdate(ExternalMappingBase): 21 | """Payload used when adjusting an existing mapping.""" 22 | 23 | 24 | class ExternalMappingResponse(ExternalMappingBase): 25 | """Representation returned via APIs.""" 26 | 27 | id: UUID 28 | -------------------------------------------------------------------------------- /backend/migrations/script.py.mako: -------------------------------------------------------------------------------- 1 | """${message} 2 | 3 | Revision ID: ${up_revision} 4 | Revises: ${down_revision | comma,n} 5 | 6 | """ 7 | from typing import Sequence, Union 8 | 9 | from alembic import op 10 | import sqlalchemy as sa 11 | ${imports if imports else ""} 12 | 13 | # revision identifiers, used by Alembic. 14 | revision: str = ${repr(up_revision)} 15 | down_revision: Union[str, None] = ${repr(down_revision)} 16 | branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)} 17 | depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)} 18 | 19 | 20 | def upgrade() -> None: 21 | ${upgrades if upgrades else "pass"} 22 | 23 | 24 | def downgrade() -> None: 25 | ${downgrades if downgrades else "pass"} 26 | -------------------------------------------------------------------------------- /backend/app/services/providers/apple/strategy.py: -------------------------------------------------------------------------------- 1 | from app.services.providers.apple.workouts import AppleWorkouts 2 | from app.services.providers.base_strategy import BaseProviderStrategy 3 | 4 | 5 | class AppleStrategy(BaseProviderStrategy): 6 | """Apple Health provider implementation.""" 7 | 8 | def __init__(self): 9 | super().__init__() 10 | self.workouts = AppleWorkouts(self.workout_repo, self.connection_repo) 11 | 12 | @property 13 | def name(self) -> str: 14 | return "apple" 15 | 16 | @property 17 | def display_name(self) -> str: 18 | return "Apple Health" 19 | 20 | @property 21 | def api_base_url(self) -> str: 22 | return "" # Apple Health doesn't have a cloud API 23 | -------------------------------------------------------------------------------- /backend/app/api/routes/v1/workouts.py: -------------------------------------------------------------------------------- 1 | from typing import Annotated 2 | 3 | from fastapi import APIRouter, Depends 4 | 5 | from app.database import DbSession 6 | from app.schemas import EventRecordQueryParams, EventRecordResponse 7 | from app.services import ApiKeyDep, event_record_service 8 | 9 | router = APIRouter() 10 | 11 | 12 | @router.get("/users/{user_id}/workouts", response_model=list[EventRecordResponse]) 13 | async def get_workouts_endpoint( 14 | user_id: str, 15 | db: DbSession, 16 | _api_key: ApiKeyDep, 17 | query_params: Annotated[EventRecordQueryParams, Depends()], 18 | ): 19 | """Get workouts with filtering, sorting, and pagination.""" 20 | return await event_record_service.get_records_response(db, query_params, user_id) 21 | -------------------------------------------------------------------------------- /backend/alembic.ini: -------------------------------------------------------------------------------- 1 | [alembic] 2 | script_location = migrations 3 | prepend_sys_path = . 4 | version_path_separator = os 5 | sqlalchemy.url = 6 | 7 | [loggers] 8 | keys = root,sqlalchemy,alembic 9 | 10 | [handlers] 11 | keys = console 12 | 13 | [formatters] 14 | keys = generic 15 | 16 | [logger_root] 17 | level = WARN 18 | handlers = console 19 | qualname = 20 | 21 | [logger_sqlalchemy] 22 | level = WARN 23 | handlers = 24 | qualname = sqlalchemy.engine 25 | 26 | [logger_alembic] 27 | level = INFO 28 | handlers = 29 | qualname = alembic 30 | 31 | [handler_console] 32 | class = StreamHandler 33 | args = (sys.stderr,) 34 | level = NOTSET 35 | formatter = generic 36 | 37 | [formatter_generic] 38 | format = %(levelname)-5.5s [%(name)s] %(message)s 39 | datefmt = %H:%M:%S 40 | -------------------------------------------------------------------------------- /backend/app/models/user.py: -------------------------------------------------------------------------------- 1 | from uuid import UUID 2 | 3 | from sqlalchemy.orm import Mapped, relationship 4 | 5 | from app.database import BaseDbModel 6 | from app.mappings import PrimaryKey, Unique, datetime_tz, email, str_100, str_255 7 | 8 | 9 | class User(BaseDbModel): 10 | """Data owner model""" 11 | 12 | id: Mapped[PrimaryKey[UUID]] 13 | created_at: Mapped[datetime_tz] 14 | 15 | first_name: Mapped[str_100 | None] 16 | last_name: Mapped[str_100 | None] 17 | email: Mapped[email | None] 18 | 19 | external_user_id: Mapped[Unique[str_255] | None] 20 | 21 | personal_record: Mapped["PersonalRecord | None"] = relationship( 22 | back_populates="user", 23 | uselist=False, 24 | cascade="all, delete-orphan", 25 | ) 26 | -------------------------------------------------------------------------------- /backend/app/api/routes/v1/heart_rate.py: -------------------------------------------------------------------------------- 1 | from typing import Annotated 2 | 3 | from fastapi import APIRouter, Depends 4 | 5 | from app.database import DbSession 6 | from app.schemas import HeartRateSampleResponse, TimeSeriesQueryParams 7 | from app.services import ApiKeyDep, timeseries_service 8 | 9 | router = APIRouter() 10 | 11 | 12 | @router.get("/users/{user_id}/heart-rate", response_model=list[HeartRateSampleResponse]) 13 | async def get_heart_rate_endpoint( 14 | user_id: str, 15 | db: DbSession, 16 | _api_key: ApiKeyDep, 17 | query_params: Annotated[TimeSeriesQueryParams, Depends()], 18 | ): 19 | """Get heart rate data with filtering, sorting, and pagination.""" 20 | return await timeseries_service.get_user_heart_rate_series(db, user_id, query_params) 21 | -------------------------------------------------------------------------------- /frontend/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "include": ["**/*.ts", "**/*.tsx"], 3 | "compilerOptions": { 4 | "target": "ES2022", 5 | "jsx": "react-jsx", 6 | "module": "ESNext", 7 | "lib": ["ES2022", "DOM", "DOM.Iterable"], 8 | "types": ["vite/client"], 9 | 10 | /* Bundler mode */ 11 | "moduleResolution": "bundler", 12 | "allowImportingTsExtensions": true, 13 | "verbatimModuleSyntax": false, 14 | "noEmit": true, 15 | 16 | /* Linting */ 17 | "skipLibCheck": true, 18 | "strict": true, 19 | "noUnusedLocals": true, 20 | "noUnusedParameters": true, 21 | "noFallthroughCasesInSwitch": true, 22 | "noUncheckedSideEffectImports": true, 23 | "baseUrl": ".", 24 | "paths": { 25 | "@/*": ["src/*"] 26 | } 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /backend/app/api/routes/v1/import_xml.py: -------------------------------------------------------------------------------- 1 | from fastapi import APIRouter 2 | 3 | from app.integrations.celery.tasks.poll_sqs_task import poll_sqs_task 4 | from app.schemas import PresignedURLRequest, PresignedURLResponse 5 | from app.services import ApiKeyDep, pre_url_service 6 | 7 | router = APIRouter() 8 | 9 | 10 | @router.post("/users/{user_id}/import/apple/xml") 11 | async def import_xml( 12 | user_id: str, 13 | request: PresignedURLRequest, 14 | _api_key: ApiKeyDep, 15 | ) -> PresignedURLResponse: 16 | """Generate presigned URL for XML file upload and trigger processing task.""" 17 | presigned_response = pre_url_service.create_presigned_url(user_id, request) 18 | 19 | poll_sqs_task.delay(presigned_response.expires_in) 20 | 21 | return presigned_response 22 | -------------------------------------------------------------------------------- /backend/app/api/routes/v1/connections.py: -------------------------------------------------------------------------------- 1 | from uuid import UUID 2 | 3 | from fastapi import APIRouter 4 | 5 | from app.database import DbSession 6 | from app.repositories import UserConnectionRepository 7 | from app.schemas import UserConnectionRead 8 | from app.services import ApiKeyDep 9 | 10 | router = APIRouter() 11 | connection_repo = UserConnectionRepository() 12 | 13 | 14 | @router.get("/users/{user_id}/connections", response_model=list[UserConnectionRead]) 15 | async def get_connections_endpoint( 16 | user_id: str, 17 | db: DbSession, 18 | _api_key: ApiKeyDep, 19 | ): 20 | """Get all connections for a user.""" 21 | connections = connection_repo.get_by_user_id(db, UUID(user_id)) 22 | return [UserConnectionRead.model_validate(conn) for conn in connections] 23 | -------------------------------------------------------------------------------- /backend/app/schemas/personal_record.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from datetime import date 4 | from typing import Literal 5 | from uuid import UUID 6 | 7 | from pydantic import BaseModel, Field 8 | 9 | 10 | class PersonalRecordBase(BaseModel): 11 | birth_date: date | None = Field(None, description="Birth date of the user") 12 | gender: Literal["female", "male", "nonbinary", "other"] | None = Field( 13 | None, 14 | description="Optional self-reported gender", 15 | ) 16 | 17 | 18 | class PersonalRecordCreate(PersonalRecordBase): 19 | id: UUID 20 | user_id: UUID 21 | 22 | 23 | class PersonalRecordUpdate(PersonalRecordBase): ... 24 | 25 | 26 | class PersonalRecordResponse(PersonalRecordBase): 27 | id: UUID 28 | user_id: UUID 29 | -------------------------------------------------------------------------------- /backend/app/services/providers/apple/handlers/base.py: -------------------------------------------------------------------------------- 1 | from abc import ABC, abstractmethod 2 | from typing import Any 3 | 4 | from app.schemas.event_record import EventRecordCreate 5 | from app.schemas.event_record_detail import EventRecordDetailCreate 6 | 7 | 8 | class AppleSourceHandler(ABC): 9 | """Base interface for Apple Health data source handlers.""" 10 | 11 | @abstractmethod 12 | def normalize(self, data: Any) -> list[tuple[EventRecordCreate, EventRecordDetailCreate]]: 13 | """Normalizes raw data from a specific Apple source into unified event records. 14 | 15 | Args: 16 | data: The raw data payload. 17 | 18 | Returns: 19 | List of (EventRecordCreate, EventRecordDetailCreate) tuples. 20 | """ 21 | pass 22 | -------------------------------------------------------------------------------- /backend/scripts/init_provider_settings.py: -------------------------------------------------------------------------------- 1 | """Initialize provider settings table with all available providers.""" 2 | 3 | from app.database import SessionLocal 4 | from app.repositories.provider_settings_repository import ProviderSettingsRepository 5 | from app.schemas import ProviderName 6 | 7 | 8 | def init_provider_settings() -> None: 9 | """Ensure all providers from ProviderName enum exist in database.""" 10 | with SessionLocal() as db: 11 | repo = ProviderSettingsRepository() 12 | all_providers = [provider.value for provider in ProviderName] 13 | repo.ensure_all_providers_exist(db, all_providers) 14 | print(f"✓ Provider settings initialized: {', '.join(all_providers)}") 15 | 16 | 17 | if __name__ == "__main__": 18 | init_provider_settings() 19 | -------------------------------------------------------------------------------- /frontend/.oxlintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "./node_modules/oxlint/configuration_schema.json", 3 | "plugins": ["react", "typescript", "unicorn"], 4 | "env": { 5 | "browser": true 6 | }, 7 | "rules": { 8 | "eqeqeq": "warn", 9 | "no-console": "warn", 10 | "no-debugger": "warn", 11 | "no-unused-vars": "off", 12 | "@typescript-eslint/no-unused-vars": "warn", 13 | "@typescript-eslint/no-explicit-any": "warn", 14 | "react/jsx-no-target-blank": "warn", 15 | "react/self-closing-comp": ["warn", { "html": false }], 16 | "unicorn/prefer-includes": "warn" 17 | }, 18 | "overrides": [ 19 | { 20 | "files": ["*.test.ts", "*.test.tsx", "*.spec.ts", "*.spec.tsx"], 21 | "rules": { 22 | "@typescript-eslint/no-explicit-any": "off" 23 | } 24 | } 25 | ] 26 | } 27 | -------------------------------------------------------------------------------- /frontend/src/components/ui/separator.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import * as SeparatorPrimitive from '@radix-ui/react-separator'; 3 | 4 | import { cn } from '@/lib/utils'; 5 | 6 | function Separator({ 7 | className, 8 | orientation = 'horizontal', 9 | decorative = true, 10 | ...props 11 | }: React.ComponentProps) { 12 | return ( 13 | 23 | ); 24 | } 25 | 26 | export { Separator }; 27 | -------------------------------------------------------------------------------- /frontend/src/components/ui/label.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import * as LabelPrimitive from '@radix-ui/react-label'; 3 | import { cva, type VariantProps } from 'class-variance-authority'; 4 | 5 | import { cn } from '@/lib/utils'; 6 | 7 | const labelVariants = cva( 8 | 'text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70' 9 | ); 10 | 11 | const Label = React.forwardRef< 12 | React.ElementRef, 13 | React.ComponentPropsWithoutRef & 14 | VariantProps 15 | >(({ className, ...props }, ref) => ( 16 | 21 | )); 22 | Label.displayName = LabelPrimitive.Root.displayName; 23 | 24 | export { Label }; 25 | -------------------------------------------------------------------------------- /backend/app/models/sleep_details.py: -------------------------------------------------------------------------------- 1 | from sqlalchemy.orm import Mapped 2 | 3 | from app.mappings import FKEventRecordDetail, numeric_5_2 4 | 5 | from .event_record_detail import EventRecordDetail 6 | 7 | 8 | class SleepDetails(EventRecordDetail): 9 | """Per-sleep aggregates and metrics.""" 10 | 11 | __tablename__ = "sleep_details" 12 | __mapper_args__ = {"polymorphic_identity": "sleep"} 13 | 14 | record_id: Mapped[FKEventRecordDetail] 15 | 16 | sleep_total_duration_minutes: Mapped[int | None] 17 | sleep_time_in_bed_minutes: Mapped[int | None] 18 | sleep_efficiency_score: Mapped[numeric_5_2 | None] 19 | sleep_deep_minutes: Mapped[int | None] 20 | sleep_rem_minutes: Mapped[int | None] 21 | sleep_light_minutes: Mapped[int | None] 22 | sleep_awake_minutes: Mapped[int | None] 23 | 24 | is_nap: Mapped[bool | None] 25 | -------------------------------------------------------------------------------- /backend/app/api/routes/v1/records.py: -------------------------------------------------------------------------------- 1 | from fastapi import APIRouter 2 | 3 | # TODO: Re-enable when HKRecordListResponse, HKRecordQueryParams, and hk_record_service are implemented 4 | # from typing import Annotated 5 | # from fastapi import Depends 6 | # from app.database import DbSession 7 | # from app.schemas import HKRecordListResponse, HKRecordQueryParams 8 | # from app.services import ApiKeyDep, hk_record_service 9 | 10 | router = APIRouter() 11 | 12 | 13 | # @router.get("/users/{user_id}/records", response_model=HKRecordListResponse) 14 | # async def get_records_endpoint( 15 | # user_id: str, 16 | # db: DbSession, 17 | # _api_key: ApiKeyDep, 18 | # query_params: Annotated[HKRecordQueryParams, Depends()], 19 | # ): 20 | # """Get records with filtering, sorting, and pagination.""" 21 | # return await hk_record_service.get_records_response(db, query_params, user_id) 22 | -------------------------------------------------------------------------------- /frontend/vite.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vite'; 2 | import { devtools } from '@tanstack/devtools-vite'; 3 | import { tanstackStart } from '@tanstack/react-start/plugin/vite'; 4 | import viteReact from '@vitejs/plugin-react'; 5 | import viteTsConfigPaths from 'vite-tsconfig-paths'; 6 | import tailwindcss from '@tailwindcss/vite'; 7 | import { nitro } from 'nitro/vite'; 8 | 9 | const config = defineConfig({ 10 | build: { 11 | outDir: 'dist', 12 | }, 13 | server: { 14 | host: '0.0.0.0', 15 | port: 3000, 16 | watch: { 17 | usePolling: true, 18 | }, 19 | }, 20 | plugins: [ 21 | devtools(), 22 | nitro(), 23 | // this is the plugin that enables path aliases 24 | viteTsConfigPaths({ 25 | projects: ['./tsconfig.json'], 26 | }), 27 | tailwindcss(), 28 | tanstackStart(), 29 | viteReact(), 30 | ], 31 | }); 32 | 33 | export default config; 34 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | repos: 2 | - repo: https://github.com/astral-sh/ruff-pre-commit 3 | rev: v0.14.2 4 | hooks: 5 | # Run the linter 6 | - id: ruff-check 7 | args: [--fix] 8 | files: ^backend/.*\.py$ 9 | # Run the formatter 10 | - id: ruff-format 11 | files: ^backend/.*\.py$ 12 | 13 | - repo: local 14 | hooks: 15 | - id: ty 16 | name: ty check 17 | entry: sh -c 'cd backend && .venv/bin/ty check .' 18 | language: system 19 | files: ^backend/.*\.py$ 20 | pass_filenames: false 21 | 22 | - repo: https://github.com/pre-commit/pre-commit-hooks 23 | rev: v6.0.0 24 | hooks: 25 | - id: trailing-whitespace 26 | files: ^backend/ 27 | - id: end-of-file-fixer 28 | files: ^backend/ 29 | - id: check-merge-conflict 30 | args: [--assume-in-merge] 31 | files: ^backend/ -------------------------------------------------------------------------------- /backend/app/models/invitation.py: -------------------------------------------------------------------------------- 1 | from uuid import UUID 2 | 3 | from sqlalchemy.orm import Mapped, relationship 4 | 5 | from app.database import BaseDbModel 6 | from app.mappings import FKDeveloper, ManyToOne, PrimaryKey, Unique, datetime_tz, str_255 7 | from app.models import Developer 8 | from app.schemas.invitation import InvitationStatus 9 | 10 | 11 | class Invitation(BaseDbModel): 12 | """Invitation to join the team as a developer.""" 13 | 14 | id: Mapped[PrimaryKey[UUID]] 15 | email: Mapped[str_255] 16 | token: Mapped[Unique[str_255]] 17 | status: Mapped[InvitationStatus] 18 | expires_at: Mapped[datetime_tz] 19 | created_at: Mapped[datetime_tz] 20 | 21 | invited_by_id: Mapped[FKDeveloper | None] 22 | invited_by: Mapped[ManyToOne["Developer"] | None] = relationship( 23 | "Developer", 24 | foreign_keys="[Invitation.invited_by_id]", 25 | lazy="joined", 26 | ) 27 | -------------------------------------------------------------------------------- /frontend/src/lib/api/services/credentials.service.ts: -------------------------------------------------------------------------------- 1 | import { apiClient } from '../client'; 2 | import { API_ENDPOINTS } from '../config'; 3 | import type { ApiKey, ApiKeyCreate } from '../types'; 4 | 5 | export const credentialsService = { 6 | async getApiKeys(): Promise { 7 | return apiClient.get(API_ENDPOINTS.apiKeys); 8 | }, 9 | 10 | async getApiKey(id: string): Promise { 11 | return apiClient.get(API_ENDPOINTS.apiKeyDetail(id)); 12 | }, 13 | 14 | async createApiKey(data: ApiKeyCreate): Promise { 15 | return apiClient.post(API_ENDPOINTS.apiKeys, data); 16 | }, 17 | 18 | async revokeApiKey(id: string): Promise { 19 | return apiClient.delete(API_ENDPOINTS.apiKeyDetail(id)); 20 | }, 21 | 22 | async deleteApiKey(id: string): Promise { 23 | return apiClient.delete(API_ENDPOINTS.apiKeyDetail(id)); 24 | }, 25 | }; 26 | -------------------------------------------------------------------------------- /backend/app/schemas/system_info.py: -------------------------------------------------------------------------------- 1 | from pydantic import BaseModel 2 | 3 | 4 | class CountWithGrowth(BaseModel): 5 | """Count with weekly growth percentage.""" 6 | 7 | count: int 8 | weekly_growth: float 9 | 10 | 11 | class SeriesTypeMetric(BaseModel): 12 | """Series type metric information.""" 13 | 14 | series_type: str 15 | count: int 16 | 17 | 18 | class WorkoutTypeMetric(BaseModel): 19 | """Workout type metric information.""" 20 | 21 | workout_type: str | None 22 | count: int 23 | 24 | 25 | class DataPointsInfo(BaseModel): 26 | """Data points information.""" 27 | 28 | count: int 29 | weekly_growth: float 30 | top_series_types: list[SeriesTypeMetric] 31 | top_workout_types: list[WorkoutTypeMetric] 32 | 33 | 34 | class SystemInfoResponse(BaseModel): 35 | """Dashboard system information response.""" 36 | 37 | total_users: CountWithGrowth 38 | active_conn: CountWithGrowth 39 | data_points: DataPointsInfo 40 | -------------------------------------------------------------------------------- /backend/app/services/providers/factory.py: -------------------------------------------------------------------------------- 1 | from app.services.providers.apple.strategy import AppleStrategy 2 | from app.services.providers.base_strategy import BaseProviderStrategy 3 | from app.services.providers.garmin.strategy import GarminStrategy 4 | from app.services.providers.polar.strategy import PolarStrategy 5 | from app.services.providers.suunto.strategy import SuuntoStrategy 6 | 7 | 8 | class ProviderFactory: 9 | """Factory for creating provider instances.""" 10 | 11 | def get_provider(self, provider_name: str) -> BaseProviderStrategy: 12 | match provider_name: 13 | case "apple": 14 | return AppleStrategy() 15 | case "garmin": 16 | return GarminStrategy() 17 | case "suunto": 18 | return SuuntoStrategy() 19 | case "polar": 20 | return PolarStrategy() 21 | case _: 22 | raise ValueError(f"Unknown provider: {provider_name}") 23 | -------------------------------------------------------------------------------- /backend/app/repositories/__init__.py: -------------------------------------------------------------------------------- 1 | from .api_key_repository import ApiKeyRepository 2 | from .data_point_series_repository import DataPointSeriesRepository 3 | from .developer_repository import DeveloperRepository 4 | from .event_record_detail_repository import EventRecordDetailRepository 5 | from .event_record_repository import EventRecordRepository 6 | from .external_mapping_repository import ExternalMappingRepository 7 | from .invitation_repository import InvitationRepository 8 | from .repositories import CrudRepository 9 | from .user_connection_repository import UserConnectionRepository 10 | from .user_repository import UserRepository 11 | 12 | __all__ = [ 13 | "UserRepository", 14 | "ApiKeyRepository", 15 | "EventRecordRepository", 16 | "EventRecordDetailRepository", 17 | "DataPointSeriesRepository", 18 | "UserConnectionRepository", 19 | "DeveloperRepository", 20 | "InvitationRepository", 21 | "CrudRepository", 22 | "ExternalMappingRepository", 23 | ] 24 | -------------------------------------------------------------------------------- /frontend/src/routes/_authenticated.tsx: -------------------------------------------------------------------------------- 1 | import { createFileRoute, Outlet, redirect } from '@tanstack/react-router'; 2 | import { SimpleSidebar } from '@/components/layout/simple-sidebar'; 3 | import { isAuthenticated } from '@/lib/auth/session'; 4 | 5 | export const Route = createFileRoute('/_authenticated')({ 6 | component: AuthenticatedLayout, 7 | beforeLoad: () => { 8 | // Skip auth check during SSR - localStorage is not available on the server 9 | // The check will run on the client after hydration 10 | if (typeof window === 'undefined') { 11 | return; 12 | } 13 | if (!isAuthenticated()) { 14 | throw redirect({ to: '/login' }); 15 | } 16 | }, 17 | }); 18 | 19 | function AuthenticatedLayout() { 20 | return ( 21 | 22 | 23 | 24 | 25 | 26 | 27 | ); 28 | } 29 | -------------------------------------------------------------------------------- /backend/app/models/data_point_series.py: -------------------------------------------------------------------------------- 1 | from uuid import UUID 2 | 3 | from sqlalchemy import Index 4 | from sqlalchemy.orm import Mapped 5 | 6 | from app.database import BaseDbModel 7 | from app.mappings import ( 8 | FKExternalMapping, 9 | FKSeriesTypeDefinition, 10 | PrimaryKey, 11 | datetime_tz, 12 | numeric_10_3, 13 | str_100, 14 | ) 15 | 16 | 17 | class DataPointSeries(BaseDbModel): 18 | """Unified time-series data points for device metrics (heart rate, steps, energy, etc.).""" 19 | 20 | __tablename__ = "data_point_series" 21 | __table_args__ = ( 22 | Index("idx_data_point_series_mapping_type_time", "external_device_mapping_id", "series_type_definition_id", "recorded_at"), 23 | ) 24 | 25 | id: Mapped[PrimaryKey[UUID]] 26 | external_id: Mapped[str_100 | None] 27 | external_device_mapping_id: Mapped[FKExternalMapping] 28 | recorded_at: Mapped[datetime_tz] 29 | value: Mapped[numeric_10_3] 30 | series_type_definition_id: Mapped[FKSeriesTypeDefinition] 31 | -------------------------------------------------------------------------------- /backend/app/schemas/apple/healthkit/record_import.py: -------------------------------------------------------------------------------- 1 | # ruff: noqa: N815 2 | 3 | from __future__ import annotations 4 | 5 | from datetime import datetime 6 | from decimal import Decimal 7 | from typing import Any 8 | 9 | from pydantic import BaseModel 10 | 11 | 12 | class MetadataEntryIn(BaseModel): 13 | """Schema for metadata entry.""" 14 | 15 | key: str 16 | value: Decimal 17 | 18 | 19 | class RecordBase(BaseModel): 20 | """Base schema for record.""" 21 | 22 | type: str 23 | startDate: datetime 24 | endDate: datetime 25 | unit: str 26 | value: Decimal 27 | sourceName: str 28 | 29 | 30 | class RecordJSON(BaseModel): 31 | """Schema for JSON import format from HealthKit.""" 32 | 33 | uuid: str | None = None 34 | user_id: str | None = None 35 | type: str | None = None 36 | startDate: datetime 37 | endDate: datetime 38 | unit: str 39 | value: Decimal 40 | sourceName: str | None = None 41 | recordMetadata: list[dict[str, Any]] | None = None 42 | -------------------------------------------------------------------------------- /frontend/src/hooks/api/use-developers.ts: -------------------------------------------------------------------------------- 1 | import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; 2 | import { developersService } from '@/lib/api/services/developers.service'; 3 | import { queryKeys } from '@/lib/query/keys'; 4 | import { toast } from 'sonner'; 5 | import { getErrorMessage } from '@/lib/errors/handler'; 6 | 7 | export function useDevelopers() { 8 | return useQuery({ 9 | queryKey: queryKeys.developers.list(), 10 | queryFn: () => developersService.getDevelopers(), 11 | }); 12 | } 13 | 14 | export function useDeleteDeveloper() { 15 | const queryClient = useQueryClient(); 16 | 17 | return useMutation({ 18 | mutationFn: (id: string) => developersService.deleteDeveloper(id), 19 | onSuccess: () => { 20 | queryClient.invalidateQueries({ queryKey: queryKeys.developers.list() }); 21 | toast.success('Team member removed successfully'); 22 | }, 23 | onError: (error) => { 24 | toast.error(`Failed to remove team member: ${getErrorMessage(error)}`); 25 | }, 26 | }); 27 | } 28 | -------------------------------------------------------------------------------- /backend/app/schemas/polar/exercise_import.py: -------------------------------------------------------------------------------- 1 | from pydantic import BaseModel, Field 2 | 3 | 4 | class HeartRateJSON(BaseModel): 5 | average: int | None = None 6 | maximum: int | None = None 7 | 8 | 9 | # unused for now - time series data 10 | class HRSamplesJSON(BaseModel): 11 | recording_rate: int = Field(alias="recording-rate") 12 | sample_type: str = Field(alias="sample-type") 13 | data: str 14 | 15 | 16 | class HRZoneJSON(BaseModel): 17 | index: int 18 | lower_limit: int = Field(alias="lower-limit") 19 | upper_limit: int = Field(alias="upper-limit") 20 | in_zone: str = Field(alias="in-zone") 21 | 22 | 23 | class ExerciseJSON(BaseModel): 24 | id: str 25 | device: str 26 | 27 | sport: str 28 | detailed_sport_info: str | None = None 29 | 30 | sport: str 31 | detailed_sport_info: str | None = None 32 | 33 | start_time: str 34 | start_time_utc_offset: int 35 | duration: str 36 | 37 | calories: int | None = None 38 | distance: int | None = None 39 | heart_rate: HeartRateJSON | None = None 40 | -------------------------------------------------------------------------------- /frontend/src/lib/query/client.ts: -------------------------------------------------------------------------------- 1 | import { QueryClient } from '@tanstack/react-query'; 2 | import { ApiError } from '../errors/api-error'; 3 | 4 | export const queryClient = new QueryClient({ 5 | defaultOptions: { 6 | queries: { 7 | staleTime: 5 * 60 * 1000, // 5 minutes 8 | gcTime: 10 * 60 * 1000, // 10 minutes (formerly cacheTime) 9 | retry: (failureCount, error) => { 10 | // Don't retry on client errors (4xx) 11 | if ( 12 | error instanceof ApiError && 13 | error.statusCode >= 400 && 14 | error.statusCode < 500 15 | ) { 16 | return false; 17 | } 18 | // Retry up to 3 times on server errors (5xx) or network errors 19 | return failureCount < 3; 20 | }, 21 | refetchOnWindowFocus: false, 22 | refetchOnReconnect: true, 23 | }, 24 | mutations: { 25 | retry: false, 26 | onError: (error) => { 27 | // Global error handling for mutations 28 | if (error instanceof ApiError) { 29 | } 30 | }, 31 | }, 32 | }, 33 | }); 34 | -------------------------------------------------------------------------------- /backend/app/schemas/provider_setting.py: -------------------------------------------------------------------------------- 1 | from pydantic import BaseModel, Field 2 | 3 | 4 | class ProviderSettingRead(BaseModel): 5 | """Provider setting with metadata.""" 6 | 7 | provider: str = Field(..., description="Provider identifier (e.g., 'apple', 'garmin')") 8 | name: str = Field(..., description="Display name (e.g., 'Apple Health', 'Garmin')") 9 | has_cloud_api: bool = Field(..., description="Whether provider uses cloud OAuth API") 10 | is_enabled: bool = Field(..., description="Whether provider is enabled by admin") 11 | icon_url: str = Field(..., description="URL to provider icon") 12 | 13 | 14 | class ProviderSettingUpdate(BaseModel): 15 | """Schema for updating provider setting.""" 16 | 17 | is_enabled: bool 18 | 19 | 20 | class BulkProviderSettingsUpdate(BaseModel): 21 | """Schema for bulk updating provider settings.""" 22 | 23 | providers: dict[str, bool] = Field( 24 | ..., 25 | description="Map of provider_id -> is_enabled", 26 | examples=[{"apple": True, "garmin": True, "polar": False, "suunto": True}], 27 | ) 28 | -------------------------------------------------------------------------------- /backend/scripts/init/seed_admin.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | """Seed default admin developer account if it doesn't exist.""" 3 | 4 | from app.database import SessionLocal 5 | from app.schemas.developer import DeveloperCreate 6 | from app.services import developer_service 7 | 8 | ADMIN_EMAIL = "admin@admin.com" 9 | ADMIN_PASSWORD = "secret123" 10 | 11 | 12 | def seed_admin() -> None: 13 | """Create default admin developer if it doesn't exist.""" 14 | with SessionLocal() as db: 15 | existing = developer_service.crud.get_all( 16 | db, 17 | filters={"email": ADMIN_EMAIL}, 18 | offset=0, 19 | limit=1, 20 | sort_by=None, 21 | ) 22 | if existing: 23 | print(f"Admin developer {ADMIN_EMAIL} already exists, skipping.") 24 | return 25 | 26 | developer_service.register(db, DeveloperCreate(email=ADMIN_EMAIL, password=ADMIN_PASSWORD)) 27 | print(f"✓ Created default admin developer: {ADMIN_EMAIL} / {ADMIN_PASSWORD}") 28 | 29 | 30 | if __name__ == "__main__": 31 | seed_admin() 32 | -------------------------------------------------------------------------------- /frontend/src/lib/api/services/invitations.service.ts: -------------------------------------------------------------------------------- 1 | import { apiClient } from '../client'; 2 | import { API_ENDPOINTS } from '../config'; 3 | import type { Invitation, InvitationCreate, InvitationAccept } from '../types'; 4 | 5 | export const invitationsService = { 6 | async getInvitations(): Promise { 7 | return apiClient.get(API_ENDPOINTS.invitations); 8 | }, 9 | 10 | async createInvitation(data: InvitationCreate): Promise { 11 | return apiClient.post(API_ENDPOINTS.invitations, data); 12 | }, 13 | 14 | async revokeInvitation(id: string): Promise { 15 | return apiClient.delete(API_ENDPOINTS.invitationDetail(id)); 16 | }, 17 | 18 | async resendInvitation(id: string): Promise { 19 | return apiClient.post(API_ENDPOINTS.invitationResend(id)); 20 | }, 21 | 22 | async acceptInvitation(data: InvitationAccept): Promise<{ message: string }> { 23 | return apiClient.post<{ message: string }>( 24 | API_ENDPOINTS.acceptInvitation, 25 | data 26 | ); 27 | }, 28 | }; 29 | -------------------------------------------------------------------------------- /backend/app/models/external_device_mapping.py: -------------------------------------------------------------------------------- 1 | from uuid import UUID 2 | 3 | from sqlalchemy import Index, UniqueConstraint 4 | from sqlalchemy.orm import Mapped 5 | 6 | from app.database import BaseDbModel 7 | from app.mappings import FKUser, OneToMany, PrimaryKey, str_10, str_100 8 | 9 | 10 | class ExternalDeviceMapping(BaseDbModel): 11 | """Maps a user/provider/device combination into a reusable identifier.""" 12 | 13 | __tablename__ = "external_device_mapping" 14 | __table_args__ = ( 15 | UniqueConstraint( 16 | "user_id", 17 | "provider_name", 18 | "device_id", 19 | name="uq_external_mapping_user_provider_device", 20 | ), 21 | Index("idx_external_mapping_user", "user_id"), 22 | Index("idx_external_mapping_device", "device_id"), 23 | ) 24 | 25 | id: Mapped[PrimaryKey[UUID]] 26 | user_id: Mapped[FKUser] 27 | provider_name: Mapped[str_10] 28 | device_id: Mapped[str_100 | None] 29 | 30 | event_records: Mapped[OneToMany["EventRecord"]] 31 | data_points: Mapped[OneToMany["DataPointSeries"]] 32 | -------------------------------------------------------------------------------- /backend/app/services/providers/garmin/strategy.py: -------------------------------------------------------------------------------- 1 | from app.services.providers.base_strategy import BaseProviderStrategy 2 | from app.services.providers.garmin.oauth import GarminOAuth 3 | from app.services.providers.garmin.workouts import GarminWorkouts 4 | 5 | 6 | class GarminStrategy(BaseProviderStrategy): 7 | """Garmin provider implementation.""" 8 | 9 | def __init__(self): 10 | super().__init__() 11 | self.oauth = GarminOAuth( 12 | user_repo=self.user_repo, 13 | connection_repo=self.connection_repo, 14 | provider_name=self.name, 15 | api_base_url=self.api_base_url, 16 | ) 17 | self.workouts = GarminWorkouts( 18 | workout_repo=self.workout_repo, 19 | connection_repo=self.connection_repo, 20 | provider_name=self.name, 21 | api_base_url=self.api_base_url, 22 | oauth=self.oauth, 23 | ) 24 | 25 | @property 26 | def name(self) -> str: 27 | return "garmin" 28 | 29 | @property 30 | def api_base_url(self) -> str: 31 | return "https://apis.garmin.com" 32 | -------------------------------------------------------------------------------- /backend/app/services/providers/polar/strategy.py: -------------------------------------------------------------------------------- 1 | from app.services.providers.base_strategy import BaseProviderStrategy 2 | from app.services.providers.polar.oauth import PolarOAuth 3 | from app.services.providers.polar.workouts import PolarWorkouts 4 | 5 | 6 | class PolarStrategy(BaseProviderStrategy): 7 | """Polar provider implementation.""" 8 | 9 | def __init__(self): 10 | super().__init__() 11 | self.oauth = PolarOAuth( 12 | user_repo=self.user_repo, 13 | connection_repo=self.connection_repo, 14 | provider_name=self.name, 15 | api_base_url=self.api_base_url, 16 | ) 17 | self.workouts = PolarWorkouts( 18 | workout_repo=self.workout_repo, 19 | connection_repo=self.connection_repo, 20 | provider_name=self.name, 21 | api_base_url=self.api_base_url, 22 | oauth=self.oauth, 23 | ) 24 | 25 | @property 26 | def name(self) -> str: 27 | return "polar" 28 | 29 | @property 30 | def api_base_url(self) -> str: 31 | return "https://www.polaraccesslink.com" 32 | -------------------------------------------------------------------------------- /backend/app/utils/healthcheck.py: -------------------------------------------------------------------------------- 1 | from fastapi import APIRouter 2 | from sqlalchemy import text 3 | 4 | from app.database import DbSession, engine 5 | 6 | healthcheck_router = APIRouter() 7 | 8 | 9 | def get_pool_status() -> dict[str, str]: 10 | """Get connection pool status for monitoring.""" 11 | pool = engine.pool 12 | return { 13 | "max_pool_size": str(pool.size()), 14 | "connections_ready_for_reuse": str(pool.checkedin()), 15 | "active_connections": str(pool.checkedout()), 16 | "overflow": str(pool.overflow()), 17 | } 18 | 19 | 20 | @healthcheck_router.get("/db") 21 | async def database_health(db: DbSession) -> dict[str, str | dict[str, str]]: 22 | """Database health check endpoint.""" 23 | try: 24 | # Test connection 25 | db.execute(text("SELECT 1")) 26 | 27 | pool_status = get_pool_status() 28 | return { 29 | "status": "healthy", 30 | "pool": pool_status, 31 | } 32 | except Exception as e: 33 | return { 34 | "status": "unhealthy", 35 | "error": str(e), 36 | } 37 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 Momentum 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /backend/app/services/__init__.py: -------------------------------------------------------------------------------- 1 | from app.utils.auth import DeveloperDep 2 | 3 | from .api_key_service import ApiKeyDep, api_key_service 4 | from .apple.apple_xml.presigned_url_service import import_service as pre_url_service 5 | from .apple.auto_export.import_service import import_service as ae_import_service 6 | from .apple.healthkit.import_service import import_service as hk_import_service 7 | from .developer_service import developer_service 8 | from .event_record_service import event_record_service 9 | from .invitation_service import invitation_service 10 | from .services import AppService 11 | from .system_info_service import system_info_service 12 | from .timeseries_service import timeseries_service 13 | from .user_service import user_service 14 | 15 | __all__ = [ 16 | "AppService", 17 | "api_key_service", 18 | "developer_service", 19 | "invitation_service", 20 | "DeveloperDep", 21 | "ApiKeyDep", 22 | "user_service", 23 | "ae_import_service", 24 | "hk_import_service", 25 | "event_record_service", 26 | "timeseries_service", 27 | "pre_url_service", 28 | "system_info_service", 29 | ] 30 | -------------------------------------------------------------------------------- /docs/LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Mintlify 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /backend/app/models/workout_details.py: -------------------------------------------------------------------------------- 1 | from sqlalchemy.orm import Mapped 2 | 3 | from app.mappings import FKEventRecordDetail, numeric_5_2, numeric_10_3 4 | 5 | from .event_record_detail import EventRecordDetail 6 | 7 | 8 | class WorkoutDetails(EventRecordDetail): 9 | """Per-workout aggregates and metrics.""" 10 | 11 | __tablename__ = "workout_details" 12 | __mapper_args__ = {"polymorphic_identity": "workout"} 13 | 14 | record_id: Mapped[FKEventRecordDetail] 15 | 16 | heart_rate_min: Mapped[int | None] 17 | heart_rate_max: Mapped[int | None] 18 | heart_rate_avg: Mapped[numeric_5_2 | None] 19 | energy_burned: Mapped[numeric_10_3 | None] 20 | distance: Mapped[numeric_10_3 | None] 21 | steps_count: Mapped[int | None] 22 | 23 | max_speed: Mapped[numeric_5_2 | None] 24 | max_watts: Mapped[numeric_10_3 | None] 25 | moving_time_seconds: Mapped[int | None] 26 | total_elevation_gain: Mapped[numeric_10_3 | None] 27 | average_speed: Mapped[numeric_5_2 | None] 28 | average_watts: Mapped[numeric_10_3 | None] 29 | elev_high: Mapped[numeric_10_3 | None] 30 | elev_low: Mapped[numeric_10_3 | None] 31 | -------------------------------------------------------------------------------- /backend/app/schemas/apple/apple_xml/aws.py: -------------------------------------------------------------------------------- 1 | from pydantic import BaseModel, Field 2 | 3 | MIN_EXPIRATION_SECONDS = 60 # 1 minute 4 | MAX_EXPIRATION_SECONDS = 3600 # 1 hour 5 | DEFAULT_EXPIRATION_SECONDS = 300 # 5 minutes 6 | MIN_FILE_SIZE = 1024 # 1KB 7 | MAX_FILE_SIZE = 1024 * 1024 * 1024 # 500MB 8 | DEFAULT_FILE_SIZE = 50 * 1024 * 1024 # 50MB 9 | 10 | 11 | class PresignedURLRequest(BaseModel): 12 | filename: str = Field("", max_length=200, description="Custom filename") 13 | expiration_seconds: int = Field( 14 | default=DEFAULT_EXPIRATION_SECONDS, 15 | ge=MIN_EXPIRATION_SECONDS, 16 | le=MAX_EXPIRATION_SECONDS, 17 | description="URL expiration time in seconds (1 min - 1 hour)", 18 | ) 19 | max_file_size: int = Field( 20 | default=DEFAULT_FILE_SIZE, 21 | ge=MIN_FILE_SIZE, 22 | le=MAX_FILE_SIZE, 23 | description="Maximum file size in bytes (1KB - 500MB)", 24 | ) 25 | 26 | 27 | class PresignedURLResponse(BaseModel): 28 | upload_url: str 29 | form_fields: dict[str, str] 30 | file_key: str 31 | expires_in: int 32 | max_file_size: int 33 | bucket: str 34 | -------------------------------------------------------------------------------- /backend/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM python:3.13-slim AS builder 2 | 3 | RUN apt-get update && \ 4 | apt-get install -y --no-install-recommends build-essential libpq-dev && \ 5 | apt-get clean && \ 6 | rm -rf /var/lib/apt/lists/* 7 | RUN pip install --no-cache-dir --upgrade pyopenssl 8 | 9 | COPY --from=ghcr.io/astral-sh/uv:latest /uv /uvx /bin/ 10 | 11 | WORKDIR /root_project 12 | 13 | COPY uv.lock pyproject.toml ./ 14 | COPY . . 15 | 16 | ENV VIRTUAL_ENV=/opt/venv 17 | RUN uv venv $VIRTUAL_ENV 18 | ENV PATH="$VIRTUAL_ENV/bin:$PATH" 19 | RUN uv sync --locked --no-dev 20 | 21 | FROM python:3.13-slim 22 | 23 | RUN apt-get update && \ 24 | apt-get install -y --no-install-recommends libpq5 && \ 25 | apt-get clean && \ 26 | rm -rf /var/lib/apt/lists/* 27 | 28 | WORKDIR /root_project 29 | 30 | COPY --from=ghcr.io/astral-sh/uv:latest /uv /uvx /bin/ 31 | 32 | COPY --from=builder /opt/venv /opt/venv 33 | COPY --from=builder /root_project /root_project 34 | ENV VIRTUAL_ENV=/opt/venv 35 | ENV PATH="/opt/venv/bin:$PATH" 36 | ENV UV_PROJECT_ENVIRONMENT=/opt/venv 37 | 38 | EXPOSE 8000 39 | -------------------------------------------------------------------------------- /backend/app/models/__init__.py: -------------------------------------------------------------------------------- 1 | from .api_key import ApiKey 2 | from .data_point_series import DataPointSeries 3 | from .developer import Developer 4 | from .device import Device 5 | from .device_software import DeviceSoftware 6 | from .event_record import EventRecord 7 | from .event_record_detail import EventRecordDetail 8 | from .external_device_mapping import ExternalDeviceMapping 9 | from .invitation import Invitation 10 | from .personal_record import PersonalRecord 11 | from .provider_setting import ProviderSetting 12 | from .series_type_definition import SeriesTypeDefinition 13 | from .sleep_details import SleepDetails 14 | from .user import User 15 | from .user_connection import UserConnection 16 | from .workout_details import WorkoutDetails 17 | 18 | __all__ = [ 19 | "ApiKey", 20 | "Developer", 21 | "Device", 22 | "DeviceSoftware", 23 | "Invitation", 24 | "ProviderSetting", 25 | "User", 26 | "UserConnection", 27 | "EventRecord", 28 | "EventRecordDetail", 29 | "SleepDetails", 30 | "WorkoutDetails", 31 | "PersonalRecord", 32 | "DataPointSeries", 33 | "ExternalDeviceMapping", 34 | "SeriesTypeDefinition", 35 | ] 36 | -------------------------------------------------------------------------------- /backend/app/services/user_connection_service.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | from logging import Logger, getLogger 3 | 4 | from app.database import DbSession 5 | from app.models import UserConnection 6 | from app.repositories.user_connection_repository import UserConnectionRepository 7 | from app.schemas import UserConnectionCreate, UserConnectionUpdate 8 | from app.services.services import AppService 9 | 10 | 11 | class UserConnectionService( 12 | AppService[UserConnectionRepository, UserConnection, UserConnectionCreate, UserConnectionUpdate], 13 | ): 14 | def __init__(self, log: Logger, **kwargs): 15 | super().__init__( 16 | crud_model=UserConnectionRepository, 17 | model=UserConnection, 18 | log=log, 19 | **kwargs, 20 | ) 21 | 22 | def get_active_count_in_range(self, db_session: DbSession, start_date: datetime, end_date: datetime) -> int: 23 | """Get count of active connections created within a date range.""" 24 | return self.crud.get_active_count_in_range(db_session, start_date, end_date) 25 | 26 | 27 | user_connection_service = UserConnectionService(log=getLogger(__name__)) 28 | -------------------------------------------------------------------------------- /backend/app/integrations/celery/core.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime, timedelta 2 | 3 | from app.config import settings 4 | from celery import Celery 5 | from celery import current_app as current_celery_app 6 | 7 | 8 | def create_celery() -> Celery: 9 | celery_app: Celery = current_celery_app # type: ignore[assignment] 10 | celery_app.conf.update( 11 | broker_url=settings.redis_url, 12 | result_backend=settings.redis_url, 13 | task_serializer="json", 14 | accept_content=["json"], 15 | result_serializer="json", 16 | timezone="Europe/Warsaw", 17 | enable_utc=True, 18 | task_default_queue="default", 19 | task_default_exchange="default", 20 | result_expires=3 * 24 * 3600, 21 | ) 22 | 23 | celery_app.autodiscover_tasks(["app.integrations.celery.tasks"]) 24 | 25 | celery_app.conf.beat_schedule = { 26 | "sync-all-users-hourly": { 27 | "task": "app.integrations.celery.tasks.periodic_sync_task.sync_all_users", 28 | "schedule": 3600.0, 29 | "args": (datetime.now() - timedelta(hours=1), None), 30 | }, 31 | } 32 | 33 | return celery_app 34 | -------------------------------------------------------------------------------- /frontend/src/components/common/error-state.tsx: -------------------------------------------------------------------------------- 1 | import { AlertCircle } from 'lucide-react'; 2 | import { Button } from '../ui/button'; 3 | import { cn } from '../../lib/utils'; 4 | 5 | interface ErrorStateProps { 6 | title?: string; 7 | message?: string; 8 | onRetry?: () => void; 9 | className?: string; 10 | } 11 | 12 | export function ErrorState({ 13 | title = 'Something went wrong', 14 | message = 'An error occurred while loading data. Please try again.', 15 | onRetry, 16 | className, 17 | }: ErrorStateProps) { 18 | return ( 19 | 25 | 26 | 27 | 28 | {title} 29 | {message} 30 | {onRetry && ( 31 | 32 | Try Again 33 | 34 | )} 35 | 36 | ); 37 | } 38 | -------------------------------------------------------------------------------- /frontend/src/components/ui/input.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | 3 | import { cn } from '@/lib/utils'; 4 | 5 | const Input = React.forwardRef>( 6 | ({ className, type, ...props }, ref) => { 7 | return ( 8 | 25 | ); 26 | } 27 | ); 28 | Input.displayName = 'Input'; 29 | 30 | export { Input }; 31 | -------------------------------------------------------------------------------- /backend/app/integrations/celery/tasks/periodic_sync_task.py: -------------------------------------------------------------------------------- 1 | from logging import getLogger 2 | 3 | from app.database import SessionLocal 4 | from app.integrations.celery.tasks.sync_vendor_data_task import sync_vendor_data 5 | from app.repositories.user_connection_repository import UserConnectionRepository 6 | from app.schemas import SyncAllUsersResult 7 | from celery import shared_task 8 | 9 | logger = getLogger(__name__) 10 | 11 | 12 | @shared_task 13 | def sync_all_users(start_date: str | None = None, end_date: str | None = None) -> dict: 14 | """ 15 | Sync all users with active connections. 16 | Calls sync_vendor_data for each user with the same parameters. 17 | """ 18 | logger.info("[sync_all_users] Starting sync for all users") 19 | 20 | user_connection_repo = UserConnectionRepository() 21 | 22 | with SessionLocal() as db: 23 | user_ids = user_connection_repo.get_all_active_users(db) 24 | 25 | logger.info(f"[sync_all_users] Found {len(user_ids)} users with active connections") 26 | 27 | for user_id in user_ids: 28 | sync_vendor_data.delay(str(user_id), start_date, end_date) 29 | 30 | return SyncAllUsersResult(users_for_sync=len(user_ids)).model_dump() 31 | -------------------------------------------------------------------------------- /backend/app/schemas/filter_params.py: -------------------------------------------------------------------------------- 1 | from pydantic import BaseModel, Field, field_validator 2 | from sqlalchemy.orm import DeclarativeBase 3 | 4 | 5 | class FilterParams(BaseModel): 6 | """Generic filter parameters for database queries.""" 7 | 8 | page: int = Field(1, ge=1, description="Page number (1-based)") 9 | limit: int = Field(20, ge=1, le=100, description="Number of results per page") 10 | sort_by: str | None = Field(None, description="Field to sort by") 11 | sort_order: str = Field("asc", description="Sort order (asc/desc)") 12 | filters: dict[str, str] = Field(default_factory=dict, description="Field filters") 13 | 14 | @field_validator("sort_order") 15 | @classmethod 16 | def validate_sort_order(cls, v: str) -> str: 17 | if v.lower() not in ["asc", "desc"]: 18 | raise ValueError("sort_order must be 'asc' or 'desc'") 19 | return v.lower() 20 | 21 | def validate_against_model(self, model: type[DeclarativeBase]) -> None: 22 | """Validate that sort_by field exists on the model.""" 23 | if self.sort_by and not hasattr(model, self.sort_by): 24 | raise ValueError(f"Field '{self.sort_by}' does not exist on model {model.__name__}") 25 | -------------------------------------------------------------------------------- /backend/app/utils/api_utils.py: -------------------------------------------------------------------------------- 1 | from collections.abc import Callable 2 | from functools import wraps 3 | 4 | from fastapi.encoders import jsonable_encoder 5 | from fastapi.responses import JSONResponse 6 | 7 | from app.utils.hateoas import get_hateoas_item, get_hateoas_list 8 | 9 | 10 | def format_response(extra_rels: list[dict] = [], status_code: int = 200) -> Callable: 11 | def decorator(func: Callable) -> Callable: 12 | @wraps(func) 13 | async def wrapper(*args, **kwargs) -> JSONResponse: 14 | if not (request := kwargs.get("request")): 15 | raise ValueError("Request object not found in kwargs") 16 | 17 | base_url = str(request.base_url).rstrip("/") 18 | full_url = str(request.url) 19 | result = await func(*args, **kwargs) 20 | if type(result) is list: 21 | page = kwargs["page"] 22 | limit = kwargs["limit"] 23 | formatted = get_hateoas_list(result, page, limit, base_url) 24 | else: 25 | formatted = get_hateoas_item(result, base_url, full_url, extra_rels) 26 | return JSONResponse(content=jsonable_encoder(formatted), status_code=status_code) 27 | 28 | return wrapper 29 | 30 | return decorator 31 | -------------------------------------------------------------------------------- /docs/README.md: -------------------------------------------------------------------------------- 1 | # Open Wearables Documentation 2 | 3 | This directory houses the documentation site built with Mintlify. 4 | 5 | ## Development 6 | 7 | Install the [Mintlify CLI](https://www.npmjs.com/package/mint) to preview your documentation changes locally. To install, use the following command: 8 | 9 | ``` 10 | npm i -g mint 11 | ``` 12 | 13 | Run the following command at the root of your documentation, where your `docs.json` is located: 14 | 15 | ``` 16 | mint dev --port 3333 17 | ``` 18 | 19 | (port `3000` is already being used by the frontend) 20 | 21 | View your local preview at `http://localhost:3333` (or the port you specified). 22 | 23 | ## Publishing changes 24 | 25 | Install our GitHub app from your [dashboard](https://dashboard.mintlify.com/settings/organization/github-app) to propagate changes from your repo to your deployment. Changes are deployed to production automatically after pushing to the default branch. 26 | 27 | ## Need help? 28 | 29 | ### Troubleshooting 30 | 31 | - If your dev environment isn't running: Run `mint update` to ensure you have the most recent version of the CLI. 32 | - If a page loads as a 404: Make sure you are running in a folder with a valid `docs.json`. 33 | 34 | ### Resources 35 | - [Mintlify documentation](https://mintlify.com/docs) 36 | -------------------------------------------------------------------------------- /docs/support.mdx: -------------------------------------------------------------------------------- 1 | --- 2 | title: "Support" 3 | description: "Get help and support for Open Wearables" 4 | --- 5 | 6 | ## Get Support 7 | 8 | Need help with Open Wearables? We're here to assist you. 9 | 10 | ## Community Support 11 | 12 | 13 | 14 | Ask questions and share your experiences with the community. 15 | 16 | 17 | Report bugs or request new features. 18 | 19 | 20 | 21 | ## Documentation 22 | 23 | 24 | 25 | Get started with Open Wearables in minutes. 26 | 27 | 28 | Explore the complete API documentation. 29 | 30 | 31 | 32 | ## Additional Resources 33 | 34 | - Check out our [FAQs](/faqs) for common questions and answers 35 | - Browse the [GitHub repository](https://github.com/the-momentum/open-wearables) for code examples 36 | - Review the [provider setup guides](/provider-setup/suunto) for integration help 37 | 38 | -------------------------------------------------------------------------------- /docs/favicon.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | -------------------------------------------------------------------------------- /frontend/src/components/ui/switch.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import * as SwitchPrimitives from '@radix-ui/react-switch'; 3 | 4 | import { cn } from '@/lib/utils'; 5 | 6 | const Switch = React.forwardRef< 7 | React.ElementRef, 8 | React.ComponentPropsWithoutRef 9 | >(({ className, ...props }, ref) => ( 10 | 18 | 23 | 24 | )); 25 | Switch.displayName = SwitchPrimitives.Root.displayName; 26 | 27 | export { Switch }; 28 | -------------------------------------------------------------------------------- /frontend/src/components/common/loading-spinner.tsx: -------------------------------------------------------------------------------- 1 | import { Loader2 } from 'lucide-react'; 2 | import { cn } from '../../lib/utils'; 3 | 4 | interface LoadingSpinnerProps { 5 | className?: string; 6 | size?: 'sm' | 'md' | 'lg'; 7 | } 8 | 9 | export function LoadingSpinner({ 10 | className, 11 | size = 'md', 12 | }: LoadingSpinnerProps) { 13 | const sizeClasses = { 14 | sm: 'h-4 w-4', 15 | md: 'h-6 w-6', 16 | lg: 'h-8 w-8', 17 | }; 18 | 19 | return ( 20 | 27 | ); 28 | } 29 | 30 | interface LoadingStateProps { 31 | message?: string; 32 | className?: string; 33 | } 34 | 35 | export function LoadingState({ 36 | message = 'Loading...', 37 | className, 38 | }: LoadingStateProps) { 39 | return ( 40 | 43 | 44 | 45 | 46 | 47 | {message} 48 | 49 | ); 50 | } 51 | -------------------------------------------------------------------------------- /frontend/src/logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /backend/app/utils/security.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime, timedelta, timezone 2 | from typing import Any 3 | 4 | import bcrypt 5 | from jose import jwt 6 | 7 | from app.config import settings 8 | 9 | 10 | def create_access_token(subject: str | Any, expires_delta: timedelta | None = None) -> str: 11 | if expires_delta: 12 | expire = datetime.now(timezone.utc) + expires_delta 13 | else: 14 | expire = datetime.now(timezone.utc) + timedelta(minutes=settings.access_token_expire_minutes) 15 | 16 | to_encode = {"exp": expire, "sub": str(subject)} 17 | return jwt.encode(to_encode, settings.secret_key, algorithm=settings.algorithm) 18 | 19 | 20 | def verify_password(plain_password: str, hashed_password: str) -> bool: 21 | """Verify a password against a bcrypt hash.""" 22 | return bcrypt.checkpw(plain_password.encode("utf-8"), hashed_password.encode("utf-8")) 23 | 24 | 25 | def get_password_hash(password: str) -> str: 26 | """Generate bcrypt hash for a password.""" 27 | # Bcrypt has a 72 byte limit - truncate if needed 28 | password_bytes = password.encode("utf-8") 29 | if len(password_bytes) > 72: 30 | password_bytes = password_bytes[:72] 31 | 32 | # Generate salt and hash 33 | salt = bcrypt.gensalt() 34 | hashed = bcrypt.hashpw(password_bytes, salt) 35 | return hashed.decode("utf-8") 36 | -------------------------------------------------------------------------------- /backend/app/api/routes/v1/events.py: -------------------------------------------------------------------------------- 1 | from typing import Annotated 2 | from uuid import UUID 3 | 4 | from fastapi import APIRouter, HTTPException, Query 5 | 6 | from app.database import DbSession 7 | from app.schemas.common_types import PaginatedResponse 8 | from app.schemas.events import ( 9 | SleepSession, 10 | Workout, 11 | ) 12 | from app.services import ApiKeyDep 13 | 14 | router = APIRouter() 15 | 16 | 17 | @router.get("/users/{user_id}/events/workouts") 18 | async def list_workouts( 19 | user_id: UUID, 20 | start_date: str, 21 | end_date: str, 22 | db: DbSession, 23 | _api_key: ApiKeyDep, 24 | type: str | None = None, 25 | cursor: str | None = None, 26 | limit: Annotated[int, Query(ge=1, le=100)] = 50, 27 | ) -> PaginatedResponse[Workout]: 28 | """Returns workout sessions.""" 29 | raise HTTPException(status_code=501, detail="Not implemented") 30 | 31 | 32 | @router.get("/users/{user_id}/events/sleep") 33 | async def list_sleep_sessions( 34 | user_id: UUID, 35 | start_date: str, 36 | end_date: str, 37 | db: DbSession, 38 | _api_key: ApiKeyDep, 39 | cursor: str | None = None, 40 | limit: Annotated[int, Query(ge=1, le=100)] = 50, 41 | ) -> PaginatedResponse[SleepSession]: 42 | """Returns sleep sessions (including naps).""" 43 | raise HTTPException(status_code=501, detail="Not implemented") 44 | -------------------------------------------------------------------------------- /frontend/src/hooks/api/use-oauth-providers.ts: -------------------------------------------------------------------------------- 1 | import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; 2 | import { oauthService } from '@/lib/api/services/oauth.service'; 3 | import type { OAuthProvidersUpdate } from '@/lib/api/services/oauth.service'; 4 | import { queryKeys } from '@/lib/query/keys'; 5 | import { toast } from 'sonner'; 6 | import { getErrorMessage } from '@/lib/errors/handler'; 7 | 8 | // Get OAuth providers 9 | export function useOAuthProviders( 10 | cloudOnly: boolean = false, 11 | enabledOnly: boolean = false 12 | ) { 13 | return useQuery({ 14 | queryKey: queryKeys.oauthProviders.list(cloudOnly, enabledOnly), 15 | queryFn: () => oauthService.getProviders(cloudOnly, enabledOnly), 16 | }); 17 | } 18 | 19 | // Update OAuth providers 20 | export function useUpdateOAuthProviders() { 21 | const queryClient = useQueryClient(); 22 | 23 | return useMutation({ 24 | mutationFn: (data: OAuthProvidersUpdate) => 25 | oauthService.updateProviders(data), 26 | onSuccess: async () => { 27 | await queryClient.invalidateQueries({ 28 | queryKey: queryKeys.oauthProviders.all, 29 | }); 30 | toast.success('Provider settings updated successfully'); 31 | }, 32 | onError: (error) => { 33 | toast.error(`Failed to update providers: ${getErrorMessage(error)}`); 34 | }, 35 | }); 36 | } 37 | -------------------------------------------------------------------------------- /frontend/src/lib/api/services/auth.service.ts: -------------------------------------------------------------------------------- 1 | import { apiClient } from '../client'; 2 | import { API_ENDPOINTS } from '../config'; 3 | import type { 4 | LoginRequest, 5 | AuthResponse, 6 | RegisterRequest, 7 | RegisterResponse, 8 | ForgotPasswordRequest, 9 | ResetPasswordRequest, 10 | Developer, 11 | } from '../types'; 12 | 13 | export const authService = { 14 | async login(credentials: LoginRequest): Promise { 15 | // fastapi-users expects OAuth2 form data with username/password 16 | return apiClient.postForm(API_ENDPOINTS.login, { 17 | username: credentials.email, 18 | password: credentials.password, 19 | }); 20 | }, 21 | 22 | async register(data: RegisterRequest): Promise { 23 | return apiClient.post(API_ENDPOINTS.register, data); 24 | }, 25 | 26 | async logout(): Promise { 27 | return apiClient.post(API_ENDPOINTS.logout); 28 | }, 29 | 30 | async me(): Promise { 31 | return apiClient.get(API_ENDPOINTS.me); 32 | }, 33 | 34 | async forgotPassword(data: ForgotPasswordRequest): Promise { 35 | return apiClient.post(API_ENDPOINTS.forgotPassword, data); 36 | }, 37 | 38 | async resetPassword(data: ResetPasswordRequest): Promise { 39 | return apiClient.post(API_ENDPOINTS.resetPassword, data); 40 | }, 41 | }; 42 | -------------------------------------------------------------------------------- /frontend/src/lib/errors/handler.ts: -------------------------------------------------------------------------------- 1 | import type { ApiErrorResponse } from '@/lib/api/types'; 2 | import { ApiError } from './api-error'; 3 | 4 | export function getErrorMessage(error: unknown): string { 5 | // Handle ApiError instances 6 | if (error instanceof ApiError) { 7 | return error.getUserFriendlyMessage(); 8 | } 9 | 10 | // Handle standard Error instances 11 | if (error instanceof Error) { 12 | return error.message; 13 | } 14 | 15 | // Handle API error responses 16 | if (typeof error === 'object' && error !== null && 'message' in error) { 17 | return (error as ApiErrorResponse).message; 18 | } 19 | 20 | // Fallback for unknown errors 21 | return 'An unexpected error occurred'; 22 | } 23 | 24 | export function getErrorCode(error: unknown): string | undefined { 25 | if (error instanceof ApiError) { 26 | return error.code; 27 | } 28 | 29 | if (typeof error === 'object' && error !== null && 'code' in error) { 30 | return (error as ApiErrorResponse).code; 31 | } 32 | 33 | return undefined; 34 | } 35 | 36 | export function getErrorDetails( 37 | error: unknown 38 | ): Record | undefined { 39 | if (error instanceof ApiError) { 40 | return error.details; 41 | } 42 | 43 | if (typeof error === 'object' && error !== null && 'details' in error) { 44 | return (error as ApiErrorResponse).details; 45 | } 46 | 47 | return undefined; 48 | } 49 | -------------------------------------------------------------------------------- /frontend/src/lib/constants/providers.ts: -------------------------------------------------------------------------------- 1 | export interface WearableProvider { 2 | id: string; 3 | name: string; 4 | description: string; 5 | logoPath: string; // Path to logo in public folder 6 | brandColor: string; 7 | isAvailable: boolean; 8 | features: string[]; 9 | } 10 | 11 | /** 12 | * Hardcoded list of wearable providers based on backend API spec. 13 | * These are the OAuth providers supported by the backend. 14 | */ 15 | export const WEARABLE_PROVIDERS: WearableProvider[] = [ 16 | { 17 | id: 'garmin', 18 | name: 'Garmin', 19 | description: 'Fitness trackers, running watches and smartwatches', 20 | logoPath: '/garmin.svg', 21 | brandColor: '#007DC3', 22 | isAvailable: true, 23 | features: ['Heart Rate', 'Activity', 'Sleep', 'Workouts'], 24 | }, 25 | { 26 | id: 'polar', 27 | name: 'Polar', 28 | description: 'Heart rate monitors and sports watches', 29 | logoPath: '/polar.svg', 30 | brandColor: '#D40029', 31 | isAvailable: true, 32 | features: ['Heart Rate', 'Training', 'Recovery'], 33 | }, 34 | { 35 | id: 'suunto', 36 | name: 'Suunto', 37 | description: 'GPS multisport and outdoor watches', 38 | logoPath: '/suunto.svg', 39 | brandColor: '#E41F1C', 40 | isAvailable: true, 41 | features: ['GPS', 'Heart Rate', 'Activity'], 42 | }, 43 | ] as const; 44 | 45 | export type WearableProviderId = (typeof WEARABLE_PROVIDERS)[number]['id']; 46 | -------------------------------------------------------------------------------- /backend/app/api/routes/v1/developers.py: -------------------------------------------------------------------------------- 1 | from uuid import UUID 2 | 3 | from fastapi import APIRouter 4 | 5 | from app.database import DbSession 6 | from app.schemas import DeveloperRead, DeveloperUpdate 7 | from app.services import DeveloperDep, developer_service 8 | 9 | router = APIRouter() 10 | 11 | 12 | @router.get("/", response_model=list[DeveloperRead]) 13 | async def list_developers(db: DbSession, _auth: DeveloperDep): 14 | """List all developers (team members).""" 15 | return db.query(developer_service.crud.model).all() 16 | 17 | 18 | @router.get("/{developer_id}", response_model=DeveloperRead) 19 | async def get_developer(developer_id: UUID, db: DbSession, _auth: DeveloperDep): 20 | """Get developer by ID.""" 21 | return developer_service.get(db, developer_id, raise_404=True) 22 | 23 | 24 | @router.patch("/{developer_id}", response_model=DeveloperRead) 25 | async def update_developer( 26 | developer_id: UUID, 27 | payload: DeveloperUpdate, 28 | db: DbSession, 29 | _auth: DeveloperDep, 30 | ): 31 | """Update developer by ID.""" 32 | return developer_service.update_developer_info(db, developer_id, payload, raise_404=True) 33 | 34 | 35 | @router.delete("/{developer_id}", response_model=DeveloperRead) 36 | async def delete_developer(developer_id: UUID, db: DbSession, _auth: DeveloperDep): 37 | """Delete developer by ID.""" 38 | return developer_service.delete(db, developer_id, raise_404=True) 39 | -------------------------------------------------------------------------------- /backend/app/api/routes/v1/timeseries.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | from typing import Annotated, Literal 3 | from uuid import UUID 4 | 5 | from fastapi import APIRouter, Query 6 | 7 | from app.database import DbSession 8 | from app.schemas.common_types import PaginatedResponse 9 | from app.schemas.series_types import SeriesType 10 | from app.schemas.timeseries import ( 11 | BloodGlucoseSample, 12 | HeartRateSample, 13 | HrvSample, 14 | Spo2Sample, 15 | StepsSample, 16 | TimeSeriesQueryParams, 17 | ) 18 | from app.services import ApiKeyDep, timeseries_service 19 | 20 | router = APIRouter() 21 | 22 | 23 | @router.get("/users/{user_id}/timeseries") 24 | async def get_timeseries( 25 | user_id: UUID, 26 | type: SeriesType, 27 | start_time: str, 28 | end_time: str, 29 | db: DbSession, 30 | _api_key: ApiKeyDep, 31 | resolution: Literal["raw", "1min", "5min", "15min", "1hour"] = "raw", 32 | cursor: str | None = None, 33 | limit: Annotated[int, Query(ge=1, le=100)] = 50, 34 | ) -> PaginatedResponse[HeartRateSample | HrvSample | Spo2Sample | BloodGlucoseSample | StepsSample]: 35 | """Returns granular time series data (biometrics or activity).""" 36 | params = TimeSeriesQueryParams( 37 | start_datetime=datetime.fromisoformat(start_time), 38 | end_datetime=datetime.fromisoformat(end_time), 39 | ) 40 | return await timeseries_service.get_timeseries(db, user_id, type, params) 41 | -------------------------------------------------------------------------------- /backend/app/services/providers/suunto/strategy.py: -------------------------------------------------------------------------------- 1 | from app.services.providers.base_strategy import BaseProviderStrategy 2 | from app.services.providers.suunto.data_247 import Suunto247Data 3 | from app.services.providers.suunto.oauth import SuuntoOAuth 4 | from app.services.providers.suunto.workouts import SuuntoWorkouts 5 | 6 | 7 | class SuuntoStrategy(BaseProviderStrategy): 8 | """Suunto provider implementation.""" 9 | 10 | def __init__(self): 11 | super().__init__() 12 | self.oauth = SuuntoOAuth( 13 | user_repo=self.user_repo, 14 | connection_repo=self.connection_repo, 15 | provider_name=self.name, 16 | api_base_url=self.api_base_url, 17 | ) 18 | self.workouts = SuuntoWorkouts( 19 | workout_repo=self.workout_repo, 20 | connection_repo=self.connection_repo, 21 | provider_name=self.name, 22 | api_base_url=self.api_base_url, 23 | oauth=self.oauth, 24 | ) 25 | # New: 247 data handler for sleep, recovery, activity samples 26 | self.data_247 = Suunto247Data( 27 | provider_name=self.name, 28 | api_base_url=self.api_base_url, 29 | oauth=self.oauth, 30 | ) 31 | 32 | @property 33 | def name(self) -> str: 34 | return "suunto" 35 | 36 | @property 37 | def api_base_url(self) -> str: 38 | return "https://cloudapi.suunto.com" 39 | -------------------------------------------------------------------------------- /backend/config/.env.example: -------------------------------------------------------------------------------- 1 | #--- APP ---# 2 | 3 | # Environment: local, test, staging, production 4 | ENVIRONMENT="local" 5 | CORS_ORIGINS=["http://localhost:3000"] 6 | SERVER_HOST="https://new_project_name.dev" 7 | 8 | #--- DB ---# 9 | DB_HOST=db 10 | DP_PORT=5432 11 | DB_NAME=open-wearables 12 | DB_USER=open-wearables 13 | DB_PASSWORD=open-wearables 14 | 15 | #--- REDIS ---# 16 | REDIS_HOST=redis 17 | REDIS_PORT=6379 18 | REDIS_DB=0 19 | # REDIS_PASSWORD=your-secure-password # Uncomment and set for production 20 | # REDIS_USERNAME=default # Uncomment if using Redis 6.0+ ACL 21 | 22 | #--- SENTRY ---# 23 | SENTRY_ENABLED=True 24 | SENTRY_DSN="" 25 | SENTRY_ENV=production 26 | SENTRY_SAMPLES_RATE=0.5 27 | 28 | #--- AUTH ---# 29 | # python3 -c "import secrets; print(secrets.token_urlsafe(64))" 30 | SECRET_KEY=secret-key-str 31 | 32 | #--- AWS ---# 33 | AWS_BUCKET_NAME=open-wearables 34 | AWS_ACCESS_KEY_ID=your-access-id 35 | AWS_SECRET_ACCESS_KEY=your-access-key 36 | AWS_REGION=eu-north-1 37 | SQS_QUEUE_URL=https://sqs.eu-north-1.amazonaws.com/12345678/xyz-queue 38 | 39 | #--- Providers ---# 40 | 41 | #--- Suunto ---# 42 | SUUNTO_CLIENT_ID=public-client-id 43 | SUUNTO_CLIENT_SECRET=private-secret-id 44 | SUUNTO_SUBSCRIPTION_KEY=private-subscription-id 45 | 46 | #--- Polar ---# 47 | POLAR_CLIENT_ID=public-client-id 48 | POLAR_CLIENT_SECRET=private-secret-id 49 | 50 | #--- Garmin ---# 51 | GARMIN_CLIENT_ID=your-garmin-client-id 52 | GARMIN_CLIENT_SECRET=your-garmin-client-secret 53 | -------------------------------------------------------------------------------- /backend/app/models/event_record.py: -------------------------------------------------------------------------------- 1 | from uuid import UUID 2 | 3 | from sqlalchemy import Index, UniqueConstraint 4 | from sqlalchemy.orm import Mapped, relationship 5 | 6 | from app.database import BaseDbModel 7 | from app.mappings import ( 8 | FKExternalMapping, 9 | PrimaryKey, 10 | datetime_tz, 11 | str_32, 12 | str_64, 13 | str_100, 14 | ) 15 | 16 | 17 | class EventRecord(BaseDbModel): 18 | __tablename__ = "event_record" 19 | __table_args__ = ( 20 | Index("idx_event_record_mapping_category", "external_device_mapping_id", "category"), 21 | Index("idx_event_record_mapping_time", "external_device_mapping_id", "start_datetime", "end_datetime"), 22 | UniqueConstraint( 23 | "external_device_mapping_id", 24 | "start_datetime", 25 | "end_datetime", 26 | name="uq_event_record_datetime", 27 | ), 28 | ) 29 | 30 | id: Mapped[PrimaryKey[UUID]] 31 | external_id: Mapped[str_100 | None] 32 | external_device_mapping_id: Mapped[FKExternalMapping] 33 | 34 | category: Mapped[str_32] 35 | type: Mapped[str_32 | None] 36 | source_name: Mapped[str_64] 37 | 38 | duration_seconds: Mapped[int | None] 39 | 40 | start_datetime: Mapped[datetime_tz] 41 | end_datetime: Mapped[datetime_tz] 42 | 43 | detail: Mapped["EventRecordDetail | None"] = relationship( 44 | "EventRecordDetail", 45 | uselist=False, 46 | cascade="all, delete-orphan", 47 | ) 48 | -------------------------------------------------------------------------------- /backend/app/main.py: -------------------------------------------------------------------------------- 1 | from logging import INFO, basicConfig 2 | from pathlib import Path 3 | 4 | from fastapi import FastAPI, Request 5 | from fastapi.exceptions import RequestValidationError 6 | from fastapi.staticfiles import StaticFiles 7 | from sqladmin import Admin 8 | 9 | from app.api import head_router 10 | from app.config import settings 11 | from app.database import engine 12 | from app.integrations.celery import create_celery 13 | from app.integrations.sentry import init_sentry 14 | from app.integrations.sqladmin import add_admin_views 15 | from app.middlewares import add_cors_middleware 16 | from app.utils.exceptions import handle_exception 17 | 18 | basicConfig(level=INFO, format="[%(asctime)s - %(name)s] (%(levelname)s) %(message)s") 19 | 20 | api = FastAPI(title=settings.api_name) 21 | admin = Admin(app=api, engine=engine) 22 | add_admin_views(admin) 23 | celery_app = create_celery() 24 | init_sentry() 25 | 26 | add_cors_middleware(api) 27 | 28 | # Mount static files for provider icons 29 | static_dir = Path(__file__).parent / "static" 30 | if static_dir.exists(): 31 | api.mount("/static", StaticFiles(directory=str(static_dir)), name="static") 32 | 33 | 34 | @api.get("/") 35 | async def root() -> dict[str, str]: 36 | return {"message": "Server is running!"} 37 | 38 | 39 | @api.exception_handler(RequestValidationError) 40 | async def request_validation_exception_handler(_: Request, exc: RequestValidationError) -> None: 41 | raise handle_exception(exc, "") 42 | 43 | 44 | api.include_router(head_router) 45 | -------------------------------------------------------------------------------- /backend/app/models/user_connection.py: -------------------------------------------------------------------------------- 1 | from uuid import UUID 2 | 3 | from sqlalchemy import Index, UniqueConstraint 4 | from sqlalchemy.orm import Mapped 5 | 6 | from app.database import BaseDbModel 7 | from app.mappings import FKUser, PrimaryKey, datetime_tz, str_64 8 | from app.schemas.oauth import ConnectionStatus 9 | 10 | 11 | class UserConnection(BaseDbModel): 12 | """OAuth connections to external cloud providers (Suunto, Garmin, Polar, Coros)""" 13 | 14 | __table_args__ = ( 15 | UniqueConstraint("user_id", "provider", name="uq_user_provider"), 16 | Index( 17 | "idx_user_connection_token_expiry", 18 | "token_expires_at", 19 | postgresql_where="status = 'active'", 20 | ), 21 | Index("idx_user_connection_user_provider", "user_id", "provider"), 22 | ) 23 | __tablename__ = "user_connection" 24 | 25 | id: Mapped[PrimaryKey[UUID]] 26 | user_id: Mapped[FKUser] 27 | provider: Mapped[str_64] # 'suunto', 'garmin', 'polar', 'coros' 28 | 29 | # Provider user data 30 | provider_user_id: Mapped[str | None] 31 | provider_username: Mapped[str | None] 32 | 33 | # OAuth tokens 34 | access_token: Mapped[str] 35 | refresh_token: Mapped[str | None] 36 | token_expires_at: Mapped[datetime_tz] 37 | scope: Mapped[str | None] 38 | 39 | # Metadata 40 | status: Mapped[ConnectionStatus] 41 | last_synced_at: Mapped[datetime_tz | None] 42 | created_at: Mapped[datetime_tz] 43 | updated_at: Mapped[datetime_tz] 44 | -------------------------------------------------------------------------------- /frontend/Dockerfile: -------------------------------------------------------------------------------- 1 | # Build stage 2 | FROM node:22-alpine AS builder 3 | 4 | # Install pnpm 5 | RUN corepack enable && corepack prepare pnpm@10.13.1 --activate 6 | 7 | WORKDIR /app 8 | 9 | # Build arguments for environment variables 10 | ARG VITE_API_URL=http://localhost:8000 11 | ENV VITE_API_URL=${VITE_API_URL} 12 | 13 | # Copy package files 14 | COPY package.json pnpm-lock.yaml ./ 15 | 16 | # Install dependencies 17 | RUN pnpm install --frozen-lockfile 18 | 19 | # Copy source files 20 | COPY . . 21 | 22 | # Build the application 23 | RUN pnpm run build 24 | 25 | # Determine build output directory and prepare it for copying 26 | # Nitro outputs to .output/public, Vite outputs to dist 27 | RUN if [ -d /app/.output/public ]; then \ 28 | echo "Using Nitro output: .output/public"; \ 29 | cp -r /app/.output/public /app/build-output; \ 30 | elif [ -d /app/dist ]; then \ 31 | echo "Using Vite output: dist"; \ 32 | cp -r /app/dist /app/build-output; \ 33 | else \ 34 | echo "ERROR: No build output found!"; \ 35 | ls -la /app/; \ 36 | exit 1; \ 37 | fi 38 | 39 | # Production stage 40 | FROM node:22-alpine 41 | 42 | WORKDIR /app 43 | # Copy built assets from builder 44 | COPY --from=builder /app/build-output /usr/share/nginx/html 45 | 46 | # Copy built output from builder 47 | COPY --from=builder /app/.output ./.output 48 | 49 | # Expose port 50 | EXPOSE 3000 51 | 52 | # Set the port for Nitro 53 | ENV PORT=3000 54 | ENV HOST=0.0.0.0 55 | 56 | # Run the Nitro server 57 | CMD ["node", ".output/server/index.mjs"] 58 | -------------------------------------------------------------------------------- /frontend/src/lib/utils/format.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Formatting utility functions for dates, durations, and other display values. 3 | */ 4 | 5 | /** 6 | * Format a date string to a localized string representation. 7 | * Returns 'Never' if the date is null or undefined. 8 | */ 9 | export function formatDate(dateString: string | null | undefined): string { 10 | if (!dateString) return 'Never'; 11 | return new Date(dateString).toLocaleString(); 12 | } 13 | 14 | /** 15 | * Format a date string to a localized date (no time). 16 | * Returns 'Never' if the date is null or undefined. 17 | */ 18 | export function formatDateOnly(dateString: string | null | undefined): string { 19 | if (!dateString) return 'Never'; 20 | return new Date(dateString).toLocaleDateString(); 21 | } 22 | 23 | /** 24 | * Format duration in seconds to a human-readable string. 25 | * Examples: "45m", "1h 30m", "2h 0m" 26 | */ 27 | export function formatDuration(seconds: string | number): string { 28 | const totalSeconds = 29 | typeof seconds === 'string' ? parseInt(seconds, 10) : seconds; 30 | if (isNaN(totalSeconds)) return '—'; 31 | 32 | const hours = Math.floor(totalSeconds / 3600); 33 | const minutes = Math.floor((totalSeconds % 3600) / 60); 34 | 35 | if (hours > 0) { 36 | return `${hours}h ${minutes}m`; 37 | } 38 | return `${minutes}m`; 39 | } 40 | 41 | /** 42 | * Truncate a UUID to show only the first 8 characters. 43 | */ 44 | export function truncateId(id: string, length = 8): string { 45 | if (!id) return '—'; 46 | return `${id.slice(0, length)}...`; 47 | } 48 | -------------------------------------------------------------------------------- /frontend/src/hooks/api/use-dashboard.ts: -------------------------------------------------------------------------------- 1 | import { useQuery } from '@tanstack/react-query'; 2 | import { dashboardService } from '../../lib/api'; 3 | import { queryKeys } from '../../lib/query/keys'; 4 | 5 | export function useDashboardStats() { 6 | return useQuery({ 7 | queryKey: queryKeys.dashboard.stats(), 8 | queryFn: () => dashboardService.getStats(), 9 | staleTime: 2 * 60 * 1000, // 2 minutes 10 | }); 11 | } 12 | 13 | export function useApiCallsData(timeRange?: string) { 14 | return useQuery({ 15 | queryKey: queryKeys.dashboard.charts(timeRange), 16 | queryFn: () => dashboardService.getApiCallsData(timeRange), 17 | staleTime: 5 * 60 * 1000, // 5 minutes 18 | }); 19 | } 20 | 21 | export function useDataPointsData(timeRange?: string) { 22 | return useQuery({ 23 | queryKey: [...queryKeys.dashboard.charts(timeRange), 'dataPoints'], 24 | queryFn: () => dashboardService.getDataPointsData(timeRange), 25 | staleTime: 5 * 60 * 1000, 26 | }); 27 | } 28 | 29 | export function useAutomationTriggersData(timeRange?: string) { 30 | return useQuery({ 31 | queryKey: [...queryKeys.dashboard.charts(timeRange), 'automationTriggers'], 32 | queryFn: () => dashboardService.getAutomationTriggersData(timeRange), 33 | staleTime: 5 * 60 * 1000, 34 | }); 35 | } 36 | 37 | export function useTriggersByTypeData() { 38 | return useQuery({ 39 | queryKey: [...queryKeys.dashboard.charts(), 'triggersByType'], 40 | queryFn: () => dashboardService.getTriggersByTypeData(), 41 | staleTime: 10 * 60 * 1000, // 10 minutes 42 | }); 43 | } 44 | -------------------------------------------------------------------------------- /frontend/src/routes/index.tsx: -------------------------------------------------------------------------------- 1 | import { createFileRoute, redirect, Navigate } from '@tanstack/react-router'; 2 | import { isAuthenticated } from '@/lib/auth/session'; 3 | import { LoadingSpinner } from '@/components/common/loading-spinner'; 4 | 5 | export const Route = createFileRoute('/')({ 6 | beforeLoad: async () => { 7 | // Skip redirect during SSR - localStorage is not available on the server 8 | if (typeof window === 'undefined') { 9 | return; 10 | } 11 | // Redirect to users if authenticated, otherwise to login 12 | if (isAuthenticated()) { 13 | throw redirect({ 14 | to: '/users', 15 | }); 16 | } else { 17 | throw redirect({ 18 | to: '/login', 19 | }); 20 | } 21 | }, 22 | component: IndexRedirect, 23 | }); 24 | 25 | function IndexRedirect() { 26 | // This component handles the client-side redirect after SSR hydration 27 | // The beforeLoad will handle the actual redirect, but we need a component 28 | // for SSR to render something. After hydration, beforeLoad kicks in. 29 | if (typeof window !== 'undefined') { 30 | // Client-side: beforeLoad should have already redirected 31 | // If we get here, fall back to Navigate 32 | if (isAuthenticated()) { 33 | return ; 34 | } 35 | return ; 36 | } 37 | 38 | // During SSR, render a minimal loading state 39 | return ( 40 | 41 | 42 | 43 | ); 44 | } 45 | -------------------------------------------------------------------------------- /backend/app/schemas/common_types.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | from typing import Literal 3 | 4 | from pydantic import BaseModel, Field 5 | 6 | 7 | class DataSource(BaseModel): 8 | provider: str = Field(..., example="apple_health") 9 | device: str | None = Field(None, example="Apple Watch Series 9") 10 | 11 | 12 | class TimeseriesMetadata(BaseModel): 13 | resolution: Literal["raw", "1min", "5min", "15min", "1hour"] | None = None 14 | sample_count: int | None = None 15 | start_time: datetime | None = None 16 | end_time: datetime | None = None 17 | 18 | 19 | class Pagination(BaseModel): 20 | next_cursor: str | None = Field( 21 | None, 22 | description="Cursor to fetch next page, null if no more data", 23 | example="eyJpZCI6IjEyMzQ1Njc4OTAiLCJ0cyI6MTcwNDA2NzIwMH0", 24 | ) 25 | previous_cursor: str | None = Field(None, description="Cursor to fetch previous page") 26 | has_more: bool = Field(..., description="Whether more data is available") 27 | 28 | 29 | class ErrorDetails(BaseModel): 30 | code: str 31 | message: str 32 | details: dict | None = None 33 | 34 | 35 | class PaginatedResponse[DataT](BaseModel): 36 | """Generic response model for paginated data with metadata. 37 | 38 | Can be used with any data type by specifying the type parameter: 39 | - PaginatedResponse[HeartRateSample] 40 | - PaginatedResponse[HeartRateSample | HrvSample | Spo2Sample] 41 | - PaginatedResponse[Workout] # for other endpoints 42 | """ 43 | 44 | data: list[DataT] 45 | pagination: Pagination 46 | metadata: TimeseriesMetadata 47 | -------------------------------------------------------------------------------- /backend/app/schemas/developer.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime, timezone 2 | from uuid import UUID, uuid4 3 | 4 | from pydantic import BaseModel, ConfigDict, EmailStr, Field 5 | 6 | 7 | class DeveloperRead(BaseModel): 8 | model_config = ConfigDict(from_attributes=True) 9 | 10 | id: UUID 11 | first_name: str | None = None 12 | last_name: str | None = None 13 | email: EmailStr 14 | created_at: datetime 15 | updated_at: datetime 16 | 17 | 18 | class DeveloperCreate(BaseModel): 19 | first_name: str | None = Field(None, max_length=100) 20 | last_name: str | None = Field(None, max_length=100) 21 | email: EmailStr 22 | password: str = Field(..., min_length=8) 23 | 24 | 25 | class DeveloperCreateInternal(BaseModel): 26 | id: UUID = Field(default_factory=uuid4) 27 | first_name: str | None = None 28 | last_name: str | None = None 29 | email: EmailStr 30 | hashed_password: str 31 | created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc)) 32 | updated_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc)) 33 | 34 | 35 | class DeveloperUpdate(BaseModel): 36 | first_name: str | None = Field(None, max_length=100) 37 | last_name: str | None = Field(None, max_length=100) 38 | email: EmailStr | None = None 39 | password: str | None = Field(None, min_length=8) 40 | 41 | 42 | class DeveloperUpdateInternal(BaseModel): 43 | first_name: str | None = None 44 | last_name: str | None = None 45 | email: EmailStr | None = None 46 | hashed_password: str | None = None 47 | updated_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc)) 48 | -------------------------------------------------------------------------------- /backend/app/api/routes/v1/users.py: -------------------------------------------------------------------------------- 1 | from typing import Annotated 2 | from uuid import UUID 3 | 4 | from fastapi import APIRouter, Depends, status 5 | 6 | from app.database import DbSession 7 | from app.schemas.common import PaginatedResponse 8 | from app.schemas.user import UserCreate, UserQueryParams, UserRead, UserUpdate 9 | from app.services import ApiKeyDep, DeveloperDep, user_service 10 | 11 | router = APIRouter() 12 | 13 | 14 | @router.get("/users", response_model=PaginatedResponse[UserRead]) 15 | async def list_users( 16 | db: DbSession, 17 | _api_key: ApiKeyDep, 18 | query_params: Annotated[UserQueryParams, Depends()], 19 | ): 20 | """List users with pagination, sorting, and search.""" 21 | return user_service.get_users_paginated(db, query_params) 22 | 23 | 24 | @router.get("/users/{user_id}", response_model=UserRead) 25 | async def get_user(user_id: UUID, db: DbSession, _api_key: ApiKeyDep): 26 | return user_service.get(db, user_id, raise_404=True) 27 | 28 | 29 | @router.post("/users", status_code=status.HTTP_201_CREATED, response_model=UserRead) 30 | async def create_user(payload: UserCreate, db: DbSession, _api_key: ApiKeyDep): 31 | return user_service.create(db, payload) 32 | 33 | 34 | @router.delete("/users/{user_id}", response_model=UserRead) 35 | async def delete_user(user_id: UUID, db: DbSession, _developer: DeveloperDep): 36 | return user_service.delete(db, user_id, raise_404=True) 37 | 38 | 39 | @router.patch("/users/{user_id}", response_model=UserRead) 40 | async def update_user(user_id: UUID, payload: UserUpdate, db: DbSession, _developer: DeveloperDep): 41 | return user_service.update(db, user_id, payload, raise_404=True) 42 | -------------------------------------------------------------------------------- /backend/app/schemas/apple/auto_export/json_schemas.py: -------------------------------------------------------------------------------- 1 | # ruff: noqa: N815 2 | 3 | from __future__ import annotations 4 | 5 | from typing import Any 6 | 7 | from pydantic import BaseModel, Field, field_validator 8 | 9 | 10 | class QuantityJSON(BaseModel): 11 | qty: float | int | None = None 12 | units: str | None = None 13 | 14 | 15 | class HeartRateEntryJSON(BaseModel): 16 | avg: float | None = Field(default=None, alias="Avg") 17 | min: float | None = Field(default=None, alias="Min") 18 | max: float | None = Field(default=None, alias="Max") 19 | units: str | None = None 20 | date: str 21 | source: str | None = None 22 | 23 | @field_validator("date") 24 | @classmethod 25 | def parse_date(cls, v: str) -> str: 26 | return v 27 | 28 | 29 | class ActiveEnergyEntryJSON(BaseModel): 30 | qty: float | int | None = None 31 | units: str | None = None 32 | date: str 33 | source: str | None = None 34 | 35 | 36 | class WorkoutJSON(BaseModel): 37 | id: str | None = None 38 | name: str | None = None 39 | location: str | None = None 40 | start: str 41 | end: str 42 | duration: float | None = None 43 | 44 | activeEnergyBurned: QuantityJSON | None = None 45 | distance: QuantityJSON | None = None 46 | intensity: QuantityJSON | None = None 47 | humidity: QuantityJSON | None = None 48 | temperature: QuantityJSON | None = None 49 | 50 | heartRateData: list[HeartRateEntryJSON] | None = None 51 | heartRateRecovery: list[HeartRateEntryJSON] | None = None 52 | activeEnergy: list[ActiveEnergyEntryJSON] | None = None 53 | 54 | metadata: dict[str, Any] = Field(default_factory=dict) 55 | -------------------------------------------------------------------------------- /frontend/src/lib/api/services/users.service.ts: -------------------------------------------------------------------------------- 1 | import { apiClient } from '../client'; 2 | import { API_ENDPOINTS } from '../config'; 3 | import { appendSearchParams } from '@/lib/utils/url'; 4 | import type { 5 | UserRead, 6 | UserCreate, 7 | UserUpdate, 8 | UserQueryParams, 9 | PaginatedUsersResponse, 10 | } from '../types'; 11 | 12 | export const usersService = { 13 | async getAll(params?: UserQueryParams): Promise { 14 | const searchParams = new URLSearchParams(); 15 | 16 | if (params) { 17 | appendSearchParams(searchParams, { 18 | page: params.page, 19 | limit: params.limit, 20 | sort_by: params.sort_by, 21 | sort_order: params.sort_order, 22 | search: params.search, 23 | email: params.email, 24 | external_user_id: params.external_user_id, 25 | }); 26 | } 27 | 28 | const queryString = searchParams.toString(); 29 | const url = queryString 30 | ? `${API_ENDPOINTS.users}?${queryString}` 31 | : API_ENDPOINTS.users; 32 | 33 | return apiClient.get(url); 34 | }, 35 | 36 | async getById(id: string): Promise { 37 | return apiClient.get(API_ENDPOINTS.userDetail(id)); 38 | }, 39 | 40 | async create(data: UserCreate): Promise { 41 | return apiClient.post(API_ENDPOINTS.users, data); 42 | }, 43 | 44 | async update(id: string, data: UserUpdate): Promise { 45 | return apiClient.patch(API_ENDPOINTS.userDetail(id), data); 46 | }, 47 | 48 | async delete(id: string): Promise { 49 | return apiClient.delete(API_ENDPOINTS.userDetail(id)); 50 | }, 51 | }; 52 | -------------------------------------------------------------------------------- /backend/app/schemas/event_record_detail.py: -------------------------------------------------------------------------------- 1 | from decimal import Decimal 2 | from uuid import UUID 3 | 4 | from pydantic import BaseModel 5 | 6 | 7 | class EventRecordDetailBase(BaseModel): 8 | """Base schema for event record detail.""" 9 | 10 | heart_rate_min: int | None = None 11 | heart_rate_max: int | None = None 12 | heart_rate_avg: Decimal | None = None 13 | 14 | steps_count: int | None = None 15 | energy_burned: Decimal | None = None 16 | distance: Decimal | None = None 17 | 18 | max_speed: Decimal | None = None 19 | max_watts: Decimal | None = None 20 | 21 | average_speed: Decimal | None = None 22 | average_watts: Decimal | None = None 23 | 24 | moving_time_seconds: int | None = None 25 | total_elevation_gain: Decimal | None = None 26 | 27 | elev_high: Decimal | None = None 28 | elev_low: Decimal | None = None 29 | 30 | # Sleep-specific fields 31 | sleep_total_duration_minutes: int | None = None 32 | sleep_time_in_bed_minutes: int | None = None 33 | sleep_efficiency_score: Decimal | None = None 34 | sleep_deep_minutes: int | None = None 35 | sleep_rem_minutes: int | None = None 36 | sleep_light_minutes: int | None = None 37 | sleep_awake_minutes: int | None = None 38 | is_nap: bool | None = None 39 | 40 | is_nap: bool | None = None 41 | 42 | 43 | class EventRecordDetailCreate(EventRecordDetailBase): 44 | """Schema for creating an event record detail entry.""" 45 | 46 | record_id: UUID 47 | 48 | 49 | class EventRecordDetailUpdate(EventRecordDetailBase): 50 | """Schema for updating an event record detail entry.""" 51 | 52 | 53 | class EventRecordDetailResponse(EventRecordDetailBase): 54 | """Schema returned to API consumers.""" 55 | 56 | record_id: UUID 57 | -------------------------------------------------------------------------------- /backend/scripts/init/seed_series_types.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | """Initialize series_type_definition table with all available series types.""" 3 | 4 | from app.database import SessionLocal 5 | from app.models import SeriesTypeDefinition 6 | from app.schemas.series_types import SERIES_TYPE_DEFINITIONS 7 | 8 | 9 | def seed_series_types() -> None: 10 | """Ensure all series types from SERIES_TYPE_DEFINITIONS exist in database.""" 11 | with SessionLocal() as db: 12 | for type_id, series_type, unit in SERIES_TYPE_DEFINITIONS: 13 | # Check if this series type already exists 14 | existing = db.query(SeriesTypeDefinition).filter(SeriesTypeDefinition.id == type_id).first() 15 | 16 | if existing: 17 | # Update if code or unit changed 18 | if existing.code != series_type.value or existing.unit != unit: 19 | existing.code = series_type.value 20 | existing.unit = unit 21 | print(f"✓ Updated series type {type_id}: {series_type.value} ({unit})") 22 | else: 23 | print(f" Series type {type_id}: {series_type.value} already exists, skipping.") 24 | else: 25 | # Create new series type 26 | new_type = SeriesTypeDefinition( 27 | id=type_id, 28 | code=series_type.value, 29 | unit=unit, 30 | ) 31 | db.add(new_type) 32 | print(f"✓ Created series type {type_id}: {series_type.value} ({unit})") 33 | 34 | db.commit() 35 | print(f"✓ Series type definitions initialized: {len(SERIES_TYPE_DEFINITIONS)} types") 36 | 37 | 38 | if __name__ == "__main__": 39 | seed_series_types() 40 | -------------------------------------------------------------------------------- /frontend/src/components/ui/badge.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { cva, type VariantProps } from 'class-variance-authority'; 3 | 4 | import { cn } from '@/lib/utils'; 5 | 6 | const badgeVariants = cva( 7 | 'inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-all duration-200 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2', 8 | { 9 | variants: { 10 | variant: { 11 | default: 12 | 'border-transparent bg-primary text-primary-foreground hover:bg-primary/80 shadow-[0_0_10px_hsla(185,100%,50%,0.3)]', 13 | secondary: 14 | 'border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80 shadow-[0_0_10px_hsla(315,100%,60%,0.2)]', 15 | destructive: 16 | 'border-transparent bg-destructive text-red-300 hover:bg-destructive/80 shadow-[0_0_10px_hsla(350,100%,55%,0.3)]', 17 | outline: 18 | 'text-foreground border-primary/30 hover:border-primary/50 hover:shadow-[0_0_8px_hsla(185,100%,50%,0.2)]', 19 | success: 20 | 'border-transparent bg-success text-green-300 hover:bg-success/80 shadow-[0_0_10px_hsla(145,100%,50%,0.3)]', 21 | warning: 22 | 'border-transparent bg-warning text-orange-300 hover:bg-warning/80 shadow-[0_0_10px_hsla(45,100%,55%,0.3)]', 23 | }, 24 | }, 25 | defaultVariants: { 26 | variant: 'default', 27 | }, 28 | } 29 | ); 30 | 31 | export interface BadgeProps 32 | extends React.HTMLAttributes, 33 | VariantProps {} 34 | 35 | function Badge({ className, variant, ...props }: BadgeProps) { 36 | return ( 37 | 38 | ); 39 | } 40 | 41 | export { Badge, badgeVariants }; 42 | -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | ## Description 2 | 3 | 4 | 5 | 6 | 7 | ### Related Issue 8 | 9 | 10 | 11 | 12 | 13 | ## Type of Change 14 | 15 | 16 | 17 | - [ ] Bug fix (non-breaking change that fixes an issue) 18 | - [ ] New feature (non-breaking change that adds functionality) 19 | - [ ] Breaking change (fix or feature that would cause existing functionality to change) 20 | - [ ] Documentation update 21 | - [ ] Refactoring (no functional changes) 22 | - [ ] Other (please describe): 23 | 24 | ## Checklist 25 | 26 | ### General 27 | 28 | - [ ] My code follows the project's code style 29 | - [ ] I have performed a self-review of my code 30 | - [ ] I have added tests that prove my fix/feature works (if applicable) 31 | - [ ] New and existing tests pass locally 32 | 33 | ### Backend Changes 34 | 35 | 36 | 37 | - [ ] `uv run ruff check` passes 38 | - [ ] `uv run ruff format --check` passes 39 | - [ ] `uv run ty check` passes 40 | 41 | ### Frontend Changes 42 | 43 | 44 | 45 | - [ ] `pnpm run lint` passes 46 | - [ ] `pnpm run format:check` passes 47 | - [ ] `pnpm run build` succeeds 48 | 49 | ## Testing Instructions 50 | 51 | 52 | 53 | **Steps to test:** 54 | 1. 55 | 2. 56 | 3. 57 | 58 | **Expected behavior:** 59 | 60 | 61 | 62 | ## Screenshots 63 | 64 | 65 | 66 | 67 | 68 | ## Additional Notes 69 | 70 | 71 | 72 | 73 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | DOCKER_COMMAND = docker compose -f docker-compose.yml 2 | DOCKER_EXEC = $(DOCKER_COMMAND) exec app 3 | ALEMBIC_CMD = uv run alembic 4 | 5 | help: ## Show this help. 6 | @echo "============================================================" 7 | @echo "This is a list of available commands for this project." 8 | @echo "============================================================" 9 | @fgrep -h "##" $(MAKEFILE_LIST) | fgrep -v fgrep | sed -e 's/\\$$//' | sed -e 's/##//' 10 | 11 | build: ## Builds docker image 12 | $(DOCKER_COMMAND) build --no-cache 13 | 14 | run: ## Runs the envionment in detached mode 15 | $(DOCKER_COMMAND) up -d --force-recreate 16 | 17 | up: ## Runs the non-detached environment 18 | $(DOCKER_COMMAND) up --force-recreate 19 | 20 | watch: ## Runs the environment with hot-reload 21 | $(DOCKER_COMMAND) --watch 22 | 23 | stop: ## Stops running instance 24 | $(DOCKER_COMMAND) stop 25 | 26 | down: ## Kills running instance 27 | $(DOCKER_COMMAND) down 28 | 29 | test: ## Run the tests. 30 | export ENV=backend/config/.env.test && \ 31 | cd backend && uv run pytest -v --cov=app 32 | 33 | migrate: ## Apply all migrations 34 | $(DOCKER_EXEC) $(ALEMBIC_CMD) upgrade head 35 | 36 | init: ## Seed sample data 37 | $(DOCKER_EXEC) uv sync --group dev 38 | $(DOCKER_EXEC) uv run python scripts/init/seed_admin.py 39 | $(DOCKER_EXEC) uv run python scripts/init/seed_series_types.py 40 | $(DOCKER_EXEC) uv run python scripts/init/seed_activity_data.py 41 | 42 | create_migration: ## Create a new migration. Use 'make create_migration m="Description of the change"' 43 | @if [ -z "$(m)" ]; then \ 44 | echo "Error: You must provide a migration description using 'm=\"Description\"'"; \ 45 | exit 1; \ 46 | fi 47 | $(DOCKER_EXEC) $(ALEMBIC_CMD) revision --autogenerate -m "$(m)" 48 | 49 | downgrade: ## Revert the last migration 50 | $(DOCKER_EXEC) $(ALEMBIC_CMD) downgrade -1 51 | -------------------------------------------------------------------------------- /backend/app/api/routes/v1/api_keys.py: -------------------------------------------------------------------------------- 1 | from typing import Annotated 2 | 3 | from fastapi import APIRouter, Body, status 4 | 5 | from app.database import DbSession 6 | from app.schemas.api_key import ApiKeyRead, ApiKeyUpdate 7 | from app.services import DeveloperDep, api_key_service 8 | 9 | router = APIRouter() 10 | 11 | 12 | @router.get("/api-keys", response_model=list[ApiKeyRead]) 13 | async def list_api_keys(db: DbSession, _developer: DeveloperDep): 14 | """List all API keys.""" 15 | return api_key_service.list_api_keys(db) 16 | 17 | 18 | @router.post("/api-keys", status_code=status.HTTP_201_CREATED, response_model=ApiKeyRead) 19 | async def create_api_key( 20 | db: DbSession, 21 | _developer: DeveloperDep, 22 | name: Annotated[str, Body(embed=True, description="Name for the API key")] = "Default", 23 | ): 24 | """Generate new API key.""" 25 | return api_key_service.create_api_key(db, _developer.id, name) 26 | 27 | 28 | @router.delete("/api-keys/{key_id}", response_model=ApiKeyRead) 29 | async def delete_api_key(key_id: str, db: DbSession, _developer: DeveloperDep): 30 | """Delete API key by key value.""" 31 | return api_key_service.delete(db, key_id, raise_404=True) 32 | 33 | 34 | @router.patch("/api-keys/{key_id}", response_model=ApiKeyRead) 35 | async def update_api_key( 36 | key_id: str, 37 | payload: ApiKeyUpdate, 38 | db: DbSession, 39 | _developer: DeveloperDep, 40 | ): 41 | """Update API key (future: name, scopes).""" 42 | return api_key_service.update(db, key_id, payload, raise_404=True) 43 | 44 | 45 | @router.post("/api-keys/{key_id}/rotate", status_code=status.HTTP_201_CREATED, response_model=ApiKeyRead) 46 | async def rotate_api_key(key_id: str, db: DbSession, _developer: DeveloperDep): 47 | """Rotate API key - delete old and generate new.""" 48 | return api_key_service.rotate_api_key(db, key_id, _developer.id) 49 | -------------------------------------------------------------------------------- /frontend/src/routes/_authenticated/settings.tsx: -------------------------------------------------------------------------------- 1 | import { createFileRoute } from '@tanstack/react-router'; 2 | import { useState } from 'react'; 3 | import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs'; 4 | import { CredentialsTab } from './settings/credentials-tab'; 5 | import { ProvidersTab } from './settings/providers-tab'; 6 | import { TeamTab } from './settings/team-tab'; 7 | 8 | export const Route = createFileRoute('/_authenticated/settings')({ 9 | component: SettingsPage, 10 | }); 11 | 12 | interface TabConfig { 13 | id: string; 14 | label: string; 15 | component: React.ComponentType; 16 | } 17 | 18 | const tabs: TabConfig[] = [ 19 | { 20 | id: 'credentials', 21 | label: 'Credentials', 22 | component: CredentialsTab, 23 | }, 24 | { 25 | id: 'providers', 26 | label: 'Providers', 27 | component: ProvidersTab, 28 | }, 29 | { 30 | id: 'team', 31 | label: 'Team', 32 | component: TeamTab, 33 | }, 34 | ]; 35 | 36 | function SettingsPage() { 37 | const [activeTab, setActiveTab] = useState(tabs[0].id); 38 | 39 | return ( 40 | 41 | 42 | Settings 43 | 44 | Manage your settings and preferences 45 | 46 | 47 | 48 | 49 | 50 | {tabs.map((tab) => ( 51 | 52 | {tab.label} 53 | 54 | ))} 55 | 56 | 57 | {tabs.map((tab) => ( 58 | 59 | 60 | 61 | ))} 62 | 63 | 64 | ); 65 | } 66 | -------------------------------------------------------------------------------- /frontend/src/lib/utils/health.ts: -------------------------------------------------------------------------------- 1 | import type { WorkoutStatisticResponse } from '@/lib/api/types'; 2 | 3 | export interface HeartRateStats { 4 | values: number[]; 5 | min: number | null; 6 | max: number | null; 7 | avg: number | null; 8 | count: number; 9 | } 10 | 11 | /** 12 | * Calculate heart rate statistics from workout statistics data. 13 | * Filters for heart rate type statistics and computes min, max, avg. 14 | */ 15 | export function calculateHeartRateStats( 16 | data: WorkoutStatisticResponse[] | undefined 17 | ): HeartRateStats { 18 | const defaultStats: HeartRateStats = { 19 | values: [], 20 | min: null, 21 | max: null, 22 | avg: null, 23 | count: 0, 24 | }; 25 | 26 | if (!data || data.length === 0) { 27 | return defaultStats; 28 | } 29 | 30 | const stats = data.reduce( 31 | (acc, stat) => { 32 | // Check for heart rate type (case-insensitive) 33 | const isHeartRate = 34 | stat.type.toLowerCase() === 'heartrate' || 35 | stat.type.toLowerCase() === 'heart_rate'; 36 | 37 | if (!isHeartRate) return acc; 38 | 39 | if (stat.avg !== null) { 40 | acc.values.push(stat.avg); 41 | } 42 | if (stat.min !== null && (acc.min === null || stat.min < acc.min)) { 43 | acc.min = stat.min; 44 | } 45 | if (stat.max !== null && (acc.max === null || stat.max > acc.max)) { 46 | acc.max = stat.max; 47 | } 48 | 49 | return acc; 50 | }, 51 | { 52 | values: [] as number[], 53 | min: null as number | null, 54 | max: null as number | null, 55 | } 56 | ); 57 | 58 | const avg = 59 | stats.values.length > 0 60 | ? Math.round( 61 | stats.values.reduce((a, b) => a + b, 0) / stats.values.length 62 | ) 63 | : null; 64 | 65 | return { 66 | ...stats, 67 | avg, 68 | count: stats.values.length, 69 | }; 70 | } 71 | -------------------------------------------------------------------------------- /frontend/src/components/ui/sonner.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | CircleCheck, 3 | Info, 4 | LoaderCircle, 5 | OctagonX, 6 | TriangleAlert, 7 | } from 'lucide-react'; 8 | import { Toaster as Sonner } from 'sonner'; 9 | 10 | type ToasterProps = React.ComponentProps; 11 | 12 | const Toaster = ({ ...props }: ToasterProps) => { 13 | return ( 14 | , 19 | info: , 20 | warning: , 21 | error: , 22 | loading: , 23 | }} 24 | toastOptions={{ 25 | classNames: { 26 | toast: 27 | 'group toast group-[.toaster]:bg-card group-[.toaster]:text-foreground group-[.toaster]:border-border/50 group-[.toaster]:shadow-lg', 28 | description: 'group-[.toast]:text-muted-foreground', 29 | actionButton: 30 | 'group-[.toast]:bg-primary group-[.toast]:text-primary-foreground', 31 | cancelButton: 32 | 'group-[.toast]:bg-muted group-[.toast]:text-muted-foreground', 33 | success: 34 | 'group-[.toaster]:!bg-success/10 group-[.toaster]:!border-success/30 group-[.toaster]:!text-success', 35 | error: 36 | 'group-[.toaster]:!bg-destructive/10 group-[.toaster]:!border-destructive/30 group-[.toaster]:!text-destructive', 37 | warning: 38 | 'group-[.toaster]:!bg-warning/10 group-[.toaster]:!border-warning/30 group-[.toaster]:!text-warning', 39 | info: 'group-[.toaster]:!bg-primary/10 group-[.toaster]:!border-primary/30 group-[.toaster]:!text-primary', 40 | }, 41 | }} 42 | {...props} 43 | /> 44 | ); 45 | }; 46 | 47 | export { Toaster }; 48 | -------------------------------------------------------------------------------- /backend/app/schemas/invitation.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | from enum import StrEnum 3 | from uuid import UUID 4 | 5 | from pydantic import BaseModel, ConfigDict, EmailStr, Field 6 | 7 | 8 | class InvitationStatus(StrEnum): 9 | PENDING = "pending" # Email queued, delivery in progress 10 | SENT = "sent" # Email delivered, waiting for acceptance 11 | FAILED = "failed" # Email delivery failed after all retries 12 | ACCEPTED = "accepted" 13 | EXPIRED = "expired" 14 | REVOKED = "revoked" 15 | 16 | 17 | class InvitationCreate(BaseModel): 18 | """Schema for creating a new invitation (API input).""" 19 | 20 | email: EmailStr 21 | 22 | 23 | class InvitationCreateInternal(BaseModel): 24 | """Schema for creating invitation internally with all fields.""" 25 | 26 | id: UUID 27 | email: EmailStr 28 | token: str 29 | status: InvitationStatus 30 | expires_at: datetime 31 | created_at: datetime 32 | invited_by_id: UUID | None = None 33 | 34 | 35 | class InvitationResend(BaseModel): 36 | """Schema for resending an invitation (updates token and expiry).""" 37 | 38 | token: str 39 | expires_at: datetime 40 | status: InvitationStatus = InvitationStatus.PENDING 41 | 42 | 43 | class InvitationRead(BaseModel): 44 | """Schema for reading invitation data.""" 45 | 46 | model_config = ConfigDict(from_attributes=True) 47 | 48 | id: UUID 49 | email: str 50 | status: InvitationStatus 51 | expires_at: datetime 52 | created_at: datetime 53 | invited_by_id: UUID | None = None 54 | 55 | 56 | class InvitationAccept(BaseModel): 57 | """Schema for accepting an invitation.""" 58 | 59 | token: str 60 | first_name: str = Field(..., min_length=1, max_length=100, strip_whitespace=True) 61 | last_name: str = Field(..., min_length=1, max_length=100, strip_whitespace=True) 62 | password: str = Field(..., min_length=8) 63 | -------------------------------------------------------------------------------- /backend/app/services/providers/garmin/oauth.py: -------------------------------------------------------------------------------- 1 | import httpx 2 | 3 | from app.config import settings 4 | from app.schemas import ( 5 | AuthenticationMethod, 6 | OAuthTokenResponse, 7 | ProviderCredentials, 8 | ProviderEndpoints, 9 | ) 10 | from app.services.providers.templates.base_oauth import BaseOAuthTemplate 11 | 12 | 13 | class GarminOAuth(BaseOAuthTemplate): 14 | """Garmin OAuth 2.0 with PKCE implementation.""" 15 | 16 | @property 17 | def endpoints(self) -> ProviderEndpoints: 18 | return ProviderEndpoints( 19 | authorize_url="https://connect.garmin.com/oauth2Confirm", 20 | token_url="https://diauth.garmin.com/di-oauth2-service/oauth/token", 21 | ) 22 | 23 | @property 24 | def credentials(self) -> ProviderCredentials: 25 | return ProviderCredentials( 26 | client_id=settings.garmin_client_id or "", 27 | client_secret=(settings.garmin_client_secret.get_secret_value() if settings.garmin_client_secret else ""), 28 | redirect_uri=settings.garmin_redirect_uri, 29 | default_scope=settings.garmin_default_scope, 30 | ) 31 | 32 | use_pkce = True 33 | auth_method = AuthenticationMethod.BODY 34 | 35 | def _get_provider_user_info(self, token_response: OAuthTokenResponse, user_id: str) -> dict[str, str | None]: 36 | """Fetches Garmin user ID via API.""" 37 | try: 38 | user_id_response = httpx.get( 39 | f"{self.api_base_url}/wellness-api/rest/user/id", 40 | headers={"Authorization": f"Bearer {token_response.access_token}"}, 41 | timeout=30.0, 42 | ) 43 | user_id_response.raise_for_status() 44 | provider_user_id = user_id_response.json().get("userId") 45 | return {"user_id": provider_user_id, "username": None} 46 | except Exception: 47 | return {"user_id": None, "username": None} 48 | -------------------------------------------------------------------------------- /frontend/src/lib/api/services/oauth.service.ts: -------------------------------------------------------------------------------- 1 | import { apiClient } from '../client'; 2 | import { API_ENDPOINTS } from '../config'; 3 | import type { Provider } from '../types'; 4 | 5 | /** 6 | * Update payload for OAuth provider settings. 7 | * Maps provider identifiers to their enabled/disabled state. 8 | */ 9 | export interface OAuthProvidersUpdate { 10 | /** Record of provider identifiers to their enabled status (true = enabled, false = disabled) */ 11 | providers: Record; 12 | } 13 | 14 | /** 15 | * Service for managing OAuth provider configurations. 16 | */ 17 | export const oauthService = { 18 | /** 19 | * Retrieves a list of OAuth providers. 20 | * 21 | * @param cloudOnly - If true, only returns cloud-based providers. Defaults to false. 22 | * @param enabledOnly - If true, only returns enabled providers. Defaults to false. 23 | * @returns Promise resolving to an array of Provider objects. 24 | */ 25 | async getProviders( 26 | cloudOnly: boolean = false, 27 | enabledOnly: boolean = false 28 | ): Promise { 29 | const params = new URLSearchParams(); 30 | if (cloudOnly) params.append('cloud_only', 'true'); 31 | if (enabledOnly) params.append('enabled_only', 'true'); 32 | 33 | const endpoint = params.toString() 34 | ? `${API_ENDPOINTS.oauthProviders}?${params}` 35 | : API_ENDPOINTS.oauthProviders; 36 | 37 | return apiClient.get(endpoint); 38 | }, 39 | 40 | /** 41 | * Updates the enabled/disabled state of OAuth providers. 42 | * 43 | * @param data - Update payload containing provider states. 44 | * @returns Promise resolving to an object containing the updated list of providers. 45 | */ 46 | async updateProviders( 47 | data: OAuthProvidersUpdate 48 | ): Promise<{ providers: Provider[] }> { 49 | return apiClient.put<{ providers: Provider[] }>( 50 | API_ENDPOINTS.oauthProviders, 51 | data 52 | ); 53 | }, 54 | }; 55 | -------------------------------------------------------------------------------- /backend/app/repositories/event_record_detail_repository.py: -------------------------------------------------------------------------------- 1 | from typing import Literal 2 | from uuid import UUID 3 | 4 | from app.database import DbSession 5 | from app.models import ( 6 | EventRecordDetail, 7 | SleepDetails, 8 | WorkoutDetails, 9 | ) 10 | from app.repositories.repositories import CrudRepository 11 | from app.schemas.event_record_detail import ( 12 | EventRecordDetailCreate, 13 | EventRecordDetailUpdate, 14 | ) 15 | from app.utils.duplicates import handle_duplicates 16 | from app.utils.exceptions import handle_exceptions 17 | 18 | DetailType = Literal["workout", "sleep"] 19 | 20 | 21 | class EventRecordDetailRepository( 22 | CrudRepository[EventRecordDetail, EventRecordDetailCreate, EventRecordDetailUpdate], 23 | ): 24 | def __init__(self, model: type[EventRecordDetail]): 25 | super().__init__(model) 26 | 27 | @handle_exceptions 28 | @handle_duplicates 29 | def create( 30 | self, 31 | db_session: DbSession, 32 | creator: EventRecordDetailCreate, 33 | detail_type: DetailType = "workout", 34 | ) -> EventRecordDetail: 35 | """Create a detail record using the appropriate polymorphic model.""" 36 | creation_data = creator.model_dump(exclude_none=True) 37 | 38 | if detail_type == "workout": 39 | detail = WorkoutDetails(**creation_data) 40 | elif detail_type == "sleep": 41 | detail = SleepDetails(**creation_data) 42 | else: 43 | raise ValueError(f"Unknown detail type: {detail_type}") 44 | 45 | db_session.add(detail) 46 | db_session.commit() 47 | db_session.refresh(detail) 48 | return detail 49 | 50 | def get_by_record_id(self, db_session: DbSession, record_id: UUID) -> EventRecordDetail | None: 51 | """Get detail by its associated event record ID.""" 52 | return db_session.query(EventRecordDetail).filter(EventRecordDetail.record_id == record_id).one_or_none() 53 | -------------------------------------------------------------------------------- /docs/provider-setup/index.mdx: -------------------------------------------------------------------------------- 1 | --- 2 | title: "Supported providers" 3 | description: "Connect and configure wearable device providers" 4 | --- 5 | 6 | ## Available Providers 7 | 8 | Connect your wearable devices and fitness platforms to Open Wearables. Click on a provider name to view its setup guide. 9 | 10 | ### Cloud-based providers 11 | 12 | | Provider | Setup Guide | Notes | 13 | |----------|-------------|--------| 14 | | **Suunto** | [Setup Guide →](/provider-setup/suunto) | You must apply for [Suunto Developer Program](https://www.suunto.com/en-gb/partners/partners/) | 15 | | **Garmin** | [Setup Guide →](/provider-setup/garmin) | You must apply for [Garmin Developer Program](https://developer.garmin.com/) | 16 | | **Polar** | [Setup Guide →](/provider-setup/polar) | You must register for the [Polar API program](https://www.polar.com/en/business/api) | 17 | | **Fitbit** | — | Coming soon | 18 | | **Oura Ring** | — | Coming soon | 19 | | **Whoop** | — | Coming soon | 20 | | **Strava** | — | Coming soon | 21 | 22 | ### SDK-based providers 23 | 24 | | Provider | Setup Guide | Notes | 25 | |----------|-------------|--------| 26 | | **Apple Health** | [Setup Guide →](/provider-setup/apple-health) | Mobile SDK coming soon! | 27 | 28 | ## Adding New Providers 29 | 30 | More providers are being added regularly. Check the current status of provider requests and implementations on [GitHub Issues](https://github.com/the-momentum/open-wearables/issues?q=is%3Aissue+label%3Aprovider-request) tagged with `provider-request`. 31 | 32 | ## General Setup Steps 33 | 34 | While each provider has specific requirements, the general setup process typically involves: 35 | 36 | 1. **Register as a developer** with the provider 37 | 2. **Create an OAuth application** to obtain client credentials 38 | 3. **Configure credentials** in your Open Wearables instance 39 | 4. **Test the connection** using the connection widget or API 40 | 41 | 42 | 43 | For detailed instructions, select a provider from the table above. 44 | 45 | -------------------------------------------------------------------------------- /frontend/src/components/ui/number-ticker.tsx: -------------------------------------------------------------------------------- 1 | import { ComponentPropsWithoutRef, useEffect, useRef } from 'react'; 2 | import { useInView, useMotionValue, useSpring } from 'motion/react'; 3 | 4 | import { cn } from '@/lib/utils'; 5 | 6 | interface NumberTickerProps extends ComponentPropsWithoutRef<'span'> { 7 | value: number; 8 | startValue?: number; 9 | direction?: 'up' | 'down'; 10 | delay?: number; 11 | decimalPlaces?: number; 12 | } 13 | 14 | export function NumberTicker({ 15 | value, 16 | startValue = 0, 17 | direction = 'up', 18 | delay = 0, 19 | className, 20 | decimalPlaces = 0, 21 | ...props 22 | }: NumberTickerProps) { 23 | const ref = useRef(null); 24 | const motionValue = useMotionValue(direction === 'down' ? value : startValue); 25 | const springValue = useSpring(motionValue, { 26 | damping: 60, 27 | stiffness: 100, 28 | }); 29 | const isInView = useInView(ref, { once: true, margin: '0px' }); 30 | 31 | useEffect(() => { 32 | if (isInView) { 33 | const timer = setTimeout(() => { 34 | motionValue.set(direction === 'down' ? startValue : value); 35 | }, delay * 1000); 36 | return () => clearTimeout(timer); 37 | } 38 | }, [motionValue, isInView, delay, value, direction, startValue]); 39 | 40 | useEffect( 41 | () => 42 | springValue.on('change', (latest) => { 43 | if (ref.current) { 44 | ref.current.textContent = Intl.NumberFormat('en-US', { 45 | minimumFractionDigits: decimalPlaces, 46 | maximumFractionDigits: decimalPlaces, 47 | }).format(Number(latest.toFixed(decimalPlaces))); 48 | } 49 | }), 50 | [springValue, decimalPlaces] 51 | ); 52 | 53 | return ( 54 | 62 | {startValue} 63 | 64 | ); 65 | } 66 | -------------------------------------------------------------------------------- /backend/migrations/env.py: -------------------------------------------------------------------------------- 1 | from logging.config import fileConfig 2 | 3 | from alembic import context 4 | from sqlalchemy import engine_from_config, pool 5 | 6 | from app.config import settings 7 | from app.database import BaseDbModel 8 | 9 | config = context.config 10 | config.set_main_option("sqlalchemy.url", settings.db_uri) 11 | 12 | if config.config_file_name is not None: 13 | fileConfig(config.config_file_name) 14 | 15 | target_metadata = BaseDbModel.metadata 16 | 17 | 18 | def run_migrations_offline() -> None: 19 | """Run migrations in 'offline' mode. 20 | 21 | This configures the context with just a URL 22 | and not an Engine, though an Engine is acceptable 23 | here as well. By skipping the Engine creation 24 | we don't even need a DBAPI to be available. 25 | 26 | Calls to context.execute() here emit the given string to the 27 | script output. 28 | 29 | """ 30 | url = config.get_main_option("sqlalchemy.url") 31 | context.configure( 32 | url=url, 33 | target_metadata=target_metadata, 34 | literal_binds=True, 35 | dialect_opts={"paramstyle": "named"}, 36 | ) 37 | 38 | with context.begin_transaction(): 39 | context.run_migrations() 40 | 41 | 42 | def run_migrations_online() -> None: 43 | """Run migrations in 'online' mode. 44 | 45 | In this scenario we need to create an Engine 46 | and associate a connection with the context. 47 | 48 | """ 49 | connectable = engine_from_config( 50 | config.get_section(config.config_ini_section, {}), 51 | prefix="sqlalchemy.", 52 | poolclass=pool.NullPool, 53 | ) 54 | 55 | with connectable.connect() as connection: 56 | context.configure( 57 | connection=connection, 58 | target_metadata=target_metadata, 59 | ) 60 | 61 | with context.begin_transaction(): 62 | context.run_migrations() 63 | 64 | 65 | if context.is_offline_mode(): 66 | run_migrations_offline() 67 | else: 68 | run_migrations_online() 69 | -------------------------------------------------------------------------------- /backend/app/services/providers/suunto/oauth.py: -------------------------------------------------------------------------------- 1 | from jose import jwt 2 | 3 | from app.config import settings 4 | from app.schemas import OAuthTokenResponse, ProviderCredentials, ProviderEndpoints 5 | from app.services.providers.templates.base_oauth import BaseOAuthTemplate 6 | 7 | 8 | class SuuntoOAuth(BaseOAuthTemplate): 9 | """Suunto OAuth 2.0 implementation.""" 10 | 11 | @property 12 | def endpoints(self) -> ProviderEndpoints: 13 | return ProviderEndpoints( 14 | authorize_url="https://cloudapi-oauth.suunto.com/oauth/authorize", 15 | token_url="https://cloudapi-oauth.suunto.com/oauth/token", 16 | ) 17 | 18 | @property 19 | def credentials(self) -> ProviderCredentials: 20 | return ProviderCredentials( 21 | client_id=settings.suunto_client_id or "", 22 | client_secret=(settings.suunto_client_secret.get_secret_value() if settings.suunto_client_secret else ""), 23 | redirect_uri=settings.suunto_redirect_uri, 24 | default_scope=settings.suunto_default_scope, 25 | subscription_key=( 26 | settings.suunto_subscription_key.get_secret_value() if settings.suunto_subscription_key else "" 27 | ), 28 | ) 29 | 30 | def _get_provider_user_info(self, token_response: OAuthTokenResponse, user_id: str) -> dict[str, str | None]: 31 | """Extracts Suunto user info from JWT access token.""" 32 | try: 33 | # jwt.decode requires a key parameter, but we're not verifying signature 34 | decoded = jwt.decode( 35 | token_response.access_token, 36 | key="", # Empty key since we're not verifying 37 | options={"verify_signature": False}, 38 | ) 39 | provider_username = decoded.get("user") 40 | provider_user_id = decoded.get("sub") 41 | return {"user_id": provider_user_id, "username": provider_username} 42 | except Exception: 43 | return {"user_id": None, "username": None} 44 | -------------------------------------------------------------------------------- /frontend/src/lib/api/services/automations.service.ts: -------------------------------------------------------------------------------- 1 | import { apiClient } from '../client'; 2 | import { API_ENDPOINTS } from '../config'; 3 | import type { 4 | Automation, 5 | AutomationCreate, 6 | AutomationUpdate, 7 | AutomationTrigger, 8 | TestAutomationResult, 9 | } from '../types'; 10 | 11 | export const automationsService = { 12 | async getAutomations(): Promise { 13 | return apiClient.get(API_ENDPOINTS.automations); 14 | }, 15 | 16 | async getAutomation(id: string): Promise { 17 | return apiClient.get(API_ENDPOINTS.automationDetail(id)); 18 | }, 19 | 20 | async createAutomation(data: AutomationCreate): Promise { 21 | return apiClient.post(API_ENDPOINTS.automations, data); 22 | }, 23 | 24 | async updateAutomation( 25 | id: string, 26 | data: AutomationUpdate 27 | ): Promise { 28 | return apiClient.patch( 29 | API_ENDPOINTS.automationDetail(id), 30 | data 31 | ); 32 | }, 33 | 34 | async deleteAutomation(id: string): Promise { 35 | return apiClient.delete(API_ENDPOINTS.automationDetail(id)); 36 | }, 37 | 38 | async toggleAutomation(id: string, isEnabled: boolean): Promise { 39 | return this.updateAutomation(id, { isEnabled }); 40 | }, 41 | 42 | async getAutomationTriggers( 43 | automationId: string 44 | ): Promise { 45 | return apiClient.get( 46 | `${API_ENDPOINTS.automationDetail(automationId)}/triggers` 47 | ); 48 | }, 49 | 50 | async testAutomation(automationId: string): Promise { 51 | return apiClient.post( 52 | API_ENDPOINTS.testAutomation(automationId), 53 | {} 54 | ); 55 | }, 56 | 57 | async improveDescription( 58 | description: string 59 | ): Promise<{ improvedDescription: string }> { 60 | return apiClient.post<{ improvedDescription: string }>( 61 | `${API_ENDPOINTS.automations}/improve-description`, 62 | { description } 63 | ); 64 | }, 65 | }; 66 | -------------------------------------------------------------------------------- /frontend/src/lib/api/services/dashboard.service.ts: -------------------------------------------------------------------------------- 1 | import { apiClient } from '../client'; 2 | import { API_ENDPOINTS } from '../config'; 3 | import type { 4 | DashboardStats, 5 | ApiCallsDataPoint, 6 | DataPointsDataPoint, 7 | AutomationTriggersDataPoint, 8 | TriggersByTypeDataPoint, 9 | } from '../types'; 10 | 11 | export const dashboardService = { 12 | async getStats(): Promise { 13 | return apiClient.get(API_ENDPOINTS.dashboardStats); 14 | }, 15 | 16 | async getApiCallsData(timeRange?: string): Promise { 17 | const params = new URLSearchParams(); 18 | if (timeRange) params.append('timeRange', timeRange); 19 | 20 | const endpoint = params.toString() 21 | ? `${API_ENDPOINTS.dashboardCharts}/api-calls?${params}` 22 | : `${API_ENDPOINTS.dashboardCharts}/api-calls`; 23 | 24 | return apiClient.get(endpoint); 25 | }, 26 | 27 | async getDataPointsData(timeRange?: string): Promise { 28 | const params = new URLSearchParams(); 29 | if (timeRange) params.append('timeRange', timeRange); 30 | 31 | const endpoint = params.toString() 32 | ? `${API_ENDPOINTS.dashboardCharts}/data-points?${params}` 33 | : `${API_ENDPOINTS.dashboardCharts}/data-points`; 34 | 35 | return apiClient.get(endpoint); 36 | }, 37 | 38 | async getAutomationTriggersData( 39 | timeRange?: string 40 | ): Promise { 41 | const params = new URLSearchParams(); 42 | if (timeRange) params.append('timeRange', timeRange); 43 | 44 | const endpoint = params.toString() 45 | ? `${API_ENDPOINTS.dashboardCharts}/automation-triggers?${params}` 46 | : `${API_ENDPOINTS.dashboardCharts}/automation-triggers`; 47 | 48 | return apiClient.get(endpoint); 49 | }, 50 | 51 | async getTriggersByTypeData(): Promise { 52 | return apiClient.get( 53 | `${API_ENDPOINTS.dashboardCharts}/triggers-by-type` 54 | ); 55 | }, 56 | }; 57 | -------------------------------------------------------------------------------- /backend/migrations/versions/c1a2b3c4d5e6_add_invitation_table.py: -------------------------------------------------------------------------------- 1 | """add invitation table and developer name fields 2 | 3 | Revision ID: c1a2b3c4d5e6 4 | Revises: bbfb683a7c6c 5 | 6 | """ 7 | 8 | from typing import Sequence, Union 9 | 10 | import sqlalchemy as sa 11 | from alembic import op 12 | 13 | # revision identifiers, used by Alembic. 14 | revision: str = "c1a2b3c4d5e6" 15 | down_revision: Union[str, None] = "bbfb683a7c6c" 16 | branch_labels: Union[str, Sequence[str], None] = None 17 | depends_on: Union[str, Sequence[str], None] = None 18 | 19 | 20 | def upgrade() -> None: 21 | # Add first_name and last_name to developer table 22 | op.add_column("developer", sa.Column("first_name", sa.String(length=100), nullable=True)) 23 | op.add_column("developer", sa.Column("last_name", sa.String(length=100), nullable=True)) 24 | 25 | # Create invitation table 26 | op.create_table( 27 | "invitation", 28 | sa.Column("id", sa.UUID(), nullable=False), 29 | sa.Column("email", sa.String(length=255), nullable=False), 30 | sa.Column("token", sa.String(length=255), nullable=False), 31 | sa.Column("status", sa.String(length=50), nullable=False), 32 | sa.Column("expires_at", sa.DateTime(timezone=True), nullable=False), 33 | sa.Column("created_at", sa.DateTime(timezone=True), nullable=False), 34 | sa.Column("invited_by_id", sa.UUID(), nullable=True), 35 | sa.ForeignKeyConstraint(["invited_by_id"], ["developer.id"], ondelete="SET NULL"), 36 | sa.PrimaryKeyConstraint("id"), 37 | ) 38 | op.create_index("ix_invitation_token", "invitation", ["token"], unique=True) 39 | op.create_index("ix_invitation_email_status", "invitation", ["email", "status"], unique=False) 40 | 41 | 42 | def downgrade() -> None: 43 | op.drop_index("ix_invitation_email_status", table_name="invitation") 44 | op.drop_index("ix_invitation_token", table_name="invitation") 45 | op.drop_table("invitation") 46 | 47 | # Remove first_name and last_name from developer table 48 | op.drop_column("developer", "last_name") 49 | op.drop_column("developer", "first_name") 50 | -------------------------------------------------------------------------------- /backend/app/services/providers/base_strategy.py: -------------------------------------------------------------------------------- 1 | from abc import ABC, abstractmethod 2 | 3 | from app.models import EventRecord, User 4 | from app.repositories.event_record_repository import EventRecordRepository 5 | from app.repositories.user_connection_repository import UserConnectionRepository 6 | from app.repositories.user_repository import UserRepository 7 | from app.services.providers.templates.base_247_data import Base247DataTemplate 8 | from app.services.providers.templates.base_oauth import BaseOAuthTemplate 9 | from app.services.providers.templates.base_workouts import BaseWorkoutsTemplate 10 | 11 | 12 | class BaseProviderStrategy(ABC): 13 | """Abstract base class for all fitness data providers.""" 14 | 15 | def __init__(self): 16 | """Initialize shared repositories used by all provider components.""" 17 | self.user_repo = UserRepository(User) 18 | self.connection_repo = UserConnectionRepository() 19 | self.workout_repo = EventRecordRepository(EventRecord) 20 | 21 | # Components should be initialized by subclasses 22 | self.oauth: BaseOAuthTemplate | None = None 23 | self.workouts: BaseWorkoutsTemplate | None = None 24 | self.data_247: Base247DataTemplate | None = None 25 | 26 | @property 27 | @abstractmethod 28 | def name(self) -> str: 29 | """Returns the unique name of the provider (e.g., 'garmin', 'suunto').""" 30 | pass 31 | 32 | @property 33 | @abstractmethod 34 | def api_base_url(self) -> str: 35 | """Returns the base URL for the provider's API.""" 36 | pass 37 | 38 | @property 39 | def display_name(self) -> str: 40 | """Returns the display name of the provider (e.g., 'Garmin', 'Apple Health').""" 41 | return self.name.capitalize() 42 | 43 | @property 44 | def has_cloud_api(self) -> bool: 45 | """Returns True if provider uses cloud OAuth API.""" 46 | return self.oauth is not None 47 | 48 | @property 49 | def icon_url(self) -> str: 50 | """Returns the URL path to the provider's icon.""" 51 | return f"/static/provider-icons/{self.name}.svg" 52 | -------------------------------------------------------------------------------- /frontend/src/components/ui/tooltip.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import * as TooltipPrimitive from '@radix-ui/react-tooltip'; 3 | 4 | import { cn } from '@/lib/utils'; 5 | 6 | function TooltipProvider({ 7 | delayDuration = 0, 8 | ...props 9 | }: React.ComponentProps) { 10 | return ( 11 | 16 | ); 17 | } 18 | 19 | function Tooltip({ 20 | ...props 21 | }: React.ComponentProps) { 22 | return ( 23 | 24 | 25 | 26 | ); 27 | } 28 | 29 | function TooltipTrigger({ 30 | ...props 31 | }: React.ComponentProps) { 32 | return ; 33 | } 34 | 35 | function TooltipContent({ 36 | className, 37 | sideOffset = 0, 38 | children, 39 | ...props 40 | }: React.ComponentProps) { 41 | return ( 42 | 43 | 52 | {children} 53 | 54 | 55 | 56 | ); 57 | } 58 | 59 | export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }; 60 | -------------------------------------------------------------------------------- /frontend/src/lib/validation/auth.schemas.ts: -------------------------------------------------------------------------------- 1 | import { z } from 'zod'; 2 | 3 | export const emailSchema = z 4 | .string() 5 | .min(1, 'Email is required') 6 | .email('Please enter a valid email address'); 7 | 8 | export const passwordSchema = z 9 | .string() 10 | .min(8, 'Password must be at least 8 characters') 11 | .refine( 12 | (val) => { 13 | const hasUpperAndLower = /[A-Z]/.test(val) && /[a-z]/.test(val); 14 | const hasNumber = /[0-9]/.test(val); 15 | return hasUpperAndLower || hasNumber; 16 | }, 17 | { message: 'Password must contain mixed case or a number' } 18 | ); 19 | 20 | export const registerSchema = z 21 | .object({ 22 | email: emailSchema, 23 | password: passwordSchema, 24 | confirmPassword: z.string().min(1, 'Please confirm your password'), 25 | }) 26 | .refine((data) => data.password === data.confirmPassword, { 27 | message: 'Passwords do not match', 28 | path: ['confirmPassword'], 29 | }); 30 | 31 | export const forgotPasswordSchema = z.object({ 32 | email: emailSchema, 33 | }); 34 | 35 | export const resetPasswordSchema = z 36 | .object({ 37 | password: passwordSchema, 38 | confirmPassword: z.string().min(1, 'Please confirm your password'), 39 | }) 40 | .refine((data) => data.password === data.confirmPassword, { 41 | message: 'Passwords do not match', 42 | path: ['confirmPassword'], 43 | }); 44 | 45 | export const acceptInvitationSchema = z 46 | .object({ 47 | first_name: z.string().min(1, 'First name is required'), 48 | last_name: z.string().min(1, 'Last name is required'), 49 | password: passwordSchema, 50 | confirmPassword: z.string().min(1, 'Please confirm your password'), 51 | }) 52 | .refine((data) => data.password === data.confirmPassword, { 53 | message: 'Passwords do not match', 54 | path: ['confirmPassword'], 55 | }); 56 | 57 | export type RegisterFormData = z.infer; 58 | export type ForgotPasswordFormData = z.infer; 59 | export type ResetPasswordFormData = z.infer; 60 | export type AcceptInvitationFormData = z.infer; 61 | -------------------------------------------------------------------------------- /backend/app/api/routes/v1/sdk_sync.py: -------------------------------------------------------------------------------- 1 | from typing import Annotated 2 | 3 | from fastapi import APIRouter, Depends, HTTPException, Request, UploadFile, status 4 | 5 | from app.database import DbSession 6 | from app.schemas import UploadDataResponse 7 | from app.services import ApiKeyDep, ae_import_service, hk_import_service 8 | 9 | router = APIRouter() 10 | 11 | 12 | async def get_content_type(request: Request) -> tuple[str, str]: 13 | content_type = request.headers.get("content-type", "") 14 | if "multipart/form-data" in content_type: 15 | form = await request.form() 16 | file = form.get("file") 17 | if not file: 18 | raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="No file found") 19 | 20 | if isinstance(file, UploadFile): 21 | content_bytes = await file.read() 22 | content_str = content_bytes.decode("utf-8") 23 | else: 24 | content_str = str(file) 25 | else: 26 | body = await request.body() 27 | content_str = body.decode("utf-8") 28 | 29 | return content_str, content_type 30 | 31 | 32 | @router.post("/sdk/users/{user_id}/sync/apple/auto-health-export") 33 | async def sync_data_auto_health_export( 34 | user_id: str, 35 | request: Request, 36 | db: DbSession, 37 | _api_key: ApiKeyDep, 38 | content: Annotated[tuple[str, str], Depends(get_content_type)], 39 | ) -> UploadDataResponse: 40 | """Import health data from file upload or JSON.""" 41 | content_str, content_type = content[0], content[1] 42 | return await ae_import_service.import_data_from_request(db, content_str, content_type, user_id) 43 | 44 | 45 | @router.post("/sdk/users/{user_id}/sync/apple/healthion") 46 | async def sync_data_healthion( 47 | user_id: str, 48 | request: Request, 49 | db: DbSession, 50 | _api_key: ApiKeyDep, 51 | content: Annotated[tuple[str, str], Depends(get_content_type)], 52 | ) -> UploadDataResponse: 53 | """Import health data from file upload or JSON.""" 54 | content_str, content_type = content[0], content[1] 55 | return await hk_import_service.import_data_from_request(db, content_str, content_type, user_id) 56 | -------------------------------------------------------------------------------- /backend/app/api/routes/v1/invitations.py: -------------------------------------------------------------------------------- 1 | from uuid import UUID 2 | 3 | from fastapi import APIRouter, status 4 | 5 | from app.database import DbSession 6 | from app.schemas import DeveloperRead 7 | from app.schemas.invitation import InvitationAccept, InvitationCreate, InvitationRead 8 | from app.services import DeveloperDep 9 | from app.services.invitation_service import invitation_service 10 | 11 | router = APIRouter() 12 | 13 | 14 | # ============ Invitation Management (Authenticated) ============ 15 | 16 | 17 | @router.post("/", status_code=status.HTTP_201_CREATED, response_model=InvitationRead) 18 | async def create_invitation( 19 | payload: InvitationCreate, 20 | db: DbSession, 21 | developer: DeveloperDep, 22 | ): 23 | """Create and send a new invitation.""" 24 | return invitation_service.create_invitation(db, payload, developer) 25 | 26 | 27 | @router.get("/", response_model=list[InvitationRead]) 28 | async def list_invitations(db: DbSession, _auth: DeveloperDep): 29 | """List all pending invitations.""" 30 | return invitation_service.get_active_invitations(db) 31 | 32 | 33 | @router.delete("/{invitation_id}", response_model=InvitationRead) 34 | async def revoke_invitation(invitation_id: UUID, db: DbSession, _auth: DeveloperDep): 35 | """Revoke a pending invitation.""" 36 | return invitation_service.revoke_invitation(db, invitation_id) 37 | 38 | 39 | @router.post("/{invitation_id}/resend", response_model=InvitationRead) 40 | async def resend_invitation(invitation_id: UUID, db: DbSession, _auth: DeveloperDep): 41 | """Resend an invitation email.""" 42 | return invitation_service.resend_invitation(db, invitation_id) 43 | 44 | 45 | # ============ Accept Invitation (Public) ============ 46 | 47 | 48 | @router.post("/accept", status_code=status.HTTP_201_CREATED, response_model=DeveloperRead) 49 | async def accept_invitation(payload: InvitationAccept, db: DbSession): 50 | """Accept an invitation and create a developer account (public endpoint).""" 51 | return invitation_service.accept_invitation( 52 | db, 53 | payload.token, 54 | payload.first_name, 55 | payload.last_name, 56 | payload.password, 57 | ) 58 | -------------------------------------------------------------------------------- /backend/app/services/developer_service.py: -------------------------------------------------------------------------------- 1 | from logging import Logger, getLogger 2 | from uuid import UUID 3 | 4 | from app.database import DbSession 5 | from app.models import Developer 6 | from app.repositories.developer_repository import DeveloperRepository 7 | from app.schemas import DeveloperCreate, DeveloperCreateInternal, DeveloperUpdate, DeveloperUpdateInternal 8 | from app.services.services import AppService 9 | from app.utils.security import get_password_hash 10 | 11 | 12 | class DeveloperService(AppService[DeveloperRepository, Developer, DeveloperCreateInternal, DeveloperUpdateInternal]): 13 | def __init__(self, log: Logger, **kwargs): 14 | super().__init__( 15 | crud_model=DeveloperRepository, 16 | model=Developer, 17 | log=log, 18 | **kwargs, 19 | ) 20 | 21 | def register(self, db_session: DbSession, creator: DeveloperCreate) -> Developer: 22 | """Create a developer with hashed password and server-generated fields.""" 23 | creation_data = creator.model_dump(exclude={"password"}) 24 | internal_creator = DeveloperCreateInternal( 25 | **creation_data, 26 | hashed_password=get_password_hash(creator.password), 27 | ) 28 | return super().create(db_session, internal_creator) 29 | 30 | def update_developer_info( 31 | self, 32 | db_session: DbSession, 33 | object_id: UUID | int, 34 | updater: DeveloperUpdate, 35 | raise_404: bool = False, 36 | ) -> Developer | None: 37 | """Update a developer, hashing password if provided and setting updated_at.""" 38 | developer = self.get(db_session, object_id, raise_404=raise_404) 39 | if not developer: 40 | return None 41 | 42 | update_data = updater.model_dump(exclude={"password"}, exclude_unset=True) 43 | internal_updater = DeveloperUpdateInternal(**update_data) 44 | 45 | if updater.password: 46 | internal_updater.hashed_password = get_password_hash(updater.password) 47 | 48 | return self.crud.update(db_session, developer, internal_updater) 49 | 50 | 51 | developer_service = DeveloperService(log=getLogger(__name__)) 52 | -------------------------------------------------------------------------------- /backend/app/integrations/sqladmin/view_models.py: -------------------------------------------------------------------------------- 1 | from typing import Any, Literal 2 | 3 | from pydantic import BaseModel 4 | from sqlalchemy import inspect 5 | 6 | 7 | def _get_model_fields(cls: Any) -> list[str]: 8 | inspector = inspect(cls.model) 9 | return [attr.key for attr in inspector.attrs] 10 | 11 | 12 | class BaseConfig(BaseModel): 13 | include: Literal["*"] | list[str] | None = None 14 | exclude: list[str] | None = None 15 | 16 | def model_post_init(self, __context: Any) -> None: 17 | if self.include is not None and self.exclude is not None: 18 | raise ValueError("Cannot use both 'include' and 'exclude' in configuration") 19 | 20 | def _all_or_value(self, val: Any) -> str | list[str] | None: 21 | """Convert '*' or ['*'] to '__all__', otherwise return value.""" 22 | return "__all__" if val == "*" or val == ["*"] else val 23 | 24 | 25 | class ColumnConfig(BaseConfig): 26 | searchable: list[str] | None = None 27 | sortable: list[str] | None = None 28 | 29 | def apply_to_class(self, cls: type) -> None: 30 | configs = { 31 | "column_list": self._all_or_value(self.include) or [], 32 | "column_exclude_list": self.exclude if self.exclude else [], 33 | "column_searchable_list": self.searchable if self.searchable else _get_model_fields(cls), 34 | "column_sortable_list": self.sortable if self.sortable else _get_model_fields(cls), 35 | } 36 | 37 | for attr, value in configs.items(): 38 | value and setattr(cls, attr, value) 39 | 40 | 41 | class FormConfig(BaseConfig): 42 | create_rules: list[str] | None = None 43 | edit_rules: list[str] | None = None 44 | 45 | def apply_to_class(self, cls: type) -> None: 46 | configs = { 47 | "form_columns": self._all_or_value(self.include) or [], 48 | "form_excluded_columns": self.exclude if self.exclude else [], 49 | "form_create_rules": self.create_rules if self.create_rules else [], 50 | "form_edit_rules": self.edit_rules if self.edit_rules else [], 51 | } 52 | 53 | for attr, value in configs.items(): 54 | value and setattr(cls, attr, value) 55 | -------------------------------------------------------------------------------- /backend/app/services/providers/polar/oauth.py: -------------------------------------------------------------------------------- 1 | from app.config import settings 2 | from app.schemas import OAuthTokenResponse, ProviderCredentials, ProviderEndpoints 3 | from app.services.providers.templates.base_oauth import BaseOAuthTemplate 4 | 5 | 6 | class PolarOAuth(BaseOAuthTemplate): 7 | """Polar OAuth 2.0 implementation.""" 8 | 9 | @property 10 | def endpoints(self) -> ProviderEndpoints: 11 | return ProviderEndpoints( 12 | authorize_url="https://flow.polar.com/oauth2/authorization", 13 | token_url="https://polarremote.com/v2/oauth2/token", 14 | ) 15 | 16 | @property 17 | def credentials(self) -> ProviderCredentials: 18 | return ProviderCredentials( 19 | client_id=settings.polar_client_id or "", 20 | client_secret=(settings.polar_client_secret.get_secret_value() if settings.polar_client_secret else ""), 21 | redirect_uri=settings.polar_redirect_uri, 22 | default_scope=settings.polar_default_scope, 23 | ) 24 | 25 | def _get_provider_user_info(self, token_response: OAuthTokenResponse, user_id: str) -> dict[str, str | None]: 26 | """Extracts Polar user ID from token response and registers user.""" 27 | provider_user_id = str(token_response.x_user_id) if token_response.x_user_id is not None else None 28 | 29 | if provider_user_id: 30 | self._register_user(token_response.access_token, user_id) 31 | 32 | return {"user_id": provider_user_id, "username": None} 33 | 34 | def _register_user(self, access_token: str, member_id: str) -> None: 35 | """Registers the user with Polar API.""" 36 | import httpx 37 | 38 | try: 39 | register_url = f"{self.api_base_url}/v3/users" 40 | headers = { 41 | "Authorization": f"Bearer {access_token}", 42 | "Content-Type": "application/json", 43 | "Accept": "application/json", 44 | } 45 | payload = {"member-id": member_id} 46 | 47 | httpx.post(register_url, json=payload, headers=headers, timeout=10.0) 48 | except Exception: 49 | # Don't fail the entire flow - user might already be registered 50 | pass 51 | -------------------------------------------------------------------------------- /backend/app/utils/auth.py: -------------------------------------------------------------------------------- 1 | from typing import Annotated 2 | from uuid import UUID 3 | 4 | from fastapi import Depends, HTTPException, status 5 | from fastapi.security import OAuth2PasswordBearer 6 | from jose import JWTError, jwt 7 | 8 | from app.config import settings 9 | from app.database import DbSession 10 | from app.models import Developer 11 | from app.repositories.developer_repository import DeveloperRepository 12 | 13 | oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/v1/auth/login", auto_error=False) 14 | developer_repository = DeveloperRepository(Developer) 15 | 16 | 17 | async def get_current_developer( 18 | db: DbSession, 19 | token: Annotated[str, Depends(oauth2_scheme)], 20 | ) -> Developer: 21 | """Get current authenticated developer from JWT token.""" 22 | credentials_exception = HTTPException( 23 | status_code=status.HTTP_401_UNAUTHORIZED, 24 | detail="Could not validate credentials", 25 | headers={"WWW-Authenticate": "Bearer"}, 26 | ) 27 | try: 28 | payload = jwt.decode(token, settings.secret_key, algorithms=[settings.algorithm]) 29 | developer_id: str = payload.get("sub") 30 | if developer_id is None: 31 | raise credentials_exception 32 | except JWTError: 33 | raise credentials_exception 34 | 35 | developer = developer_repository.get(db, UUID(developer_id)) 36 | if not developer: 37 | raise credentials_exception 38 | 39 | return developer 40 | 41 | 42 | async def get_current_developer_optional( 43 | db: DbSession, 44 | token: Annotated[str | None, Depends(oauth2_scheme)] = None, 45 | ) -> Developer | None: 46 | """Get current authenticated developer from JWT token, or None if not authenticated.""" 47 | if not token: 48 | return None 49 | 50 | try: 51 | payload = jwt.decode(token, settings.secret_key, algorithms=[settings.algorithm]) 52 | developer_id: str = payload.get("sub") 53 | if developer_id is None: 54 | return None 55 | except JWTError: 56 | return None 57 | 58 | return developer_repository.get(db, UUID(developer_id)) 59 | 60 | 61 | DeveloperDep = Annotated[Developer, Depends(get_current_developer)] 62 | DeveloperOptionalDep = Annotated[Developer | None, Depends(get_current_developer_optional)] 63 | -------------------------------------------------------------------------------- /backend/app/api/routes/v1/summaries.py: -------------------------------------------------------------------------------- 1 | from typing import Annotated 2 | from uuid import UUID 3 | 4 | from fastapi import APIRouter, HTTPException, Query 5 | 6 | from app.database import DbSession 7 | from app.schemas.common_types import PaginatedResponse 8 | from app.schemas.summaries import ( 9 | ActivitySummary, 10 | BodySummary, 11 | RecoverySummary, 12 | SleepSummary, 13 | ) 14 | from app.services import ApiKeyDep 15 | 16 | router = APIRouter() 17 | 18 | 19 | @router.get("/users/{user_id}/summaries/activity") 20 | async def get_activity_summary( 21 | user_id: UUID, 22 | start_date: str, 23 | end_date: str, 24 | db: DbSession, 25 | _api_key: ApiKeyDep, 26 | cursor: str | None = None, 27 | limit: Annotated[int, Query(ge=1, le=100)] = 50, 28 | ) -> PaginatedResponse[ActivitySummary]: 29 | """Returns daily aggregated activity metrics.""" 30 | raise HTTPException(status_code=501, detail="Not implemented") 31 | 32 | 33 | @router.get("/users/{user_id}/summaries/sleep") 34 | async def get_sleep_summary( 35 | user_id: UUID, 36 | start_date: str, 37 | end_date: str, 38 | db: DbSession, 39 | _api_key: ApiKeyDep, 40 | cursor: str | None = None, 41 | limit: Annotated[int, Query(ge=1, le=100)] = 50, 42 | ) -> PaginatedResponse[SleepSummary]: 43 | """Returns daily sleep metrics.""" 44 | raise HTTPException(status_code=501, detail="Not implemented") 45 | 46 | 47 | @router.get("/users/{user_id}/summaries/recovery") 48 | async def get_recovery_summary( 49 | user_id: UUID, 50 | start_date: str, 51 | end_date: str, 52 | db: DbSession, 53 | _api_key: ApiKeyDep, 54 | cursor: str | None = None, 55 | limit: Annotated[int, Query(ge=1, le=100)] = 50, 56 | ) -> PaginatedResponse[RecoverySummary]: 57 | """Returns daily recovery metrics (Sleep + HRV + RHR).""" 58 | raise HTTPException(status_code=501, detail="Not implemented") 59 | 60 | 61 | @router.get("/users/{user_id}/summaries/body") 62 | async def get_body_summary( 63 | user_id: UUID, 64 | start_date: str, 65 | end_date: str, 66 | db: DbSession, 67 | _api_key: ApiKeyDep, 68 | cursor: str | None = None, 69 | limit: Annotated[int, Query(ge=1, le=100)] = 50, 70 | ) -> PaginatedResponse[BodySummary]: 71 | """Returns daily body metrics.""" 72 | raise HTTPException(status_code=501, detail="Not implemented") 73 | -------------------------------------------------------------------------------- /frontend/src/components/ui/card.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | 3 | import { cn } from '@/lib/utils'; 4 | 5 | const Card = React.forwardRef< 6 | HTMLDivElement, 7 | React.HTMLAttributes 8 | >(({ className, ...props }, ref) => ( 9 | 19 | )); 20 | Card.displayName = 'Card'; 21 | 22 | const CardHeader = React.forwardRef< 23 | HTMLDivElement, 24 | React.HTMLAttributes 25 | >(({ className, ...props }, ref) => ( 26 | 31 | )); 32 | CardHeader.displayName = 'CardHeader'; 33 | 34 | const CardTitle = React.forwardRef< 35 | HTMLDivElement, 36 | React.HTMLAttributes 37 | >(({ className, ...props }, ref) => ( 38 | 46 | )); 47 | CardTitle.displayName = 'CardTitle'; 48 | 49 | const CardDescription = React.forwardRef< 50 | HTMLDivElement, 51 | React.HTMLAttributes 52 | >(({ className, ...props }, ref) => ( 53 | 58 | )); 59 | CardDescription.displayName = 'CardDescription'; 60 | 61 | const CardContent = React.forwardRef< 62 | HTMLDivElement, 63 | React.HTMLAttributes 64 | >(({ className, ...props }, ref) => ( 65 | 66 | )); 67 | CardContent.displayName = 'CardContent'; 68 | 69 | const CardFooter = React.forwardRef< 70 | HTMLDivElement, 71 | React.HTMLAttributes 72 | >(({ className, ...props }, ref) => ( 73 | 78 | )); 79 | CardFooter.displayName = 'CardFooter'; 80 | 81 | export { 82 | Card, 83 | CardHeader, 84 | CardFooter, 85 | CardTitle, 86 | CardDescription, 87 | CardContent, 88 | }; 89 | -------------------------------------------------------------------------------- /backend/app/repositories/repositories.py: -------------------------------------------------------------------------------- 1 | from uuid import UUID 2 | 3 | from pydantic import BaseModel 4 | from sqlalchemy.orm import Query 5 | 6 | from app.database import BaseDbModel, DbSession 7 | from app.utils.duplicates import handle_duplicates 8 | from app.utils.exceptions import handle_exceptions 9 | 10 | 11 | class CrudRepository[ 12 | ModelType: BaseDbModel, 13 | CreateSchemaType: BaseModel, 14 | UpdateSchemaType: BaseModel, 15 | ]: 16 | """Class to manage database operations.""" 17 | 18 | def __init__(self, model: type[ModelType]): 19 | self.model = model 20 | 21 | @handle_exceptions 22 | @handle_duplicates 23 | def create(self, db_session: DbSession, creator: CreateSchemaType) -> ModelType: 24 | creation_data = creator.model_dump() 25 | creation = self.model(**creation_data) 26 | db_session.add(creation) 27 | db_session.commit() 28 | db_session.refresh(creation) 29 | return creation 30 | 31 | def get(self, db_session: DbSession, object_id: UUID | int) -> ModelType | None: 32 | return db_session.query(self.model).filter(getattr(self.model, "id") == object_id).one_or_none() 33 | 34 | def get_all( 35 | self, 36 | db_session: DbSession, 37 | filters: dict[str, str], 38 | offset: int, 39 | limit: int, 40 | sort_by: str | None, 41 | ) -> list[ModelType]: 42 | query: Query = db_session.query(self.model) 43 | 44 | for field, value in filters.items(): 45 | query = query.filter(getattr(self.model, field) == value) 46 | 47 | if sort_by: 48 | query = query.order_by(getattr(self.model, sort_by)) 49 | 50 | return query.offset(offset).limit(limit).all() 51 | 52 | def update( 53 | self, 54 | db_session: DbSession, 55 | originator: ModelType, 56 | updater: UpdateSchemaType, 57 | ) -> ModelType: 58 | updater_data = updater.model_dump(exclude_none=True) 59 | for field_name, field_value in updater_data.items(): 60 | setattr(originator, field_name, field_value) 61 | db_session.add(originator) 62 | db_session.commit() 63 | db_session.refresh(originator) 64 | return originator 65 | 66 | def delete(self, db_session: DbSession, originator: ModelType) -> ModelType: 67 | db_session.delete(originator) 68 | db_session.commit() 69 | return originator 70 | -------------------------------------------------------------------------------- /frontend/src/hooks/api/use-credentials.ts: -------------------------------------------------------------------------------- 1 | import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; 2 | import { credentialsService } from '@/lib/api/services/credentials.service'; 3 | import type { ApiKeyCreate } from '@/lib/api/types'; 4 | import { queryKeys } from '@/lib/query/keys'; 5 | import { toast } from 'sonner'; 6 | import { getErrorMessage } from '@/lib/errors/handler'; 7 | 8 | // Get all API keys 9 | export function useApiKeys() { 10 | return useQuery({ 11 | queryKey: queryKeys.credentials.list(), 12 | queryFn: () => credentialsService.getApiKeys(), 13 | }); 14 | } 15 | 16 | // Get single API key 17 | export function useApiKey(id: string) { 18 | return useQuery({ 19 | queryKey: queryKeys.credentials.detail(id), 20 | queryFn: () => credentialsService.getApiKey(id), 21 | enabled: !!id, 22 | }); 23 | } 24 | 25 | // Create API key 26 | export function useCreateApiKey() { 27 | const queryClient = useQueryClient(); 28 | 29 | return useMutation({ 30 | mutationFn: (data: ApiKeyCreate) => credentialsService.createApiKey(data), 31 | onSuccess: () => { 32 | queryClient.invalidateQueries({ queryKey: queryKeys.credentials.list() }); 33 | toast.success('API key created successfully'); 34 | }, 35 | onError: (error) => { 36 | toast.error(`Failed to create API key: ${getErrorMessage(error)}`); 37 | }, 38 | }); 39 | } 40 | 41 | // Revoke API key 42 | export function useRevokeApiKey() { 43 | const queryClient = useQueryClient(); 44 | 45 | return useMutation({ 46 | mutationFn: (id: string) => credentialsService.revokeApiKey(id), 47 | onSuccess: () => { 48 | queryClient.invalidateQueries({ queryKey: queryKeys.credentials.list() }); 49 | toast.success('API key revoked successfully'); 50 | }, 51 | onError: (error) => { 52 | toast.error(`Failed to revoke API key: ${getErrorMessage(error)}`); 53 | }, 54 | }); 55 | } 56 | 57 | // Delete API key 58 | export function useDeleteApiKey() { 59 | const queryClient = useQueryClient(); 60 | 61 | return useMutation({ 62 | mutationFn: (id: string) => credentialsService.deleteApiKey(id), 63 | onSuccess: () => { 64 | queryClient.invalidateQueries({ queryKey: queryKeys.credentials.list() }); 65 | toast.success('API key deleted successfully'); 66 | }, 67 | onError: (error) => { 68 | toast.error(`Failed to delete API key: ${getErrorMessage(error)}`); 69 | }, 70 | }); 71 | } 72 | -------------------------------------------------------------------------------- /backend/app/schemas/events.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | from typing import Literal 3 | from uuid import UUID 4 | 5 | from pydantic import BaseModel, Field, RootModel 6 | 7 | from app.schemas.common_types import DataSource 8 | from app.schemas.summaries import SleepStagesSummary 9 | from app.schemas.timeseries import HeartRateSample 10 | 11 | 12 | class WorkoutType(RootModel[str]): 13 | # Using str for now as enum might be extensive, but RFC lists specific values 14 | # running, walking, cycling, swimming, strength_training, hiit, yoga, pilates, rowing, elliptical, hiking, other 15 | pass 16 | 17 | 18 | class Workout(BaseModel): 19 | id: UUID 20 | type: str # Should be WorkoutType enum ideally 21 | name: str | None = Field(None, example="Morning Run") 22 | start_time: datetime 23 | end_time: datetime 24 | duration_seconds: int | None = None 25 | source: DataSource 26 | calories_kcal: float | None = None 27 | distance_meters: float | None = None 28 | avg_heart_rate_bpm: int | None = None 29 | max_heart_rate_bpm: int | None = None 30 | avg_pace_sec_per_km: int | None = None 31 | elevation_gain_meters: float | None = None 32 | 33 | 34 | class WorkoutDetailed(Workout): 35 | heart_rate_samples: list[HeartRateSample] | None = None 36 | 37 | 38 | class Macros(BaseModel): 39 | protein_g: float | None = None 40 | carbohydrates_g: float | None = None 41 | fat_g: float | None = None 42 | fiber_g: float | None = None 43 | 44 | 45 | class Meal(BaseModel): 46 | id: UUID 47 | timestamp: datetime 48 | meal_type: Literal["breakfast", "lunch", "dinner", "snack"] | None = None 49 | name: str | None = None 50 | source: DataSource 51 | calories_kcal: float | None = None 52 | macros: Macros | None = None 53 | water_ml: float | None = None 54 | 55 | 56 | class Measurement(BaseModel): 57 | id: UUID 58 | type: Literal["weight", "blood_pressure", "body_composition", "temperature", "blood_glucose"] 59 | timestamp: datetime 60 | source: DataSource 61 | values: dict[str, float | str] = Field(..., description="Measurement-specific values", example={"weight_kg": 72.5}) 62 | 63 | 64 | class SleepSession(BaseModel): 65 | id: UUID 66 | start_time: datetime 67 | end_time: datetime 68 | source: DataSource 69 | duration_seconds: int 70 | efficiency_percent: float | None = None 71 | stages: SleepStagesSummary | None = None 72 | is_nap: bool = False 73 | -------------------------------------------------------------------------------- /backend/pyproject.toml: -------------------------------------------------------------------------------- 1 | [project] 2 | name = "open-wearables" 3 | version = "0.1.0" 4 | description = "" 5 | requires-python = ">=3.13" 6 | dependencies = [ 7 | "cryptography>=45.0.4", 8 | "pydantic-settings>=2.10.1", 9 | "email-validator>=2.2.0", 10 | "psycopg>=3.2.9", 11 | "sqlalchemy>=2.0.43", 12 | "fastapi>=0.120.4", 13 | "fastapi-cli>=0.0.8", 14 | "sqladmin[full]>=0.21.0", 15 | "celery>=5.5.3", 16 | "flower>=2.0.1", 17 | "redis>=7.0.1", 18 | "sentry-sdk[fastapi]>=2.42.1", 19 | "python-multipart>=0.0.20", 20 | "python-jose[cryptography]>=3.5.0", 21 | "httpx>=0.28.1", 22 | "alembic>=1.17.1", 23 | "boto3>=1.40.67", 24 | "requests>=2.32.5", 25 | "bcrypt>=5.0.0", 26 | "isodate>=0.7.2", 27 | "resend>=2.0.0", 28 | ] 29 | 30 | [dependency-groups] 31 | code-quality = [ 32 | "pre-commit>=4.3.0", 33 | "ruff>=0.14.3", 34 | "ty>=0.0.1a25", 35 | ] 36 | dev = [ 37 | "pytest>=8.4.1", 38 | "pytest-asyncio>=1.0.0", 39 | "pytest-cov>=6.2.1", 40 | "faker>=33.1.0", 41 | ] 42 | 43 | [tool.ruff] 44 | line-length = 120 45 | target-version = 'py313' 46 | exclude = ["README.md", ".python-version", "LICENSE", "app/models/"] 47 | 48 | [tool.ruff.lint] 49 | select = [ 50 | "I", # isort 51 | "F", # pyflakes 52 | "FAST", # FastApi 53 | "ANN", # flake8-annotations 54 | "ASYNC", # flake8-async 55 | "COM", # flake8-commas 56 | "T10", # flake8-debugger 57 | "PT", # flake8-pytest-style 58 | "RET", # flake8-return 59 | "SIM", # flake8-simplify 60 | "N", # pep8-naming 61 | "E", # pycodestyle errors 62 | "W", # pycodestyle warnings 63 | ] 64 | ignore = [ 65 | "ANN002", # missing-type-args 66 | "ANN003", # missing-type-kwargs 67 | "ANN204", # missing-return-type-special-method 68 | "ANN401", # any-type 69 | "RET503", # implicit-return 70 | "COM812", # trailing-comma (conflicts with ruff format) 71 | ] 72 | [tool.ruff.lint.per-file-ignores] 73 | "app/api/routes/v*/*.py" = ["ANN201"] # missing-type-annotation 74 | "app/schemas/garmin/*.py" = ["N815"] # mixedCase from external API 75 | "app/schemas/suunto/*.py" = ["N815"] # mixedCase from external API 76 | 77 | [tool.ty.src] 78 | exclude = ["migrations/", "app/models/", "README.md", ".python-version", "LICENSE"] 79 | 80 | [build-system] 81 | requires = ["uv_build"] 82 | build-backend = "uv_build" 83 | 84 | [tool.uv.build-backend] 85 | module-root = "" 86 | module-name = "app" 87 | -------------------------------------------------------------------------------- /backend/app/schemas/suunto/workout_import.py: -------------------------------------------------------------------------------- 1 | from pydantic import BaseModel, Field 2 | 3 | 4 | class HeartRateJSON(BaseModel): 5 | """Suunto heart rate data from workout. 6 | 7 | Note: 'max' is userMaxHR (from user settings), 'hrmax' is actual max HR during workout. 8 | """ 9 | 10 | workoutMaxHR: int | None = None # Actual max HR during workout (alternative field) 11 | workoutAvgHR: int | None = None # Actual avg HR during workout (alternative field) 12 | userMaxHR: int | None = None # User's max HR from settings 13 | avg: int | None = None # Average HR during workout 14 | hrmax: int | None = None # Actual maximum HR during workout (THIS IS THE CORRECT ONE) 15 | max: int | None = None # User's max HR from settings (DON'T USE FOR workout max) 16 | min: int | None = None # Minimum HR during workout 17 | 18 | 19 | class DeviceJSON(BaseModel): 20 | """Suunto device/gear information.""" 21 | 22 | manufacturer: str | None = None 23 | name: str | None = None 24 | displayName: str | None = None 25 | serialNumber: str | None = None 26 | swVersion: str | None = None 27 | hwVersion: str | None = None 28 | 29 | 30 | class WorkoutJSON(BaseModel): 31 | """Suunto workout data from API.""" 32 | 33 | workoutId: int 34 | activityId: int 35 | 36 | # Unix timestamp (ms) 37 | startTime: int 38 | stopTime: int 39 | # Seconds 40 | totalTime: float 41 | 42 | # Metrics (all optional) 43 | totalDistance: int | None = None 44 | stepCount: int | None = None 45 | energyConsumption: int | None = None 46 | 47 | # Speed metrics (m/s) 48 | maxSpeed: float | None = None 49 | avgSpeed: float | None = None 50 | 51 | # Elevation (meters) 52 | totalAscent: float | None = None 53 | totalDescent: float | None = None 54 | maxAltitude: float | None = None 55 | minAltitude: float | None = None 56 | 57 | # Power (watts) 58 | avgPower: float | None = None 59 | maxPower: float | None = None 60 | 61 | # Cadence 62 | avgCadence: float | None = None 63 | maxCadence: float | None = None 64 | 65 | # Heart rate data 66 | hrdata: HeartRateJSON | None = None 67 | 68 | # Device info (gear = watch) 69 | gear: DeviceJSON | None = None 70 | 71 | # Workout name/notes 72 | workoutName: str | None = Field(default=None, alias="name") 73 | notes: str | None = None 74 | 75 | 76 | class RootJSON(BaseModel): 77 | error: str | None = None 78 | payload: list[WorkoutJSON] 79 | --------------------------------------------------------------------------------
{message}
44 | Manage your settings and preferences 45 |