Benjamin Harris преди 2 месеца
родител
ревизия
bff4f6862f

+ 3 - 1
.claude/settings.local.json

@@ -7,7 +7,9 @@
       "Bash(where composer:*)",
       "Read(//c/ProgramData/ComposerSetup/**)",
       "Read(//c/Users/lumion/AppData/Roaming/Composer/vendor/**)",
-      "Bash(find /c -name composer.phar)"
+      "Bash(find /c -name composer.phar)",
+      "WebSearch",
+      "WebFetch(domain:github.com)"
     ]
   }
 }

+ 221 - 0
api/weather.php

@@ -0,0 +1,221 @@
+<?php
+/**
+ * api/weather.php
+ *
+ * Returns current weather + 7-day forecast + past 7 days of daily rainfall
+ * for the logged-in user's location, sourced from Open-Meteo (free, no API key).
+ *
+ * Flow:
+ *   1. Load user's postcode from client_records
+ *   2. Geocode postcode → lat/lng via Nominatim (OpenStreetMap, free)
+ *   3. Call Open-Meteo for current conditions, daily forecast, past rainfall
+ *   4. Cache result in session for 30 minutes
+ *   5. Return JSON
+ *
+ * GET params: none required (uses session user)
+ * Optional: ?lat=<lat>&lng=<lng>  (manual override)
+ */
+
+if (session_status() === PHP_SESSION_NONE) {
+    session_start();
+}
+
+require_once __DIR__ . '/../config/database.php';
+require_once __DIR__ . '/../lib/auth.php';
+
+header('Content-Type: application/json');
+
+if (!isLoggedIn()) {
+    http_response_code(401);
+    echo json_encode(['error' => 'Not authenticated']);
+    exit;
+}
+
+$userId = getCurrentUserId();
+
+// ── Manual lat/lng override ───────────────────────────────────────────────────
+$manualLat = isset($_GET['lat']) && is_numeric($_GET['lat']) ? (float)$_GET['lat'] : null;
+$manualLng = isset($_GET['lng']) && is_numeric($_GET['lng']) ? (float)$_GET['lng'] : null;
+
+// ── Session cache (30 min) ────────────────────────────────────────────────────
+$cacheKey = 'weather_' . $userId . ($manualLat ? "_{$manualLat}_{$manualLng}" : '');
+if (
+    !$manualLat
+    && isset($_SESSION[$cacheKey], $_SESSION[$cacheKey . '_ts'])
+    && (time() - $_SESSION[$cacheKey . '_ts']) < 1800
+) {
+    echo $_SESSION[$cacheKey];
+    exit;
+}
+
+// ── Resolve lat/lng ───────────────────────────────────────────────────────────
+$lat = $manualLat;
+$lng = $manualLng;
+$locationLabel = 'Your Location';
+
+if (!$lat) {
+    // Load client postcode
+    $postcode = null;
+    try {
+        $pdo  = getDBConnection();
+        $stmt = $pdo->prepare(
+            'SELECT state_postcode, address FROM client_records WHERE modx_user_id = ? LIMIT 1'
+        );
+        $stmt->execute([$userId]);
+        $client = $stmt->fetch(PDO::FETCH_ASSOC);
+        if ($client && !empty($client['state_postcode'])) {
+            // Extract just the postcode portion (e.g. "SA 5000" → "5000")
+            preg_match('/\d{4}/', $client['state_postcode'], $m);
+            $postcode = $m[0] ?? null;
+            $locationLabel = trim($client['state_postcode']);
+        }
+    } catch (PDOException $e) {
+        error_log('weather.php DB error: ' . $e->getMessage());
+    }
+
+    if ($postcode) {
+        // Geocode via Nominatim
+        $geocodeUrl = 'https://nominatim.openstreetmap.org/search?'
+            . http_build_query([
+                'postalcode' => $postcode,
+                'country'    => 'AU',
+                'format'     => 'json',
+                'limit'      => 1,
+            ]);
+
+        $ch = curl_init($geocodeUrl);
+        curl_setopt_array($ch, [
+            CURLOPT_RETURNTRANSFER => true,
+            CURLOPT_TIMEOUT        => 5,
+            CURLOPT_HTTPHEADER     => ['User-Agent: CropMonitor/1.0 (contact@cropmonitor.com.au)'],
+        ]);
+        $geoResp = curl_exec($ch);
+        curl_close($ch);
+
+        if ($geoResp) {
+            $geoData = json_decode($geoResp, true);
+            if (!empty($geoData[0]['lat'])) {
+                $lat = (float)$geoData[0]['lat'];
+                $lng = (float)$geoData[0]['lon'];
+            }
+        }
+    }
+
+    // Default to Adelaide if geocoding fails
+    if (!$lat) {
+        $lat = -34.9285;
+        $lng = 138.6007;
+        $locationLabel = 'Adelaide, SA (default)';
+    }
+}
+
+// ── Open-Meteo API call ───────────────────────────────────────────────────────
+// current: temperature, apparent temp, weather code, wind speed, humidity, precipitation
+// daily (14 days: 7 past + 7 forecast): max/min temp, precipitation sum, weather code
+$meteoUrl = 'https://api.open-meteo.com/v1/forecast?' . http_build_query([
+    'latitude'               => round($lat, 4),
+    'longitude'              => round($lng, 4),
+    'current'                => 'temperature_2m,apparent_temperature,weather_code,wind_speed_10m,relative_humidity_2m,precipitation',
+    'daily'                  => 'weather_code,temperature_2m_max,temperature_2m_min,precipitation_sum,wind_speed_10m_max',
+    'timezone'               => 'Australia/Adelaide',
+    'past_days'              => 7,
+    'forecast_days'          => 7,
+    'wind_speed_unit'        => 'kmh',
+]);
+
+$ch = curl_init($meteoUrl);
+curl_setopt_array($ch, [
+    CURLOPT_RETURNTRANSFER => true,
+    CURLOPT_TIMEOUT        => 8,
+]);
+$meteoResp = curl_exec($ch);
+$meteoCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
+curl_close($ch);
+
+if (!$meteoResp || $meteoCode !== 200) {
+    http_response_code(502);
+    echo json_encode(['error' => 'Could not fetch weather data from Open-Meteo']);
+    exit;
+}
+
+$meteo = json_decode($meteoResp, true);
+
+// ── WMO weather code → description + icon name ────────────────────────────────
+// Icon names match Skycons library used in dashboard
+function wmoToCondition(int $code): array
+{
+    $map = [
+        0  => ['Clear',              'clear-day'],
+        1  => ['Mostly Clear',       'clear-day'],
+        2  => ['Partly Cloudy',      'partly-cloudy-day'],
+        3  => ['Overcast',           'cloudy'],
+        45 => ['Fog',                'fog'],
+        48 => ['Icy Fog',            'fog'],
+        51 => ['Light Drizzle',      'rain'],
+        53 => ['Drizzle',            'rain'],
+        55 => ['Heavy Drizzle',      'rain'],
+        61 => ['Light Rain',         'rain'],
+        63 => ['Rain',               'rain'],
+        65 => ['Heavy Rain',         'rain'],
+        71 => ['Light Snow',         'snow'],
+        73 => ['Snow',               'snow'],
+        75 => ['Heavy Snow',         'snow'],
+        77 => ['Snow Grains',        'sleet'],
+        80 => ['Light Showers',      'showers-day'],
+        81 => ['Showers',            'showers-day'],
+        82 => ['Heavy Showers',      'showers-day'],
+        85 => ['Snow Showers',       'snow'],
+        86 => ['Heavy Snow Showers', 'snow'],
+        95 => ['Thunderstorm',       'thunderstorm'],
+        96 => ['Hail Storm',         'hail'],
+        99 => ['Heavy Hail Storm',   'hail'],
+    ];
+    return $map[$code] ?? ['Unknown', 'cloudy'];
+}
+
+// ── Build response ────────────────────────────────────────────────────────────
+$current = $meteo['current'] ?? [];
+$daily   = $meteo['daily']   ?? [];
+
+[$curLabel, $curIcon] = wmoToCondition((int)($current['weather_code'] ?? 0));
+
+$days = [];
+$today = date('Y-m-d');
+foreach (($daily['time'] ?? []) as $i => $date) {
+    [$label, $icon] = wmoToCondition((int)($daily['weather_code'][$i] ?? 0));
+    $days[] = [
+        'date'      => $date,
+        'is_past'   => $date < $today,
+        'is_today'  => $date === $today,
+        'label'     => $label,
+        'icon'      => $icon,
+        'temp_max'  => round($daily['temperature_2m_max'][$i] ?? 0, 1),
+        'temp_min'  => round($daily['temperature_2m_min'][$i] ?? 0, 1),
+        'rain'      => round($daily['precipitation_sum'][$i] ?? 0, 1),
+        'wind_max'  => round($daily['wind_speed_10m_max'][$i] ?? 0, 0),
+        'day_name'  => date('D', strtotime($date)),
+        'day_short' => date('j M', strtotime($date)),
+    ];
+}
+
+$result = json_encode([
+    'location' => $locationLabel,
+    'lat'      => $lat,
+    'lng'      => $lng,
+    'current'  => [
+        'temp'       => round($current['temperature_2m']      ?? 0, 1),
+        'feels_like' => round($current['apparent_temperature'] ?? 0, 1),
+        'humidity'   => round($current['relative_humidity_2m'] ?? 0, 0),
+        'wind'       => round($current['wind_speed_10m']       ?? 0, 0),
+        'rain'       => round($current['precipitation']        ?? 0, 1),
+        'label'      => $curLabel,
+        'icon'       => $curIcon,
+    ],
+    'days' => $days,
+]);
+
+// Cache in session
+$_SESSION[$cacheKey]        = $result;
+$_SESSION[$cacheKey . '_ts'] = time();
+
+echo $result;

BIN
books/Hill-Laboratories_Field-Consultants-Guide_Soil_Plant-Analysis.pdf


BIN
books/plant-analysis-an-interpretation-manual_compress.pdf


+ 1 - 10
dashboard/crop-analysis/plant-test-data/plant-report.php

@@ -73,12 +73,7 @@ $siteName  = 'Crop Monitor';
 include __DIR__ . '/../../../layouts/header.php';
 ?>
 
-<div id="layoutSidenav">
-    <div id="layoutSidenav_nav">
-        <?php include __DIR__ . '/../../../layouts/sidebar.php'; ?>
-    </div>
-    <div id="layoutSidenav_content">
-        <main>
+
             <div class="container-fluid px-4">
 
                 <!-- ── Page heading ─────────────────────────────────────────── -->
@@ -218,10 +213,6 @@ include __DIR__ . '/../../../layouts/header.php';
                 </form>
 
             </div><!-- /.container-fluid -->
-        </main>
-        <?php include __DIR__ . '/../../../layouts/footer.php'; ?>
-    </div>
-</div>
 
 <script>
 (function () {

+ 144 - 46
dashboard/dashboard.php

@@ -98,14 +98,20 @@ include __DIR__ . '/../layouts/navbar.php';
 
                     <!-- Weather widget -->
                     <div class="col-md-7">
-                        <div class="weather">
+                        <!-- Skeleton shown while loading -->
+                        <div id="weather-loading" class="weather d-flex align-items-center justify-content-center" style="min-height:180px;">
+                            <div class="text-muted small"><span class="spinner-border spinner-border-sm me-2"></span>Loading weather…</div>
+                        </div>
+
+                        <!-- Populated by JS -->
+                        <div id="weather-widget" class="weather" style="display:none;">
                             <div class="weather-top">
                                 <div class="weather-top-left">
                                     <div class="degree">
                                         <figure class="icons">
-                                            <canvas id="partly-cloudy-day" width="64" height="64"></canvas>
+                                            <canvas id="wx-hero-canvas" width="64" height="64"></canvas>
                                         </figure>
-                                        <span>37°</span>
+                                        <span id="wx-temp">—</span>
                                         <div class="clearfix"></div>
                                     </div>
                                     <p>
@@ -115,38 +121,35 @@ include __DIR__ . '/../layouts/navbar.php';
                                     </p>
                                 </div>
                                 <div class="weather-top-right">
-                                    <p><i class="fa fa-map-marker"></i> &mdash;</p>
-                                    <label>&mdash;</label>
+                                    <p><i class="fa fa-map-marker"></i> <span id="wx-location">—</span></p>
+                                    <label id="wx-condition">—</label>
+                                    <div class="small text-muted mt-1">
+                                        <span title="Humidity"><i class="fa fa-tint"></i> <span id="wx-humidity">—</span>%</span>
+                                        &nbsp;
+                                        <span title="Wind"><i class="fa fa-wind"></i> <span id="wx-wind">—</span> km/h</span>
+                                        &nbsp;
+                                        <span title="Rain now"><i class="fa fa-cloud-rain"></i> <span id="wx-rain">—</span> mm</span>
+                                    </div>
                                 </div>
                                 <div class="clearfix"></div>
                             </div>
 
-                            <div class="weather-bottom">
-                                <?php
-                                $forecast = [
-                                    ['label' => 'Cloudy', 'icon' => 'cloudy',    'temp' => '20°', 'offset' => 1],
-                                    ['label' => 'Sunny',  'icon' => 'clear-day', 'temp' => '37°', 'offset' => 2],
-                                    ['label' => 'Rainy',  'icon' => 'sleet',     'temp' => '7°',  'offset' => 3],
-                                    ['label' => 'Windy',  'icon' => 'wind',      'temp' => '14°', 'offset' => 4],
-                                ];
-                                foreach ($forecast as $day): ?>
-                                <div class="weather-bottom1">
-                                    <div class="weather-head">
-                                        <h4><?= $day['label'] ?></h4>
-                                        <figure class="icons">
-                                            <canvas id="fc-<?= $day['icon'] ?>" width="58" height="58"></canvas>
-                                        </figure>
-                                        <h6><?= $day['temp'] ?></h6>
-                                        <div class="bottom-head">
-                                            <p><?= date('F j', strtotime('+' . $day['offset'] . ' day')) ?></p>
-                                            <p><?= date('l',   strtotime('+' . $day['offset'] . ' day')) ?></p>
-                                        </div>
-                                    </div>
-                                </div>
-                                <?php endforeach; ?>
+                            <!-- 7-day forecast (future days) -->
+                            <div class="weather-bottom" id="wx-forecast-row">
+                                <!-- filled by JS -->
                                 <div class="clearfix"></div>
                             </div>
                         </div>
+
+                        <!-- Past 7-day rainfall chart -->
+                        <div id="wx-rainfall-card" class="card mt-3" style="display:none;">
+                            <div class="card-header py-1 small fw-bold">Past 7 Days Rainfall (mm)</div>
+                            <div class="card-body py-2">
+                                <canvas id="wx-rainfall-chart" height="80"></canvas>
+                            </div>
+                        </div>
+
+                        <div id="weather-error" class="alert alert-warning mt-2 small" style="display:none;"></div>
                     </div><!-- /col: weather -->
 
                     <!-- Calendar widget -->
@@ -171,23 +174,118 @@ include __DIR__ . '/../layouts/navbar.php';
 <?php include __DIR__ . '/../layouts/footer.php'; ?>
 
 <script>
-    // Initialise all Skycons in a single pass after DOM is ready
-    (function () {
-        var hero = new Skycons({ color: '#1ABC9C' });
-        hero.set('partly-cloudy-day', 'partly-cloudy-day');
-        hero.play();
-
-        var fc = new Skycons({ color: '#999' });
-        [
-            ['fc-cloudy',    'cloudy'],
-            ['fc-clear-day', 'clear-day'],
-            ['fc-sleet',     'sleet'],
-            ['fc-wind',      'wind'],
-        ].forEach(function (pair) {
-            if (document.getElementById(pair[0])) {
-                fc.set(pair[0], pair[1]);
-            }
+(function () {
+    'use strict';
+
+    var heroSkycons = null;
+    var fcSkycons   = null;
+    var rainfallChart = null;
+
+    function renderWeather(data) {
+        // ── Current conditions ───────────────────────────────────────────────
+        document.getElementById('wx-temp').textContent      = data.current.temp + '°';
+        document.getElementById('wx-condition').textContent = data.current.label;
+        document.getElementById('wx-location').textContent  = data.location;
+        document.getElementById('wx-humidity').textContent  = data.current.humidity;
+        document.getElementById('wx-wind').textContent      = data.current.wind;
+        document.getElementById('wx-rain').textContent      = data.current.rain;
+
+        // Hero Skycon
+        var heroCanvas = document.getElementById('wx-hero-canvas');
+        if (!heroSkycons) {
+            heroSkycons = new Skycons({ color: '#1ABC9C' });
+        }
+        heroSkycons.set(heroCanvas, data.current.icon);
+        heroSkycons.play();
+
+        // ── Forecast row (future days only, up to 7) ─────────────────────────
+        var forecastRow = document.getElementById('wx-forecast-row');
+        var futureDays  = data.days.filter(function (d) { return !d.is_past && !d.is_today; }).slice(0, 5);
+        var fcHtml = '';
+
+        if (!fcSkycons) {
+            fcSkycons = new Skycons({ color: '#999' });
+        }
+
+        futureDays.forEach(function (d, i) {
+            var cid = 'wx-fc-' + i;
+            fcHtml +=
+                '<div class="weather-bottom1">' +
+                    '<div class="weather-head">' +
+                        '<h4>' + d.label + '</h4>' +
+                        '<figure class="icons"><canvas id="' + cid + '" width="58" height="58"></canvas></figure>' +
+                        '<h6>' + d.temp_max + '°</h6>' +
+                        '<div class="bottom-head">' +
+                            '<p>' + d.day_short + '</p>' +
+                            '<p>' + d.day_name + '</p>' +
+                        '</div>' +
+                    '</div>' +
+                '</div>';
+        });
+        fcHtml += '<div class="clearfix"></div>';
+        forecastRow.innerHTML = fcHtml;
+
+        futureDays.forEach(function (d, i) {
+            var el = document.getElementById('wx-fc-' + i);
+            if (el) { fcSkycons.set(el, d.icon); }
         });
-        fc.play();
-    })();
+        fcSkycons.play();
+
+        // ── Past 7-day rainfall chart ─────────────────────────────────────────
+        var pastDays = data.days.filter(function (d) { return d.is_past; }).slice(-7);
+        if (pastDays.length > 0) {
+            var labels = pastDays.map(function (d) { return d.day_name + ' ' + d.day_short; });
+            var values = pastDays.map(function (d) { return d.rain; });
+
+            var ctx = document.getElementById('wx-rainfall-chart').getContext('2d');
+            if (rainfallChart) { rainfallChart.destroy(); }
+            rainfallChart = new Chart(ctx, {
+                type: 'bar',
+                data: {
+                    labels: labels,
+                    datasets: [{
+                        label: 'Rainfall (mm)',
+                        data: values,
+                        backgroundColor: 'rgba(54, 162, 235, 0.6)',
+                        borderColor:     'rgba(54, 162, 235, 1)',
+                        borderWidth: 1,
+                    }],
+                },
+                options: {
+                    responsive: true,
+                    plugins: { legend: { display: false } },
+                    scales: {
+                        y: { beginAtZero: true, ticks: { font: { size: 10 } } },
+                        x: { ticks: { font: { size: 10 } } },
+                    },
+                },
+            });
+            document.getElementById('wx-rainfall-card').style.display = '';
+        }
+
+        // Show widget, hide skeleton
+        document.getElementById('weather-loading').style.display = 'none';
+        document.getElementById('weather-widget').style.display  = '';
+    }
+
+    function loadWeather() {
+        fetch('/api/weather.php')
+            .then(function (r) {
+                if (!r.ok) { throw new Error('HTTP ' + r.status); }
+                return r.json();
+            })
+            .then(function (data) {
+                if (data.error) { throw new Error(data.error); }
+                renderWeather(data);
+            })
+            .catch(function (err) {
+                document.getElementById('weather-loading').style.display = 'none';
+                var errEl = document.getElementById('weather-error');
+                errEl.textContent = 'Weather unavailable: ' + err.message;
+                errEl.style.display = '';
+            });
+    }
+
+    document.addEventListener('DOMContentLoaded', loadWeather);
+})();
 </script>