✨ Major Features Added: - Complete magical theming and rebranding from LifeRPG to The Wizard's Grimoire - Production-grade React frontend with Tailwind CSS v4 and magical aesthetics - Comprehensive analytics dashboard with Recharts integration (ScryingPortal) - Push notifications system with PWA service worker support - Drag & drop functionality using @dnd-kit for habit reordering - Social features with friends system and leaderboards - Performance optimization tools and monitoring - Mobile app enhancement with PWA installation support 🏗️ Technical Infrastructure: - Advanced service worker with offline support and background sync - Zustand state management for scalable application state - Production-ready UI component system with enhanced Button, Card, Input - Progressive Web App (PWA) with manifest and app installation - FastAPI backend with comprehensive API endpoints - Docker containerization and CI/CD pipeline setup 📱 Progressive Web App Features: - Offline functionality with intelligent caching - Push notification support for habit reminders - App installation on mobile and desktop platforms - Background sync for offline data management - Performance monitoring and optimization tools 🎨 User Experience: - Magical wizard/grimoire theming throughout application - Responsive design optimized for all device sizes - Drag & drop habit management with smooth animations - Interactive analytics with multiple chart types - Social connectivity with friends and competitive features - Comprehensive notification and performance settings 🔧 Developer Experience: - Modern development stack with Vite and React - Comprehensive testing setup and CI/CD pipelines - Code quality tools with pre-commit hooks - Docker development environment - Detailed documentation and implementation guides This represents a complete transformation from prototype to production-ready application with enterprise-grade features and magical user experience.
417 lines
16 KiB
Python
417 lines
16 KiB
Python
from abc import ABC, abstractmethod
|
|
from typing import Any, Dict
|
|
|
|
|
|
class AdapterError(Exception):
|
|
pass
|
|
|
|
|
|
class TransientError(AdapterError):
|
|
"""Errors that may succeed on retry (e.g., 429/5xx)."""
|
|
|
|
|
|
class Adapter(ABC):
|
|
name: str
|
|
|
|
@abstractmethod
|
|
def sync(self, *, db, integration_id: int) -> Dict[str, Any]:
|
|
"""Perform a sync for an integration and return a summary dict.
|
|
|
|
Expected return shape: {"ok": bool, "count": int, "details": {...}}
|
|
"""
|
|
...
|
|
|
|
|
|
class GoogleCalendarAdapter(Adapter):
|
|
name = 'google_calendar'
|
|
|
|
def sync(self, *, db, integration_id: int) -> Dict[str, Any]:
|
|
# Placeholder: our Google flow is handled by a dedicated endpoint.
|
|
return {"ok": True, "count": 0, "details": {"note": "use /sync_to_habits endpoint"}}
|
|
|
|
|
|
class TodoistAdapter(Adapter):
|
|
name = 'todoist'
|
|
|
|
def sync(self, *, db, integration_id: int) -> Dict[str, Any]:
|
|
# Lazy imports to avoid circulars
|
|
from . import models
|
|
from .crypto import decrypt_text
|
|
import requests
|
|
|
|
token_row = (
|
|
db.query(models.OAuthToken)
|
|
.filter_by(integration_id=integration_id)
|
|
.order_by(models.OAuthToken.id.desc())
|
|
.first()
|
|
)
|
|
if not token_row:
|
|
raise AdapterError('no token for todoist integration')
|
|
token = decrypt_text(token_row.access_token) if token_row.access_token else None
|
|
if not token:
|
|
raise AdapterError('unable to decrypt todoist token')
|
|
|
|
headers = {
|
|
'Authorization': f'Bearer {token}',
|
|
'Accept': 'application/json',
|
|
}
|
|
try:
|
|
resp = requests.get('https://api.todoist.com/rest/v2/tasks', headers=headers, timeout=10)
|
|
except Exception as e:
|
|
raise TransientError(str(e))
|
|
if resp.status_code in (429, 500, 502, 503, 504):
|
|
raise TransientError(f'todoist HTTP {resp.status_code}')
|
|
if resp.status_code != 200:
|
|
raise AdapterError(f'todoist HTTP {resp.status_code}')
|
|
|
|
# Load integration config for cursors/flags
|
|
integ = db.query(models.Integration).filter_by(id=integration_id).first()
|
|
conf = {}
|
|
if integ and integ.config:
|
|
try:
|
|
import json as _json
|
|
conf = _json.loads(integ.config)
|
|
except Exception:
|
|
conf = {}
|
|
full_fetch = bool(conf.get('todoist_full_fetch', True))
|
|
|
|
items = resp.json() or []
|
|
created = 0
|
|
updated = 0
|
|
seen_ext_ids = set()
|
|
|
|
from .config import settings
|
|
|
|
def _apply_close_policy(db, habit, should_close: bool, archived: bool):
|
|
if not habit:
|
|
return False
|
|
if settings.INTEGRATION_CLOSE_MODE == 'delete' and should_close:
|
|
db.delete(habit)
|
|
return True
|
|
new_status = 'archived' if archived else ('completed' if should_close else habit.status)
|
|
if habit.status != new_status:
|
|
habit.status = new_status
|
|
return True
|
|
return False
|
|
|
|
for it in items:
|
|
ext_id = str(it.get('id'))
|
|
title = it.get('content') or 'Todoist Task'
|
|
is_completed = bool(it.get('is_completed'))
|
|
is_archived = bool(it.get('is_deleted')) or bool(it.get('is_archived')) if isinstance(it.get('is_archived'), bool) else False
|
|
due = it.get('due', {}) or {}
|
|
due_dt = due.get('datetime') or due.get('date')
|
|
labels = it.get('labels') or []
|
|
if not ext_id:
|
|
continue
|
|
seen_ext_ids.add(ext_id)
|
|
mapping = (
|
|
db.query(models.IntegrationItemMap)
|
|
.filter_by(integration_id=integration_id, external_id=ext_id, entity_type='habit')
|
|
.first()
|
|
)
|
|
if mapping:
|
|
habit = db.query(models.Habit).filter_by(id=mapping.entity_id).first()
|
|
if habit:
|
|
changed = False
|
|
if habit.title != title:
|
|
habit.title = title
|
|
changed = True
|
|
changed |= _apply_close_policy(db, habit, is_completed, is_archived)
|
|
if due_dt:
|
|
try:
|
|
from datetime import datetime
|
|
habit.due_date = datetime.fromisoformat(due_dt.replace('Z', '+00:00'))
|
|
changed = True
|
|
except Exception:
|
|
pass
|
|
if labels:
|
|
import json as _json
|
|
habit.labels = _json.dumps(labels)
|
|
changed = True
|
|
if changed:
|
|
updated += 1
|
|
else:
|
|
integ2 = integ or db.query(models.Integration).filter_by(id=integration_id).first()
|
|
if not integ2:
|
|
raise AdapterError('integration missing during upsert')
|
|
import json as _json
|
|
habit = models.Habit(
|
|
user_id=integ2.user_id,
|
|
project_id=None,
|
|
title=title,
|
|
notes='from todoist',
|
|
cadence='once',
|
|
status='archived' if is_archived else ('completed' if is_completed else 'active'),
|
|
labels=_json.dumps(labels) if labels else None,
|
|
)
|
|
db.add(habit)
|
|
db.flush()
|
|
try:
|
|
from sqlalchemy.dialects.postgresql import insert as pg_insert
|
|
stmt = pg_insert(models.IntegrationItemMap.__table__).values(
|
|
integration_id=integration_id,
|
|
external_id=ext_id,
|
|
entity_type='habit',
|
|
entity_id=habit.id,
|
|
).on_conflict_do_update(
|
|
index_elements=['integration_id', 'external_id', 'entity_type'],
|
|
set_={'entity_id': habit.id}
|
|
)
|
|
db.execute(stmt)
|
|
except Exception:
|
|
db.add(models.IntegrationItemMap(integration_id=integration_id, external_id=ext_id, entity_type='habit', entity_id=habit.id))
|
|
created += 1
|
|
|
|
db.flush()
|
|
|
|
if full_fetch:
|
|
mappings = db.query(models.IntegrationItemMap).filter_by(integration_id=integration_id, entity_type='habit').all()
|
|
for m in mappings:
|
|
if m.external_id not in seen_ext_ids:
|
|
habit = db.query(models.Habit).filter_by(id=m.entity_id).first()
|
|
if habit:
|
|
try:
|
|
if settings.INTEGRATION_CLOSE_MODE == 'delete':
|
|
db.delete(habit)
|
|
else:
|
|
habit.status = 'archived'
|
|
except Exception:
|
|
habit.status = 'archived'
|
|
db.flush()
|
|
|
|
if integ:
|
|
try:
|
|
import json as _json
|
|
from datetime import datetime, timezone
|
|
conf['last_sync_at'] = datetime.now(timezone.utc).replace(microsecond=0).isoformat().replace('+00:00', 'Z')
|
|
integ.config = _json.dumps(conf)
|
|
db.flush()
|
|
except Exception:
|
|
pass
|
|
|
|
return {"ok": True, "count": len(items), "created": created, "updated": updated}
|
|
|
|
|
|
class GitHubAdapter(Adapter):
|
|
name = 'github'
|
|
|
|
def sync(self, *, db, integration_id: int) -> Dict[str, Any]:
|
|
from . import models
|
|
from .crypto import decrypt_text
|
|
import requests
|
|
|
|
token_row = (
|
|
db.query(models.OAuthToken)
|
|
.filter_by(integration_id=integration_id)
|
|
.order_by(models.OAuthToken.id.desc())
|
|
.first()
|
|
)
|
|
if not token_row:
|
|
raise AdapterError('no token for github integration')
|
|
token = decrypt_text(token_row.access_token) if token_row.access_token else None
|
|
if not token:
|
|
raise AdapterError('unable to decrypt github token')
|
|
|
|
headers = {
|
|
'Authorization': f'token {token}',
|
|
'Accept': 'application/vnd.github+json',
|
|
}
|
|
url = 'https://api.github.com/issues'
|
|
try:
|
|
resp = requests.get(url, headers=headers, timeout=10)
|
|
except Exception as e:
|
|
raise TransientError(str(e))
|
|
if resp.status_code in (429, 500, 502, 503, 504):
|
|
raise TransientError(f'github HTTP {resp.status_code}')
|
|
if resp.status_code != 200:
|
|
raise AdapterError(f'github HTTP {resp.status_code}')
|
|
|
|
integ = db.query(models.Integration).filter_by(id=integration_id).first()
|
|
conf = {}
|
|
if integ and integ.config:
|
|
try:
|
|
import json as _json
|
|
conf = _json.loads(integ.config)
|
|
except Exception:
|
|
conf = {}
|
|
since = conf.get('github_since')
|
|
|
|
items = []
|
|
page = 1
|
|
while True:
|
|
params = {'per_page': 100, 'page': page}
|
|
if since:
|
|
params['since'] = since
|
|
r = requests.get(url, headers=headers, params=params, timeout=10)
|
|
if r.status_code in (429, 500, 502, 503, 504):
|
|
raise TransientError(f'github HTTP {r.status_code}')
|
|
if r.status_code != 200:
|
|
raise AdapterError(f'github HTTP {r.status_code}')
|
|
batch = r.json() or []
|
|
items.extend(batch)
|
|
link = r.headers.get('Link') or r.headers.get('link')
|
|
if link and 'rel="next"' in link:
|
|
page += 1
|
|
continue
|
|
if len(batch) == 100:
|
|
page += 1
|
|
continue
|
|
break
|
|
|
|
created = 0
|
|
updated = 0
|
|
seen_ext_ids = set()
|
|
|
|
from .config import settings
|
|
|
|
def _apply_close_policy(db, habit, should_close: bool):
|
|
if not habit:
|
|
return False
|
|
if settings.INTEGRATION_CLOSE_MODE == 'delete' and should_close:
|
|
db.delete(habit)
|
|
return True
|
|
new_status = 'completed' if should_close else 'active'
|
|
if habit.status != new_status:
|
|
habit.status = new_status
|
|
return True
|
|
return False
|
|
|
|
for issue in items:
|
|
ext_id = str(issue.get('id'))
|
|
title = issue.get('title') or 'GitHub Issue'
|
|
state = (issue.get('state') or '').lower()
|
|
labels = [l.get('name') for l in (issue.get('labels') or []) if isinstance(l, dict)]
|
|
milestone = issue.get('milestone', {}) or {}
|
|
due_on = milestone.get('due_on')
|
|
if not ext_id:
|
|
continue
|
|
seen_ext_ids.add(ext_id)
|
|
mapping = (
|
|
db.query(models.IntegrationItemMap)
|
|
.filter_by(integration_id=integration_id, external_id=ext_id, entity_type='habit')
|
|
.first()
|
|
)
|
|
if mapping:
|
|
habit = db.query(models.Habit).filter_by(id=mapping.entity_id).first()
|
|
if habit:
|
|
changed = False
|
|
if habit.title != title:
|
|
habit.title = title
|
|
changed = True
|
|
changed |= _apply_close_policy(db, habit, state == 'closed')
|
|
if due_on:
|
|
from datetime import datetime
|
|
try:
|
|
habit.due_date = datetime.fromisoformat(due_on.replace('Z', '+00:00'))
|
|
changed = True
|
|
except Exception:
|
|
pass
|
|
if labels:
|
|
import json as _json
|
|
habit.labels = _json.dumps(labels)
|
|
changed = True
|
|
if changed:
|
|
updated += 1
|
|
else:
|
|
integ2 = integ or db.query(models.Integration).filter_by(id=integration_id).first()
|
|
if not integ2:
|
|
raise AdapterError('integration missing during upsert')
|
|
import json as _json
|
|
habit = models.Habit(
|
|
user_id=integ2.user_id,
|
|
project_id=None,
|
|
title=title,
|
|
notes='from github',
|
|
cadence='once',
|
|
status='completed' if state == 'closed' else 'active',
|
|
labels=_json.dumps(labels) if labels else None,
|
|
)
|
|
db.add(habit)
|
|
db.flush()
|
|
try:
|
|
from sqlalchemy.dialects.postgresql import insert as pg_insert
|
|
stmt = pg_insert(models.IntegrationItemMap.__table__).values(
|
|
integration_id=integration_id,
|
|
external_id=ext_id,
|
|
entity_type='habit',
|
|
entity_id=habit.id,
|
|
).on_conflict_do_update(
|
|
index_elements=['integration_id', 'external_id', 'entity_type'],
|
|
set_={'entity_id': habit.id}
|
|
)
|
|
db.execute(stmt)
|
|
except Exception:
|
|
db.add(models.IntegrationItemMap(integration_id=integration_id, external_id=ext_id, entity_type='habit', entity_id=habit.id))
|
|
created += 1
|
|
|
|
db.flush()
|
|
|
|
if not since:
|
|
mappings = db.query(models.IntegrationItemMap).filter_by(integration_id=integration_id, entity_type='habit').all()
|
|
for m in mappings:
|
|
if m.external_id not in seen_ext_ids:
|
|
habit = db.query(models.Habit).filter_by(id=m.entity_id).first()
|
|
if habit:
|
|
if settings.INTEGRATION_CLOSE_MODE == 'delete':
|
|
db.delete(habit)
|
|
else:
|
|
habit.status = 'archived'
|
|
db.flush()
|
|
|
|
if integ:
|
|
try:
|
|
import json as _json
|
|
from datetime import datetime, timezone
|
|
conf['github_since'] = datetime.now(timezone.utc).replace(microsecond=0).isoformat().replace('+00:00', 'Z')
|
|
integ.config = _json.dumps(conf)
|
|
db.flush()
|
|
except Exception:
|
|
pass
|
|
|
|
return {"ok": True, "count": len(items), "created": created, "updated": updated}
|
|
|
|
|
|
ADAPTERS = {
|
|
'google_calendar': GoogleCalendarAdapter(),
|
|
'todoist': TodoistAdapter(),
|
|
'github': GitHubAdapter(),
|
|
}
|
|
|
|
|
|
class SlackAdapter(Adapter):
|
|
name = 'slack'
|
|
|
|
def sync(self, *, db, integration_id: int) -> Dict[str, Any]:
|
|
"""Optional: send a simple notification via incoming webhook as a scaffold.
|
|
|
|
This is a no-op if the webhook is missing. Intended as a placeholder.
|
|
"""
|
|
from . import models
|
|
from .crypto import decrypt_text
|
|
import requests
|
|
|
|
tok = (
|
|
db.query(models.OAuthToken)
|
|
.filter_by(integration_id=integration_id)
|
|
.order_by(models.OAuthToken.id.desc())
|
|
.first()
|
|
)
|
|
if not tok or not tok.access_token:
|
|
return {"ok": True, "count": 0, "details": {"note": "no webhook"}}
|
|
webhook = decrypt_text(tok.access_token)
|
|
if not webhook:
|
|
raise AdapterError('unable to decrypt slack webhook')
|
|
payload = {"text": "LifeRPG: Slack integration sync triggered."}
|
|
try:
|
|
r = requests.post(webhook, json=payload, timeout=5)
|
|
except Exception as e:
|
|
raise TransientError(str(e))
|
|
if r.status_code >= 500:
|
|
raise TransientError(f'slack HTTP {r.status_code}')
|
|
if r.status_code >= 400:
|
|
raise AdapterError(f'slack HTTP {r.status_code}')
|
|
return {"ok": True, "count": 1}
|
|
|
|
ADAPTERS['slack'] = SlackAdapter()
|