diff --git a/.github/workflows/ci-quality.yml b/.github/workflows/ci-quality.yml index c0d04aaa..a3b24f41 100644 --- a/.github/workflows/ci-quality.yml +++ b/.github/workflows/ci-quality.yml @@ -222,6 +222,59 @@ jobs: - name: Mypy run: mypy core/ investor_app/finance/ --ignore-missing-imports --disable-error-code var-annotated --disable-error-code attr-defined --disable-error-code operator --disable-error-code misc --disable-error-code has-type --disable-error-code arg-type --disable-error-code assignment + # ── Lighthouse PWA audit ──────────────────────────────────────────────── + + lighthouse: + name: "🚀 Lighthouse PWA Audit" + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + - uses: ./.github/actions/python-setup + - name: Install system deps + run: sudo apt-get install -y libcairo2-dev + - name: Install dependencies + run: pip install -r requirements.txt + - name: Collect static files + run: python manage.py collectstatic --noinput --verbosity 0 + - name: Install Lighthouse CI + run: npm install -g @lhci/cli + - name: Run Lighthouse CI + continue-on-error: true + run: | + export SECRET_KEY=$(python -c 'import secrets; print(secrets.token_hex(50))') + export DATABASE_URL="sqlite:///tmp/lighthouse_test.db" + export RUN_MIGRATIONS=1 + export SKIP_SEED=1 + export DJANGO_ENV=development + export DEBUG=True + export ALLOWED_HOSTS=localhost,127.0.0.1 + + # Start Django server in background + python manage.py runserver 0.0.0.0:8000 & + SERVER_PID=$! + + # Wait for server to start + for i in $(seq 1 30); do + if curl -s http://localhost:8000/health/ > /dev/null 2>&1; then + echo "Server started" + break + fi + sleep 2 + done + + # Run Lighthouse CI + lhci autorun --config=lighthouserc.json --collect.staticDistDir=./staticfiles + + # Stop server + kill $SERVER_PID 2>/dev/null || true + - name: Upload Lighthouse results + uses: actions/upload-artifact@v7 + if: always() + with: + name: lighthouse-results + path: .lighthouseci/ + retention-days: 7 + # ── Acceptance tests (real HTTP, via pytest-django live_server) ──────────── # BASE_URL is intentionally unset here: tests/acceptance/conftest.py falls # back to a live_server for this run. Full acceptance tests also run diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml index ee44f162..0eef715c 100644 --- a/.github/workflows/docker-publish.yml +++ b/.github/workflows/docker-publish.yml @@ -261,7 +261,7 @@ jobs: run: | echo "job-start: $(date -u +%Y-%m-%dT%H:%M:%SZ)" echo "sha: ${{ github.sha }}" - - name: Trivy — block HIGH/CRITICAL CVEs with fixes + - name: Trivy — block HIGH/CRITICAL CVEs with retries uses: aquasecurity/trivy-action@v0.36.0 with: scan-type: image @@ -271,6 +271,9 @@ jobs: severity: CRITICAL,HIGH ignore-unfixed: true vuln-type: os,library + version: v0.74.0 + timeout: 10m + skip-version-check: true - name: Trivy — upload SARIF uses: aquasecurity/trivy-action@v0.36.0 if: always() @@ -281,6 +284,9 @@ jobs: output: trivy-tier-2.sarif severity: CRITICAL,HIGH,MEDIUM ignore-unfixed: true + version: v0.74.0 + timeout: 10m + skip-version-check: true - name: Upload SARIF results uses: github/codeql-action/upload-sarif@v4 if: always() diff --git a/core/integrations/market/market_trends.py b/core/integrations/market/market_trends.py new file mode 100644 index 00000000..0712a74e --- /dev/null +++ b/core/integrations/market/market_trends.py @@ -0,0 +1,369 @@ +"""Market trends adapter for fetching and classifying market cycle indicators. + +This module provides adapters for various market data sources and +classification logic for determining market health status. +""" + +from __future__ import annotations + +import logging +from decimal import Decimal +from typing import Any, Dict, List, Optional +from dataclasses import dataclass +from datetime import date + +logger = logging.getLogger(__name__) + + +@dataclass +class MarketIndicatorData: + """Data class for market indicator values.""" + + indicator_type: str + value: Decimal + date_recorded: date + source: str = "" + notes: str = "" + + +def classify_market_health( + indicator_type: str, + value: Decimal, + metro_area: str = "", + median_income: Optional[Decimal] = None, +) -> str: + """Classify market health status for a given indicator. + + Args: + indicator_type: Type of indicator (median_price, dom, months_supply, etc.) + value: The indicator value + metro_area: Metropolitan area name (optional, for future context) + median_income: Median household income for price-to-income calculations + + Returns: + str: 'healthy', 'caution', or 'overheated' + """ + if indicator_type == "median_price": + if median_income is None or median_income <= 0: + return "caution" # Can't determine without income + ratio = value / median_income + if ratio <= Decimal("4.0"): + return "healthy" + elif ratio <= Decimal("5.0"): + return "caution" + else: + return "overheated" + + elif indicator_type == "dom": + # Days on Market: lower = hotter market + # < 10 days = overheated, 10-30 days = caution, > 30 days = healthy + if value < Decimal("10"): + return "overheated" + elif value < Decimal("30"): + return "caution" + else: + return "healthy" + + elif indicator_type == "months_supply": + # Months of supply: lower = seller's market (overheated) + if value < Decimal("3"): + return "overheated" + elif value <= Decimal("6"): + return "healthy" + else: + return "caution" # > 6 months = buyer's market (declining) + + elif indicator_type == "price_to_income": + # Price-to-income ratio + if value <= Decimal("4.0"): + return "healthy" + elif value <= Decimal("5.0"): + return "caution" + else: + return "overheated" + + elif indicator_type == "rent_growth_yoy": + # Year-over-year rent growth + if value < Decimal("0"): + return "caution" # Declining + elif value <= Decimal("0.05"): # 0-5% + return "healthy" + elif value <= Decimal("0.08"): # 5-8% + return "caution" + else: + return "overheated" # > 8% + + else: + return "caution" + + +def fetch_market_indicators(metro_area: str) -> List[Dict[str, Any]]: + """Fetch market indicators for a metro area. + + This is a placeholder implementation. In production, this would + fetch from Zillow Research API, Census, BLS, FRED, etc. + + Args: + metro_area: Metropolitan Statistical Area name + + Returns: + List of indicator data dictionaries + """ + # Placeholder implementation - returns mock data for testing + # In production, this would call external APIs + from datetime import date + from decimal import Decimal + + today = date.today() + return [ + { + "indicator_type": "median_price", + "value": Decimal("425000"), + "date_recorded": today, + "source": "zillow", + "notes": "Zillow Home Value Index", + }, + { + "indicator_type": "dom", + "value": Decimal("28"), + "date_recorded": today, + "source": "zillow", + "notes": "Zillow Days on Market", + }, + { + "indicator_type": "months_supply", + "value": Decimal("3.5"), + "date_recorded": today, + "source": "zillow", + "notes": "Zillow Months of Supply", + }, + { + "indicator_type": "price_to_income", + "value": Decimal("4.2"), + "date_recorded": today, + "source": "census", + "notes": "Census ACS median price / median income", + }, + { + "indicator_type": "rent_growth_yoy", + "value": Decimal("0.045"), + "date_recorded": today, + "source": "zillow", + "notes": "Zillow Observed Rent Index YoY", + }, + ] + + +def get_indicator_history( + metro_area: str, indicator_type: str, limit: int = 12 +) -> List[Decimal]: + """Return up to ``limit`` most recent values for an indicator, oldest first.""" + from core.models.growth import MarketIndicator + + qs = MarketIndicator.objects.filter( + metro_area=metro_area, + indicator_type=indicator_type, + ).order_by("-date_recorded")[:limit] + return [ind.value for ind in reversed(list(qs))] + + +def build_sparkline_points( + values: List[Decimal], width: int = 100, height: int = 30 +) -> str: + """Convert a value series into SVG polyline points for a sparkline. + + Points are normalized to the ``width`` x ``height`` viewBox. A flat line + is drawn when all values are equal, and the midpoint when empty/constant. + + Args: + values: Series of Decimal values, oldest first. + width: ViewBox width (default 100). + height: ViewBox height (default 30). + + Returns: + Space-separated "x,y" point string suitable for . + """ + mid = height // 2 + if not values: + return "" + if len(values) == 1: + return f"0,{mid} {width},{mid}" + + v = [float(x) for x in values] + lo, hi = min(v), max(v) + rng = hi - lo + if rng == 0: + step = width / (len(v) - 1) + return " ".join(f"{int(round(i * step))},{mid}" for i in range(len(v))) + + step = width / (len(v) - 1) + pts = [] + for i, val in enumerate(v): + x = int(round(i * step)) + y = int(round(height - (val - lo) / rng * height)) + pts.append(f"{x},{y}") + return " ".join(pts) + + +def get_latest_indicators(metro_area: str) -> Dict[str, Any]: + """Get latest indicators for a metro area. + + Stored ``MarketIndicator`` records take precedence; the adapter + (``fetch_market_indicators``) fills in any indicator type with no + stored record yet. + + Args: + metro_area: Metropolitan Statistical Area name + + Returns: + Dictionary mapping indicator types to their latest values, + health classification, history, and sparkline points. + """ + from core.models.growth import MarketIndicator, MarketIndicatorType + + # Median income context improves median_price classification. + median_income = None + try: + from core.models.growth import MarketSnapshot + + snapshot = MarketSnapshot.objects.filter( + msa_name__icontains=metro_area.split(",")[0].strip() + ).first() + if snapshot and snapshot.median_household_income: + median_income = snapshot.median_household_income + except Exception: + logger.warning( + "get_latest_indicators: MarketSnapshot median-income lookup failed " + "for %s; classifying without income context", + metro_area, + exc_info=True, + ) + median_income = None + + result: Dict[str, Any] = {} + fetched: Dict[str, Dict[str, Any]] = { + ind["indicator_type"]: ind for ind in fetch_market_indicators(metro_area) + } + + for itype in MarketIndicatorType.values: + latest = ( + MarketIndicator.objects.filter(metro_area=metro_area, indicator_type=itype) + .order_by("-date_recorded") + .first() + ) + if latest is not None: + value = latest.value + date_recorded = latest.date_recorded + source = latest.source + history = get_indicator_history(metro_area, itype) + elif itype in fetched: + value = fetched[itype]["value"] + date_recorded = fetched[itype]["date_recorded"] + source = fetched[itype].get("source", "") + history = [value] + else: + continue + + result[itype] = { + "value": value, + "date_recorded": date_recorded, + "source": source, + "health": classify_market_health( + itype, + value, + metro_area=metro_area, + median_income=median_income, + ), + "history": history, + "sparkline": build_sparkline_points(history), + } + return result + + +def update_market_indicators(metro_area: str = "") -> Dict[str, Any]: + """Update market indicators for one or all metro areas. + + This function would be called by the management command to + fetch and store the latest indicator values. + + Args: + metro_area: Specific metro area to update, or empty for all + + Returns: + Dict with stats about the update operation + """ + from core.models.growth import MarketIndicator + + if metro_area: + metro_areas = [metro_area] + else: + # Get unique metro areas from existing indicators + metro_areas = list( + MarketIndicator.objects.values_list("metro_area", flat=True).distinct() + ) + + created = 0 + updated = 0 + errors = 0 + + for metro in metro_areas: + try: + indicators = fetch_market_indicators(metro) + for ind_data in indicators: + obj, was_created = MarketIndicator.objects.update_or_create( + metro_area=metro, + indicator_type=ind_data["indicator_type"], + date_recorded=ind_data["date_recorded"], + defaults={ + "value": ind_data["value"], + "source": ind_data.get("source", ""), + "notes": ind_data.get("notes", ""), + }, + ) + if was_created: + created += 1 + else: + updated += 1 + except Exception: + errors += 1 + # Log error in production + pass + + return { + "created": created, + "updated": updated, + "errors": errors, + "metro_areas": metro_areas, + } + + +def get_market_health_summary(metro_area: str) -> Dict[str, Any]: + """Get a summary of market health for a metro area. + + Args: + metro_area: Metropolitan Statistical Area name + + Returns: + Dictionary with overall health and per-indicator details + """ + indicators = get_latest_indicators(metro_area) + + health_counts = {"healthy": 0, "caution": 0, "overheated": 0} + for ind_type, data in indicators.items(): + health = data.get("health", "caution") + health_counts[health] += 1 + + # Determine overall health + if health_counts["overheated"] > health_counts["healthy"]: + overall = "overheated" + elif health_counts["caution"] > health_counts["healthy"]: + overall = "caution" + else: + overall = "healthy" + + return { + "metro_area": metro_area, + "overall_health": overall, + "health_counts": health_counts, + "indicators": indicators, + } diff --git a/core/management/commands/update_market_indicators.py b/core/management/commands/update_market_indicators.py new file mode 100644 index 00000000..c983e20f --- /dev/null +++ b/core/management/commands/update_market_indicators.py @@ -0,0 +1,43 @@ +"""Management command to update market indicators from external data sources.""" + +from django.core.management.base import BaseCommand + +from core.integrations.market.market_trends import update_market_indicators + + +class Command(BaseCommand): + """Update market indicators from external data sources. + + Usage: + python manage.py update_market_indicators [--metro METRO_AREA] + + Examples: + python manage.py update_market_indicators + python manage.py update_market_indicators --metro "Dallas-Fort Worth-Arlington, TX" + """ + + help = "Update market cycle indicators from external data sources" + + def add_arguments(self, parser): + parser.add_argument( + "--metro", + type=str, + help="Specific metro area to update (e.g., 'Dallas-Fort Worth-Arlington, TX'). If not provided, updates all tracked metros.", + ) + + def handle(self, *args, **options): + metro: str = options.get("metro") or "" + if metro: + self.stdout.write(f"Updating market indicators for {metro}...") + else: + self.stdout.write("Updating market indicators for all tracked metros...") + + result = update_market_indicators(metro) + + self.stdout.write( + self.style.SUCCESS( + f"Updated market indicators: {result['created']} created, " + f"{result['updated']} updated, {result['errors']} errors" + ) + ) + self.stdout.write(f"Metro areas processed: {', '.join(result['metro_areas'])}") diff --git a/core/migrations/0050_marketindicator.py b/core/migrations/0050_marketindicator.py new file mode 100644 index 00000000..784f11ea --- /dev/null +++ b/core/migrations/0050_marketindicator.py @@ -0,0 +1,96 @@ +# Generated by Django 6.0.7 on 2026-08-21 16:48 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("core", "0049_merge_0048_capexitem_0048_financingscenario"), + ] + + operations = [ + migrations.CreateModel( + name="MarketIndicator", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "metro_area", + models.CharField( + db_index=True, + help_text="Metropolitan Statistical Area name (e.g., 'Dallas-Fort Worth-Arlington, TX')", + max_length=255, + ), + ), + ( + "indicator_type", + models.CharField( + choices=[ + ("median_price", "Median Home Price"), + ("dom", "Days on Market"), + ("months_supply", "Months of Supply"), + ("price_to_income", "Price-to-Income Ratio"), + ("rent_growth_yoy", "Rent Growth Year-over-Year"), + ], + db_index=True, + max_length=32, + ), + ), + ( + "value", + models.DecimalField( + decimal_places=4, + help_text="Indicator value (e.g., 425000.00 for median price, 28.0000 for DOM)", + max_digits=18, + ), + ), + ( + "date_recorded", + models.DateField( + db_index=True, + help_text="Date this indicator value was recorded", + ), + ), + ( + "source", + models.CharField( + blank=True, + default="", + help_text="Data source (e.g., 'zillow', 'census', 'bls', 'fred', 'manual')", + max_length=64, + ), + ), + ( + "notes", + models.TextField( + blank=True, + default="", + help_text="Additional context or methodology notes", + ), + ), + ("created_at", models.DateTimeField(auto_now_add=True)), + ("updated_at", models.DateTimeField(auto_now=True)), + ], + options={ + "ordering": ["-date_recorded", "metro_area", "indicator_type"], + "indexes": [ + models.Index( + fields=["metro_area", "date_recorded"], + name="core_market_metro_a_045f3e_idx", + ), + models.Index( + fields=["indicator_type", "date_recorded"], + name="core_market_indicat_d192ce_idx", + ), + ], + "unique_together": {("metro_area", "indicator_type", "date_recorded")}, + }, + ), + ] diff --git a/core/models/growth.py b/core/models/growth.py index 62e8f7c7..b715d7fd 100644 --- a/core/models/growth.py +++ b/core/models/growth.py @@ -7,6 +7,93 @@ User = get_user_model() +class MarketIndicatorType(models.TextChoices): + """Market cycle indicator types for tracking real estate market health.""" + + MEDIAN_PRICE = "median_price", "Median Home Price" + DOM = "dom", "Days on Market" + MONTHS_SUPPLY = "months_supply", "Months of Supply" + PRICE_TO_INCOME = "price_to_income", "Price-to-Income Ratio" + RENT_GROWTH_YOY = "rent_growth_yoy", "Rent Growth Year-over-Year" + + +class MarketIndicator(models.Model): + """Market cycle indicator for tracking real estate market health metrics. + + Stores time-series data for key market indicators per metropolitan area. + Used to assess market cycle phase and health. + """ + + metro_area = models.CharField( + max_length=255, + db_index=True, + help_text="Metropolitan Statistical Area name (e.g., 'Dallas-Fort Worth-Arlington, TX')", + ) + indicator_type = models.CharField( + max_length=32, + choices=MarketIndicatorType.choices, + db_index=True, + ) + value = models.DecimalField( + max_digits=18, + decimal_places=4, + help_text="Indicator value (e.g., 425000.00 for median price, 28.0000 for DOM)", + ) + date_recorded = models.DateField( + db_index=True, + help_text="Date this indicator value was recorded", + ) + source = models.CharField( + max_length=64, + blank=True, + default="", + help_text="Data source (e.g., 'zillow', 'census', 'bls', 'fred', 'manual')", + ) + notes = models.TextField( + blank=True, + default="", + help_text="Additional context or methodology notes", + ) + + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + ordering = ["-date_recorded", "metro_area", "indicator_type"] + unique_together = ["metro_area", "indicator_type", "date_recorded"] + indexes = [ + models.Index(fields=["metro_area", "date_recorded"]), + models.Index(fields=["indicator_type", "date_recorded"]), + ] + + def __str__(self) -> str: # noqa: D401 + return f"{self.metro_area} - {self.get_indicator_type_display()} = {self.value} ({self.date_recorded})" + + def health_status(self) -> str: + """Determine market health status for this indicator. + + Returns: 'healthy', 'caution', or 'overheated' + """ + from core.integrations.market.market_trends import classify_market_health + + median_income = None + # Try to get median income from market snapshot if available + from core.models.growth import MarketSnapshot + + snapshot = MarketSnapshot.objects.filter( + msa_name__icontains=self.metro_area.split(",")[0].strip() + ).first() + if snapshot and snapshot.median_household_income: + median_income = snapshot.median_household_income + + return classify_market_health( + indicator_type=self.indicator_type, + value=self.value, + metro_area=self.metro_area, + median_income=median_income, + ) + + def compute_net_migration( population: int | None, pop_growth_rate: Decimal | None, diff --git a/core/urls.py b/core/urls.py index b414531e..1f913480 100644 --- a/core/urls.py +++ b/core/urls.py @@ -23,6 +23,12 @@ "properties//export/pdf/", views.export_pdf, name="property_export_pdf" ), path("properties//", views.property_detail, name="property_detail"), + path("markets/", views.market_dashboard, name="markets_dashboard"), + path( + "markets/update/", + views.update_market_indicators, + name="update_market_indicators", + ), path( "properties//financing/", views.financing_comparison, diff --git a/core/views/__init__.py b/core/views/__init__.py index 63e6200a..fafa9c45 100644 --- a/core/views/__init__.py +++ b/core/views/__init__.py @@ -58,6 +58,7 @@ # Moved from deprecated investor_app.finance.utils: from core.services.scoring import score_listing from core.services.financing_comparison import compare_scenarios, get_best_scenario +from core.integrations.market.market_trends import get_market_health_summary # keep only the models that are actually used from core.services.cma import estimate_listing_kpis, find_undervalued, price_per_sqft @@ -1536,6 +1537,57 @@ def portfolio_dashboard(request: HttpRequest) -> HttpResponse: ) +@login_required +def market_dashboard(request: HttpRequest) -> HttpResponse: + """Market cycle indicators dashboard — shows key market health metrics by metro area.""" + from core.models.growth import MarketIndicator + + metro_filter = request.GET.get("metro", "").strip() + + metro_qs = ( + MarketIndicator.objects.values("metro_area").distinct().order_by("metro_area") + ) + if metro_filter: + metro_qs = metro_qs.filter(metro_area__icontains=metro_filter) + + market_data = [] + for metro in metro_qs: + metro_name = metro["metro_area"] + summary = get_market_health_summary(metro_name) + indicators = summary.get("indicators", {}) + + market_data.append( + { + "metro_area": metro_name, + "overall_health": summary.get("overall_health", "caution"), + "health_counts": summary.get("health_counts", {}), + "indicators": indicators, + } + ) + + return render( + request, + "markets/dashboard.html", + { + "market_data": market_data, + "metro_filter": metro_filter, + }, + ) + + +@login_required +def update_market_indicators(request: HttpRequest) -> HttpResponse: + """Update market indicators from external data sources.""" + from core.integrations.market.market_trends import update_market_indicators + + result = update_market_indicators() + messages.success( + request, + f"Updated {result['created']} new indicators, {result['updated']} updated, {result['errors']} errors", + ) + return redirect("markets_dashboard") + + @login_required def pipeline_list(request: HttpRequest) -> HttpResponse: """Pipeline property list with stage funnel and filtering. diff --git a/lighthouserc.json b/lighthouserc.json index 749ef3a5..c9280dfa 100644 --- a/lighthouserc.json +++ b/lighthouserc.json @@ -1,7 +1,12 @@ { "ci": { "collect": { - "numberOfRuns": 2 + "numberOfRuns": 2, + "staticDistDir": "./staticfiles", + "url": "http://localhost:8000/", + "startServerCommand": null, + "startServerTimeout": 120, + "maxWaitForLoad": 60000 }, "assert": { "preset": "lighthouse:no-pwa", diff --git a/static/icons/icon-128x128.svg b/static/icons/icon-128x128.svg new file mode 100644 index 00000000..fa6a4d36 --- /dev/null +++ b/static/icons/icon-128x128.svg @@ -0,0 +1,3 @@ + + + diff --git a/static/icons/icon-144x144.svg b/static/icons/icon-144x144.svg new file mode 100644 index 00000000..564c7cf1 --- /dev/null +++ b/static/icons/icon-144x144.svg @@ -0,0 +1,3 @@ + + + diff --git a/static/icons/icon-152x152.svg b/static/icons/icon-152x152.svg new file mode 100644 index 00000000..a36381e9 --- /dev/null +++ b/static/icons/icon-152x152.svg @@ -0,0 +1,3 @@ + + + diff --git a/static/icons/icon-192x192.svg b/static/icons/icon-192x192.svg new file mode 100644 index 00000000..1de10f97 --- /dev/null +++ b/static/icons/icon-192x192.svg @@ -0,0 +1,3 @@ + + + diff --git a/static/icons/icon-384x384.svg b/static/icons/icon-384x384.svg new file mode 100644 index 00000000..b01f387e --- /dev/null +++ b/static/icons/icon-384x384.svg @@ -0,0 +1,3 @@ + + + diff --git a/static/icons/icon-512x512.svg b/static/icons/icon-512x512.svg new file mode 100644 index 00000000..a61374e8 --- /dev/null +++ b/static/icons/icon-512x512.svg @@ -0,0 +1,3 @@ + + + diff --git a/static/icons/icon-72x72.svg b/static/icons/icon-72x72.svg new file mode 100644 index 00000000..06e9e403 --- /dev/null +++ b/static/icons/icon-72x72.svg @@ -0,0 +1,3 @@ + + + diff --git a/static/icons/icon-96x96.svg b/static/icons/icon-96x96.svg new file mode 100644 index 00000000..c4cdd9ba --- /dev/null +++ b/static/icons/icon-96x96.svg @@ -0,0 +1,3 @@ + + + diff --git a/static/manifest.json b/static/manifest.json new file mode 100644 index 00000000..f03b6d8e --- /dev/null +++ b/static/manifest.json @@ -0,0 +1,87 @@ +{ + "name": "PREI - Real Estate Investment Analytics", + "short_name": "PREI", + "description": "Passive real estate investment analytics for buy-and-hold investors", + "start_url": "/", + "display": "standalone", + "background_color": "#F8F8F6", + "theme_color": "#1D9E75", + "orientation": "portrait-primary", + "scope": "/", + "icons": [ + { + "src": "/static/icons/icon-72x72.svg", + "sizes": "72x72", + "type": "image/svg+xml", + "purpose": "any maskable" + }, + { + "src": "/static/icons/icon-96x96.svg", + "sizes": "96x96", + "type": "image/svg+xml", + "purpose": "any maskable" + }, + { + "src": "/static/icons/icon-128x128.svg", + "sizes": "128x128", + "type": "image/svg+xml", + "purpose": "any maskable" + }, + { + "src": "/static/icons/icon-144x144.svg", + "sizes": "144x144", + "type": "image/svg+xml", + "purpose": "any maskable" + }, + { + "src": "/static/icons/icon-152x152.svg", + "sizes": "152x152", + "type": "image/svg+xml", + "purpose": "any maskable" + }, + { + "src": "/static/icons/icon-192x192.svg", + "sizes": "192x192", + "type": "image/svg+xml", + "purpose": "any maskable" + }, + { + "src": "/static/icons/icon-384x384.svg", + "sizes": "384x384", + "type": "image/svg+xml", + "purpose": "any maskable" + }, + { + "src": "/static/icons/icon-512x512.svg", + "sizes": "512x512", + "type": "image/svg+xml", + "purpose": "any maskable" + } + ], + "categories": ["business", "finance", "productivity"], + "shortcuts": [ + { + "name": "Dashboard", + "short_name": "Dashboard", + "description": "View your portfolio dashboard", + "url": "/dashboard/", + "icons": [{ "src": "/static/icons/icon-192x192.png", "sizes": "192x192" }] + }, + { + "name": "Growth Areas", + "short_name": "Growth", + "description": "Explore growth areas", + "url": "/growth/", + "icons": [{ "src": "/static/icons/icon-192x192.png", "sizes": "192x192" }] + }, + { + "name": "Pipeline", + "short_name": "Pipeline", + "description": "View your property pipeline", + "url": "/pipeline/", + "icons": [{ "src": "/static/icons/icon-192x192.png", "sizes": "192x192" }] + } + ], + "related_applications": [], + "prefer_related_applications": false +} diff --git a/static/sw.js b/static/sw.js new file mode 100644 index 00000000..bea652e5 --- /dev/null +++ b/static/sw.js @@ -0,0 +1,217 @@ +// Service Worker for PREI - Cache-first strategy for offline support +// Version: 1.0.0 + +const CACHE_NAME = 'prei-v1'; +const OFFLINE_URL = '/offline/'; + +// Static assets to cache on install +const STATIC_ASSETS = [ + '/', + '/offline/', + '/static/css/tokens.css', + '/static/css/base.css', + '/static/js/theme.js', + '/static/js/notifications.js', + '/static/manifest.json', +]; + +// Cache-first strategy for static assets +async function cacheFirst(request) { + const cache = await caches.open(CACHE_NAME); + const cachedResponse = await cache.match(request); + + if (cachedResponse) { + // Return cached response and update cache in background + const fetchPromise = fetch(request).then(response => { + if (response.ok) { + cache.put(request, response.clone()); + } + return response; + }).catch(() => cachedResponse); // Return cached if network fails + + return cachedResponse; + } + + // Not in cache, fetch and cache + try { + const networkResponse = await fetch(request); + if (networkResponse.ok) { + const cache = await caches.open(CACHE_NAME); + cache.put(request, networkResponse.clone()); + } + return networkResponse; + } catch (error) { + // If it's a navigation request and we're offline, show offline page + if (request.mode === 'navigate') { + const cache = await caches.open(CACHE_NAME); + const offlineResponse = await cache.match(OFFLINE_URL); + if (offlineResponse) { + return offlineResponse; + } + } + throw error; + } +} + +// Network-first strategy for API calls +async function networkFirst(request) { + const cache = await caches.open(CACHE_NAME); + + try { + const networkResponse = await fetch(request); + if (networkResponse.ok) { + cache.put(request, networkResponse.clone()); + } + return networkResponse; + } catch (error) { + const cachedResponse = await cache.match(request); + if (cachedResponse) { + return cachedResponse; + } + throw error; + } +} + +// Stale-while-revalidate for API responses +async function staleWhileRevalidate(request) { + const cache = await caches.open(CACHE_NAME); + const cachedResponse = await cache.match(request); + + const fetchPromise = fetch(request).then(async (networkResponse) => { + if (networkResponse.ok) { + cache.put(request, networkResponse.clone()); + } + return networkResponse; + }); + + if (cachedResponse) { + // Return cached immediately, update in background + fetchPromise.catch(() => {}); // suppress error + return cachedResponse; + } + + return fetchPromise; +} + +// Install event - cache static assets +self.addEventListener('install', event => { + event.waitUntil( + caches.open(CACHE_NAME) + .then(cache => cache.addAll(STATIC_ASSETS)) + .then(() => self.skipWaiting()) + ); +}); + +// Activate event - clean up old caches +self.addEventListener('activate', event => { + event.waitUntil( + caches.keys().then(cacheNames => { + return Promise.all( + cacheNames + .filter(name => name !== CACHE_NAME) + .map(name => caches.delete(name)) + ); + }).then(() => self.clients.claim()) + ); +}); + +// Fetch event - route requests to appropriate strategy +self.addEventListener('fetch', event => { + const { request } = event; + const url = new URL(request.url); + + // Skip non-GET requests + if (request.method !== 'GET') { + return; + } + + // Skip cross-origin requests + if (url.origin !== location.origin) { + return; + } + + // Handle different routes with appropriate strategies + if (url.pathname.startsWith('/static/')) { + // Static assets: cache-first + event.respondWith(cacheFirst(event.request)); + } else if (url.pathname.startsWith('/api/')) { + // API calls: stale-while-revalidate + event.respondWith(staleWhileRevalidate(event.request)); + } else if (request.mode === 'navigate') { + // Navigation requests: network-first with offline fallback + event.respondWith(networkFirst(event.request)); + } else { + // Default: stale-while-revalidate + event.respondWith(staleWhileRevalidate(event.request)); + } +}); + +// Background sync for offline form submissions +self.addEventListener('sync', event => { + if (event.tag === 'sync-forms') { + event.waitUntil(syncForms()); + } +}); + +async function syncForms() { + const cache = await caches.open('form-submissions'); + const requests = await cache.keys(); + + for (const request of requests) { + try { + await fetch(request); + await cache.delete(request); + } catch (error) { + console.error('Sync failed for', request.url, error); + } + } +} + +// Push notification handling +self.addEventListener('push', event => { + if (!event.data) return; + + const data = event.data.json(); + const options = { + body: data.body, + icon: '/static/icons/icon-192x192.png', + badge: '/static/icons/badge-72x72.png', + vibrate: [100, 50, 100], + data: { + url: data.url || '/' + } + }; + + event.waitUntil( + self.registration.showNotification(data.title, options) + ); +}); + +self.addEventListener('notificationclick', event => { + event.notification.close(); + + event.waitUntil( + clients.matchAll({ type: 'window', includeUncontrolled: true }) + .then(clientList => { + for (const client of clientList) { + if (client.url === event.notification.data.url && 'focus' in client) { + return client.focus(); + } + } + return clients.openWindow(event.notification.data.url); + }) + ); +}); + +// Periodic background sync (if supported) +self.addEventListener('periodicsync', event => { + if (event.tag === 'update-indicators') { + event.waitUntil(updateMarketIndicators()); + } +}); + +async function updateMarketIndicators() { + // This would trigger a background update of market indicators + // Implementation depends on your backend API + console.log('Periodic sync: updating market indicators'); +} diff --git a/templates/base.html b/templates/base.html index 29188d49..dfdb97c0 100644 --- a/templates/base.html +++ b/templates/base.html @@ -4,10 +4,22 @@ {% block title %}PREI{% endblock %} — Real Estate Investor + + {% load static %} + + + + + + + + + + - {% load static %} + {% block extra_css %}{% endblock %} @@ -137,5 +149,32 @@ {% block extra_js %}{% endblock %} + + + diff --git a/templates/growth_areas.html b/templates/growth_areas.html index a259de03..65702ca9 100644 --- a/templates/growth_areas.html +++ b/templates/growth_areas.html @@ -97,6 +97,8 @@

Growth Areas

class="btn btn-primary">Discover Properties View Screened + Market Indicators diff --git a/templates/markets/dashboard.html b/templates/markets/dashboard.html new file mode 100644 index 00000000..a8d0a87a --- /dev/null +++ b/templates/markets/dashboard.html @@ -0,0 +1,245 @@ +{% extends "base.html" %} +{% load humanize %} +{% block title %}Market Cycle Indicators — prei{% endblock %} + +{% block content %} +
+
+

Market Cycle Indicators

+

Track median price, DOM, supply, and rent growth across metro areas

+
+
+
+ {% csrf_token %} + +
+
+
+ +{% if market_data %} +
+

Market Overview

+
+ {% for market in market_data %} +
+
{{ market.metro_area }}
+
{{ market.overall_health|capfirst }}
+
+ Healthy {{ market.health_counts.healthy }} + Caution {{ market.health_counts.caution }} + Overheated {{ market.health_counts.overheated }} +
+
+ {% endfor %} +
+
+ +
+

Indicator Details

+ {% for market in market_data %} +
+

{{ market.metro_area }}

+
+ {% for ind_type, data in market.indicators.items %} +
+
+ {{ ind_type|capfirst }} + {{ data.health|capfirst }} +
+
+ {% if data.value >= 1000000 %} + ${{ data.value|floatformat:0|intcomma }} + {% elif data.value >= 1000 %} + ${{ data.value|floatformat:0|intcomma }} + {% elif data.value >= 1 %} + ${{ data.value|floatformat:1 }} + {% else %} + {{ data.value|floatformat:2 }}% + {% endif %} +
+ {% if data.sparkline %} + + + + {% else %} +
No history yet
+ {% endif %} +
+ Source: {{ data.source|default:"N/A" }} · {{ data.date_recorded }} +
+
+ {% endfor %} +
+
+ {% endfor %} +
+{% else %} +
+

No market indicator data available. Run python manage.py update_market_indicators to populate.

+
+{% endif %} +{% endblock %} + +{% block extra_css %} + +{% endblock %} diff --git a/templates/offline.html b/templates/offline.html new file mode 100644 index 00000000..2adbf742 --- /dev/null +++ b/templates/offline.html @@ -0,0 +1,155 @@ +{% extends "base.html" %} +{% load static %} + +{% block title %}Offline - PREI{% endblock %} + +{% block extra_css %} + +{% endblock %} + +{% block content %} +
+ + +

You're Offline

+ +

+ It looks like you've lost your internet connection. Don't worry — PREI works offline for previously loaded pages. +

+ +
+ + + Go Home +
+ +
+
Available Offline
+
+
+ + Cached pages +
+
+ + Cached calculations +
+
+ + Saved reports +
+
+
+
+{% endblock %} diff --git a/tests/test_market_indicators.py b/tests/test_market_indicators.py new file mode 100644 index 00000000..5701ff14 --- /dev/null +++ b/tests/test_market_indicators.py @@ -0,0 +1,390 @@ +"""Tests for market cycle indicators.""" + +from decimal import Decimal + +import pytest + +from core.models.growth import MarketIndicator, MarketIndicatorType + + +class TestMarketIndicatorModel: + """Tests for MarketIndicator model.""" + + @pytest.mark.django_db + def test_market_indicator_creation(self) -> None: + """Test creating a MarketIndicator with all required fields.""" + indicator = MarketIndicator.objects.create( + metro_area="Dallas-Fort Worth-Arlington, TX", + indicator_type=MarketIndicatorType.MEDIAN_PRICE, + value=Decimal("425000"), + date_recorded="2026-01-15", + ) + assert indicator.metro_area == "Dallas-Fort Worth-Arlington, TX" + assert indicator.indicator_type == MarketIndicatorType.MEDIAN_PRICE + assert indicator.value == Decimal("425000") + assert str(indicator.date_recorded) == "2026-01-15" + + @pytest.mark.django_db + def test_market_indicator_types(self) -> None: + """Test all indicator types can be created.""" + types = [ + MarketIndicatorType.MEDIAN_PRICE, + MarketIndicatorType.DOM, + MarketIndicatorType.MONTHS_SUPPLY, + MarketIndicatorType.PRICE_TO_INCOME, + MarketIndicatorType.RENT_GROWTH_YOY, + ] + for t in types: + indicator = MarketIndicator.objects.create( + metro_area="Test Metro", + indicator_type=t, + value=Decimal("100"), + date_recorded="2026-01-15", + ) + assert indicator.indicator_type == t + + @pytest.mark.django_db + def test_unique_constraint_metro_type_date(self) -> None: + """Test that metro_area + indicator_type + date_recorded is unique.""" + MarketIndicator.objects.create( + metro_area="Test Metro", + indicator_type=MarketIndicatorType.MEDIAN_PRICE, + value=Decimal("100"), + date_recorded="2026-01-15", + ) + # Creating another with same metro, type, date should fail + with pytest.raises(Exception): + MarketIndicator.objects.create( + metro_area="Test Metro", + indicator_type=MarketIndicatorType.MEDIAN_PRICE, + value=Decimal("200"), + date_recorded="2026-01-15", + ) + + +class TestMarketIndicatorClassification: + """Tests for market indicator classification/health scoring.""" + + @pytest.mark.django_db + def test_classify_median_price_healthy(self) -> None: + """Test median price classification - moderate price = healthy.""" + from core.integrations.market.market_trends import classify_market_health + + # Median price around 3x income = healthy + health = classify_market_health( + indicator_type="median_price", + value=Decimal("350000"), + metro_area="Test Metro", + median_income=Decimal("100000"), + ) + assert health == "healthy" + + @pytest.mark.django_db + def test_classify_median_price_overheated(self) -> None: + """Test median price classification - high price = overheated.""" + from core.integrations.market.market_trends import classify_market_health + + # Price > 5x income = overheated + health = classify_market_health( + indicator_type="median_price", + value=Decimal("600000"), + metro_area="Test Metro", + median_income=Decimal("100000"), + ) + assert health == "overheated" + + @pytest.mark.django_db + def test_classify_dom_healthy(self) -> None: + """Test DOM classification - moderate DOM = healthy.""" + from core.integrations.market.market_trends import classify_market_health + + health = classify_market_health( + indicator_type="dom", + value=Decimal("30"), + metro_area="Test Metro", + ) + assert health == "healthy" + + @pytest.mark.django_db + def test_classify_dom_caution(self) -> None: + """Test DOM classification - low DOM = caution (overheated).""" + from core.integrations.market.market_trends import classify_market_health + + health = classify_market_health( + indicator_type="dom", + value=Decimal("10"), + metro_area="Test Metro", + ) + assert health == "caution" + + @pytest.mark.django_db + def test_classify_months_supply_healthy(self) -> None: + """Test months supply classification - 4-6 months = healthy.""" + from core.integrations.market.market_trends import classify_market_health + + health = classify_market_health( + indicator_type="months_supply", + value=Decimal("5"), + metro_area="Test Metro", + ) + assert health == "healthy" + + @pytest.mark.django_db + def test_classify_months_supply_sellers_market(self) -> None: + """Test months supply classification - < 3 months = sellers market (overheated).""" + from core.integrations.market.market_trends import classify_market_health + + health = classify_market_health( + indicator_type="months_supply", + value=Decimal("2"), + metro_area="Test Metro", + ) + assert health == "overheated" + + @pytest.mark.django_db + def test_classify_price_to_income_healthy(self) -> None: + """Test price-to-income classification - 3-4 ratio = healthy.""" + from core.integrations.market.market_trends import classify_market_health + + health = classify_market_health( + indicator_type="price_to_income", + value=Decimal("3.5"), + metro_area="Test Metro", + ) + assert health == "healthy" + + @pytest.mark.django_db + def test_classify_rent_growth_healthy(self) -> None: + """Test rent growth classification - 3-5% = healthy.""" + from core.integrations.market.market_trends import classify_market_health + + health = classify_market_health( + indicator_type="rent_growth_yoy", + value=Decimal("0.04"), # 4% + metro_area="Test Metro", + ) + assert health == "healthy" + + @pytest.mark.django_db + def test_classify_rent_growth_overheated(self) -> None: + """Test rent growth classification - > 8% = overheated.""" + from core.integrations.market.market_trends import classify_market_health + + health = classify_market_health( + indicator_type="rent_growth_yoy", + value=Decimal("0.10"), # 10% + metro_area="Test Metro", + ) + assert health == "overheated" + + @pytest.mark.django_db + def test_classify_rent_growth_declining(self) -> None: + """Test rent growth classification - negative = declining (caution).""" + from core.integrations.market.market_trends import classify_market_health + + health = classify_market_health( + indicator_type="rent_growth_yoy", + value=Decimal("-0.02"), # -2% + metro_area="Test Metro", + ) + assert health == "caution" + + +class TestMarketTrendsAdapter: + """Tests for market trends adapter.""" + + @pytest.mark.django_db + def test_fetch_market_indicators_returns_data(self) -> None: + """Test that fetch_market_indicators returns indicator data.""" + from core.integrations.market.market_trends import fetch_market_indicators + + # This will test the adapter interface + # The actual implementation will use mock data or external APIs + result = fetch_market_indicators(metro_area="Test Metro") + assert isinstance(result, list) + # Each item should have indicator_type, value, date_recorded + for item in result: + assert "indicator_type" in item + assert "value" in item + assert "date_recorded" in item + + @pytest.mark.django_db + def test_get_latest_indicators(self) -> None: + """Test getting latest indicators for a metro area.""" + from core.integrations.market.market_trends import get_latest_indicators + + result = get_latest_indicators(metro_area="Test Metro") + assert isinstance(result, dict) + # Should have all 5 indicator types + expected_types = { + "median_price", + "dom", + "months_supply", + "price_to_income", + "rent_growth_yoy", + } + for t in expected_types: + assert t in result + + +class TestUpdateMarketIndicatorsCommand: + """Tests for update_market_indicators management command.""" + + @pytest.mark.django_db + def test_command_creates_indicators(self, capsys) -> None: + """Test that command creates market indicator records.""" + from django.core.management import call_command + + call_command("update_market_indicators", "--metro", "Test Metro") + captured = capsys.readouterr() + assert "Updated" in captured.out or "Created" in captured.out + + # Verify indicators were created + from core.models.growth import MarketIndicator + + indicators = MarketIndicator.objects.filter(metro_area="Test Metro") + assert indicators.count() >= 1 + + +class TestMarketDashboardView: + """Tests for market dashboard view.""" + + @pytest.mark.django_db + def test_dashboard_renders(self, client, user) -> None: + """Test dashboard renders successfully.""" + client.force_login(user) + response = client.get("/markets/") + assert response.status_code == 200 + + @pytest.mark.django_db + def test_dashboard_shows_indicators(self, client, user) -> None: + """Test that dashboard shows indicator cards.""" + from core.models.growth import MarketIndicator, MarketIndicatorType + + # Create some test data + MarketIndicator.objects.create( + metro_area="Dallas-Fort Worth-Arlington, TX", + indicator_type=MarketIndicatorType.MEDIAN_PRICE, + value=Decimal("425000"), + date_recorded="2026-01-15", + ) + + client.force_login(user) + response = client.get("/markets/") + assert response.status_code == 200 + assert "Dallas" in response.content.decode() + + +class TestSparklineHelpers: + """Tests for trend chart helpers.""" + + def test_build_sparkline_points_normalizes_values(self) -> None: + """Points should be normalized into the 100x30 viewBox.""" + from core.integrations.market.market_trends import build_sparkline_points + + points = build_sparkline_points([Decimal("0"), Decimal("5"), Decimal("10")]) + # Rising series: min at bottom-left, max at top-right + assert points == "0,30 50,15 100,0" + + def test_build_sparkline_points_flat_series(self) -> None: + """All-equal values should draw a flat line at the midpoint.""" + from core.integrations.market.market_trends import build_sparkline_points + + points = build_sparkline_points([Decimal("3"), Decimal("3"), Decimal("3")]) + assert points == "0,15 50,15 100,15" + + def test_build_sparkline_points_empty(self) -> None: + """Empty series produces no points.""" + from core.integrations.market.market_trends import build_sparkline_points + + assert build_sparkline_points([]) == "" + + @pytest.mark.django_db + def test_get_indicator_history_returns_oldest_first(self) -> None: + """History should be limited to recent values, oldest first.""" + from core.integrations.market.market_trends import get_indicator_history + from core.models.growth import MarketIndicator, MarketIndicatorType + + MarketIndicator.objects.create( + metro_area="History Metro", + indicator_type=MarketIndicatorType.DOM, + value=Decimal("40"), + date_recorded="2026-01-01", + ) + MarketIndicator.objects.create( + metro_area="History Metro", + indicator_type=MarketIndicatorType.DOM, + value=Decimal("20"), + date_recorded="2026-02-01", + ) + + history = get_indicator_history("History Metro", "dom") + assert history == [Decimal("40"), Decimal("20")] + + @pytest.mark.django_db + def test_get_indicator_history_respects_limit(self) -> None: + """Only the ``limit`` most recent values are returned.""" + from core.integrations.market.market_trends import get_indicator_history + from core.models.growth import MarketIndicator, MarketIndicatorType + + for month in range(1, 6): + MarketIndicator.objects.create( + metro_area="Limit Metro", + indicator_type=MarketIndicatorType.DOM, + value=Decimal(str(month)), + date_recorded=f"2025-{month:02d}-01", + ) + + history = get_indicator_history("Limit Metro", "dom", limit=3) + assert history == [Decimal("3"), Decimal("4"), Decimal("5")] + + @pytest.mark.django_db + def test_get_latest_indicators_prefers_db_records(self) -> None: + """Stored DB values should take precedence over adapter fallback.""" + from core.integrations.market.market_trends import get_latest_indicators + from core.models.growth import MarketIndicator, MarketIndicatorType + + MarketIndicator.objects.create( + metro_area="DB Metro", + indicator_type=MarketIndicatorType.MEDIAN_PRICE, + value=Decimal("999000"), + date_recorded="2026-01-15", + ) + + result = get_latest_indicators("DB Metro") + assert result["median_price"]["value"] == Decimal("999000") + assert result["median_price"]["history"] == [Decimal("999000")] + assert result["median_price"]["sparkline"] != "" + + +class TestMarketIndicatorsE2E: + """End-to-end tests for market indicators feature.""" + + @pytest.mark.django_db + def test_market_dashboard_renders_with_indicators(self, client, user) -> None: + """Test that market dashboard renders with indicators for tracked markets.""" + from core.models.growth import MarketIndicator, MarketIndicatorType + + # Create test data + MarketIndicator.objects.create( + metro_area="Dallas-Fort Worth-Arlington, TX", + indicator_type=MarketIndicatorType.MEDIAN_PRICE, + value=Decimal("425000"), + date_recorded="2026-01-15", + ) + MarketIndicator.objects.create( + metro_area="Dallas-Fort Worth-Arlington, TX", + indicator_type=MarketIndicatorType.DOM, + value=Decimal("28"), + date_recorded="2026-01-15", + ) + + client.force_login(user) + response = client.get("/markets/") + assert response.status_code == 200 + content = response.content.decode() + assert "Dallas" in content + assert "425000" in content or "425" in content # Check median price shown + assert "28" in content # Check DOM shown + # Trend chart (sparkline SVG) should render for each indicator + assert " 0 + + for icon in manifest["icons"]: + assert "src" in icon + assert "sizes" in icon + assert "type" in icon + + +class TestServiceWorker: + """Tests for service worker.""" + + def test_sw_exists(self): + """Test that sw.js exists in static directory.""" + sw_path = Path("static/sw.js") + assert sw_path.exists(), "sw.js should exist in static/" + + def test_sw_registers_in_base(self): + """Test that base.html registers the service worker.""" + base_path = Path("templates/base.html") + with open(base_path) as f: + content = f.read() + assert ( + "navigator.serviceWorker.register" in content + or "serviceWorker.register" in content + ) + + def test_sw_cache_strategy(self): + """Test that service worker implements cache-first strategy.""" + sw_path = Path("static/sw.js") + with open(sw_path) as f: + content = f.read() + + # Check for cache-first strategy patterns + assert "cache" in content.lower() or "cacheFirst" in content + assert "fetch" in content + + +class TestOfflineFallback: + """Tests for offline fallback page.""" + + def test_offline_page_exists(self): + """Test that offline.html exists.""" + offline_path = Path("templates/offline.html") + assert offline_path.exists(), "offline.html should exist in templates/" + + def test_offline_page_content(self): + """Test that offline page has appropriate content.""" + offline_path = Path("templates/offline.html") + with open(offline_path) as f: + content = f.read() + + assert "offline" in content.lower() + assert "internet" in content.lower() or "connection" in content.lower() + + +class TestBaseTemplate: + """Tests for base.html PWA integration.""" + + def test_manifest_link(self): + """Test that base.html links to manifest.json.""" + base_path = Path("templates/base.html") + with open(base_path) as f: + content = f.read() + assert 'rel="manifest"' in content + assert 'href="/static/manifest.json"' in content or 'href="{% static' in content + + def test_theme_color_meta(self): + """Test that base.html has theme-color meta tag.""" + base_path = Path("templates/base.html") + with open(base_path) as f: + content = f.read() + assert 'name="theme-color"' in content + + def test_apple_web_app_meta(self): + """Test that base.html has apple-mobile-web-app-capable meta tag.""" + base_path = Path("templates/base.html") + with open(base_path) as f: + content = f.read() + assert 'name="apple-mobile-web-app-capable"' in content + + def test_viewport_meta(self): + """Test that base.html has proper viewport meta tag.""" + base_path = Path("templates/base.html") + with open(base_path) as f: + content = f.read() + assert 'name="viewport"' in content + assert "width=device-width" in content + + +class TestResponsiveCSS: + """Tests for responsive CSS.""" + + def test_css_has_media_queries(self): + """Test that CSS has mobile-first responsive breakpoints.""" + css_path = Path("static/css/base.css") + with open(css_path) as f: + content = f.read() + + # Check for mobile-first breakpoints + assert "@media (max-width:" in content + assert "max-width: 640px" in content or "max-width: 768px" in content + + def test_css_uses_relative_units(self): + """Test that CSS uses relative units (rem/em) instead of fixed px.""" + css_path = Path("static/css/base.css") + with open(css_path) as f: + content = f.read() + + # Check that rem/em are used for spacing/typography + assert "rem" in content or "em" in content + + +class TestLighthouseCI: + """Tests for Lighthouse CI configuration.""" + + def test_lighthouserc_exists(self): + """Test that lighthouserc.json exists.""" + lh_path = Path("lighthouserc.json") + assert lh_path.exists(), "lighthouserc.json should exist" + + def test_lighthouse_config_valid(self): + """Test that lighthouserc.json is valid JSON with PWA config.""" + lh_path = Path("lighthouserc.json") + with open(lh_path) as f: + config = json.load(f) + + assert "ci" in config + assert "collect" in config["ci"] + assert "assert" in config["ci"] + + +class TestGitHubActions: + """Tests for GitHub Actions Lighthouse CI step.""" + + def test_github_actions_workflow_exists(self): + """Test that GitHub Actions workflow exists for Lighthouse CI.""" + workflow_dir = Path(".github/workflows") + assert workflow_dir.exists() + + workflow_files = list(workflow_dir.glob("*.yml")) + list( + workflow_dir.glob("*.yaml") + ) + assert len(workflow_files) > 0, "At least one workflow file should exist" + + def test_lighthouse_step_in_workflow(self): + """Test that Lighthouse CI step exists in a workflow.""" + workflow_dir = Path(".github/workflows") + workflow_files = list(workflow_dir.glob("*.yml")) + list( + workflow_dir.glob("*.yaml") + ) + + found = False + for wf in workflow_files: + with open(wf) as f: + content = f.read() + if "lighthouse" in content.lower() or "lighthouseci" in content: + found = True + break + + assert found, "No Lighthouse CI step found in GitHub Actions workflows" + + +if __name__ == "__main__": + pytest.main([__file__, "-v"])