Skip to content
Merged
5 changes: 4 additions & 1 deletion Makefile
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
.PHONY: help install run test fuzz lint format audit a11y ci migrate schema-dump backup restore docker-up docker-down clean
.PHONY: help install run demo-readings test fuzz lint format audit a11y ci migrate schema-dump backup restore docker-up docker-down clean

help: ## Show this help
@egrep -h '\s##\s' $(MAKEFILE_LIST) | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-20s\033[0m %s\n", $$1, $$2}'
Expand All @@ -10,6 +10,9 @@ install: ## Install python dependencies
run: ## Run the FastAPI application locally
uvicorn app.main:app --reload --host 127.0.0.1 --port 8000

demo-readings: ## Demo öncesi: sensörlere son 48h taze toprak nemi okuması (DB sıfırlamadan)
PYTHONPATH=. python scripts/seed_demo_readings.py

test: ## Run the test suite with coverage (Schemathesis fuzz hariç — `make fuzz`)
SKIP_SCHEMATHESIS=1 pytest tests/ --cov=app --cov-report=term-missing

Expand Down
24 changes: 24 additions & 0 deletions app/routers/dashboard.py
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@
from app.schemas.schemas import (
DashboardLastDisease,
DashboardLastIrrigation,
DashboardMoisturePoint,
DashboardOpenAlerts,
DashboardSoilMoisture,
DashboardSummaryResponse,
Expand Down Expand Up @@ -145,6 +146,28 @@ def get_dashboard_summary(
status=_classify_moisture(avg_moisture),
)

# Toprak nemi trendi (son 24 saat, saatlik ortalama) — dashboard grafiği için.
# DB-agnostik: ham okumalar Python'da saatlik bucket'lanır (~192 satır, hafif).
trend_q = (
db.query(SoilMoistureReading.reading_timestamp, SoilMoistureReading.moisture_percent)
.join(Sensor, SoilMoistureReading.sensor_id == Sensor.id)
.join(Field, Sensor.field_id == Field.id)
.join(Farm, Field.farm_id == Farm.id)
.filter(SoilMoistureReading.reading_timestamp >= since)
)
if not is_system:
trend_q = trend_q.filter(Farm.user_id == current_user.id)
hourly_buckets: dict[datetime, list[float]] = {}
for ts, moisture in trend_q.all():
if ts is None or moisture is None:
continue
bucket = ts.replace(minute=0, second=0, microsecond=0)
hourly_buckets.setdefault(bucket, []).append(moisture)
moisture_trend = [
DashboardMoisturePoint(hour=bucket, moisture_percent=round(sum(vals) / len(vals), 1))
for bucket, vals in sorted(hourly_buckets.items())
]

# ─── 2. Son sulama ───────────────────────────────────────────
irrigation_q = (
db.query(IrrigationSchedule, Field.name)
Expand Down Expand Up @@ -224,6 +247,7 @@ def get_dashboard_summary(
field_count=field_count,
sensor_count=sensor_count,
soil_moisture_today=soil_moisture,
soil_moisture_trend=moisture_trend,
last_irrigation=last_irrigation,
open_alerts=open_alerts,
last_disease=last_disease,
Expand Down
8 changes: 8 additions & 0 deletions app/schemas/dashboard.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,13 @@ class DashboardSoilMoisture(BaseModel):
status: str = "no_data" # 'dry' | 'optimal' | 'wet' | 'no_data'


class DashboardMoisturePoint(BaseModel):
"""Saatlik ortalama toprak nemi — dashboard 'Toprak Nemi Trendi' grafiği için."""

hour: UtcDateTime
moisture_percent: float


class DashboardLastIrrigation(BaseModel):
"""En son planlanan/gerçekleşen sulama kaydı."""

Expand Down Expand Up @@ -69,6 +76,7 @@ class DashboardSummaryResponse(BaseModel):
field_count: int
sensor_count: int
soil_moisture_today: DashboardSoilMoisture
soil_moisture_trend: list[DashboardMoisturePoint] = Field(default_factory=list)
last_irrigation: DashboardLastIrrigation
open_alerts: DashboardOpenAlerts
last_disease: DashboardLastDisease
Expand Down
2 changes: 2 additions & 0 deletions docs/demo_script.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@ rm -f sfdap_dev.db && python database/seed_data.py
make run # uvicorn → http://localhost:8000
```

> 💡 **Alternatif** (DB sıfırlamadan): mevcut demo verisini koruyup okumaları tazelemek → `make demo-readings`.

Tarayıcıda aç: **http://localhost:8000/dashboard/** (+ opsiyonel `/docs` Swagger).

**Demo hesapları:**
Expand Down
2 changes: 1 addition & 1 deletion frontend/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -899,7 +899,7 @@ <h2 id="pageTitle">Dashboard</h2>
<!-- ═══ DASHBOARD PAGE ═══ -->
<section class="page active" id="page-dashboard">
<div class="hero-banner">
<h1><span class="hero-emoji">🌾</span> Hoş geldin, çiftçi!</h1>
<h1><span class="hero-emoji">🌾</span> Hoş geldin, <span id="heroGreetName">çiftçi</span>!</h1>
<p class="hero-filiz-tip" data-page="dashboard">Çiftliğinden toplanan toprak, hava ve sulama bilgilerini tek bir ekranda gör. Akıllı asistan seninle çalışıyor.</p>
<div class="hero-stats">
<div class="hero-stat">🚜 <strong id="heroFarms">—</strong> çiftlik</div>
Expand Down
1 change: 1 addition & 0 deletions frontend/src/lib/api.js
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,7 @@ export async function apiAuth(endpoint, options = {}) {
return await res.json();
} catch (e) {
console.warn(`API Auth Error: ${endpoint}`, e);
if (e instanceof TypeError) _showToast?.('Sunucuya ulaşılamadı', 'error');
return null;
}
}
Expand Down
10 changes: 5 additions & 5 deletions frontend/src/lib/charts.js
Original file line number Diff line number Diff line change
Expand Up @@ -51,12 +51,12 @@ export function rethemeCharts() {
}

export function renderMoistureChart(data) {
const sorted = [...data].reverse();
const labels = sorted.map(d => new Date(d.recorded_at).toLocaleDateString('tr'));
const values = sorted.map(d => d.humidity_percent);
const points = Array.isArray(data) ? data : [];
const labels = points.map(d => new Date(d.hour).toLocaleString('tr', { day: '2-digit', month: '2-digit', hour: '2-digit', minute: '2-digit' }));
const values = points.map(d => d.moisture_percent);
if (charts.moisture) charts.moisture.destroy();
charts.moisture = new Chart(document.getElementById('moistureChart'), {
type: 'line', data: { labels, datasets: [{ label: 'Nem %', data: values, borderColor: '#3b82f6',
type: 'line', data: { labels, datasets: [{ label: 'Toprak Nemi %', data: values, borderColor: '#3b82f6',
backgroundColor: 'rgba(59,130,246,.1)', fill: true, tension: .4, pointRadius: 2 }] },
options: { responsive: true, plugins: { legend: { labels: { color: chartLegend() } } },
scales: { x: { ticks: { color: chartTick() }, grid: { color: chartGrid() } }, y: { ticks: { color: chartTick() }, grid: { color: chartGrid() } } } }
Expand Down Expand Up @@ -246,7 +246,7 @@ export function renderDailyTrendChart(trends) {
}

export function renderSensorStatsChart(stats) {
if (!stats || !stats.moisture.avg) return;
if (!stats || !stats.moisture || !stats.soil_temperature || stats.moisture.avg == null) return;
if (charts.sensorStats) charts.sensorStats.destroy();
charts.sensorStats = new Chart(document.getElementById('sensorStatsChart'), {
type: 'bar',
Expand Down
2 changes: 1 addition & 1 deletion frontend/src/lib/filiz.js
Original file line number Diff line number Diff line change
Expand Up @@ -423,7 +423,7 @@ export function initFiliz() {
setFilizMood(isLateNight ? 'sleepy' : 'happy');

// Alert listesini balon için arka planda topla (mood'u etkilemez)
if (getApiOnline()) {
if (getApiOnline() && getCurrentUser()) {
try {
const alerts = await api('/api/alerts/?severity=critical&is_resolved=false&limit=10');
filizCriticalAlerts = alerts || [];
Expand Down
26 changes: 26 additions & 0 deletions frontend/src/lib/labels.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
/* ════════════════════════════════════════════════════════════════
i18n — backend enum değerlerini Türkçe etikete çevirir.
Bilinmeyen değer → değerin kendisi (fallback); null/boş → '—'.
════════════════════════════════════════════════════════════════ */
const _map = (m) => (v) => (v == null || v === '' ? '—' : (m[String(v).toLowerCase()] ?? String(v)));

// Uyarı + hastalık şiddeti (low/medium/critical + none/low/medium/high)
export const severityLabel = _map({
none: 'Yok', low: 'Düşük', medium: 'Orta', high: 'Yüksek', critical: 'Kritik',
});

// Uyarı tipi
export const alertTypeLabel = _map({
low_moisture: 'Düşük nem', disease_reminder: 'Hastalık hatırlatması', model_drift: 'Model sapması',
});

// Sulama durumu
export const irrigationStatusLabel = _map({
pending: 'Beklemede', completed: 'Tamamlandı', cancelled: 'İptal', scheduled: 'Planlandı',
});

// Hastalık tanısı (ML sınıfları)
export const diagnosisLabel = _map({
healthy: 'Sağlıklı', leaf_spot: 'Yaprak lekesi', powdery_mildew: 'Külleme', rust: 'Pas',
blight: 'Yanıklık', mosaic_virus: 'Mozaik virüsü', bacterial_wilt: 'Bakteriyel solgunluk', anthracnose: 'Antraknoz',
});
29 changes: 10 additions & 19 deletions frontend/src/lib/pages/alerts.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,9 @@
============================================================ */

import { _escAttr, showToast } from "../utils.js";
import { api, apiAuth, API_BASE } from "../api.js";
import { api, apiAuth } from "../api.js";
import { _skeletonCards, _skeletonBlock, _setBusy } from "../skeleton.js";
import { severityLabel, alertTypeLabel } from "../labels.js";

// ─── ALERTS ───────────────────────────────────────────────────
export async function loadAlerts() {
Expand Down Expand Up @@ -57,9 +58,9 @@ export async function loadAlerts() {
: `<button class="btn-secondary" style="padding:4px 10px;" data-action="resolveAlert" data-id="${a.id}" aria-label="Uyarıyı çözüldü olarak işaretle">Çöz</button>`;
html += `<tr>
<td style="padding:8px;border-bottom:1px solid var(--border);font-size:.85rem;">${date}</td>
<td style="padding:8px;border-bottom:1px solid var(--border);"><span style="color:${sevColor};font-weight:600;">${a.severity}</span></td>
<td style="padding:8px;border-bottom:1px solid var(--border);">${a.alert_type}</td>
<td style="padding:8px;border-bottom:1px solid var(--border);">${a.message}</td>
<td style="padding:8px;border-bottom:1px solid var(--border);"><span style="color:${sevColor};font-weight:600;">${severityLabel(a.severity)}</span></td>
<td style="padding:8px;border-bottom:1px solid var(--border);">${alertTypeLabel(a.alert_type)}</td>
<td style="padding:8px;border-bottom:1px solid var(--border);">${_escAttr(a.message)}</td>
<td style="padding:8px;border-bottom:1px solid var(--border);">${status}</td>
</tr>`;
}
Expand All @@ -69,20 +70,10 @@ export async function loadAlerts() {
}

export async function resolveAlert(id) {
try {
const resp = await fetch(`${API_BASE}/api/alerts/${id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json', 'X-API-Key': 'dev-api-key' },
body: JSON.stringify({ is_resolved: true }),
});
if (resp.ok) {
showToast('Uyarı çözüldü olarak işaretlendi', 'success');
loadAlerts();
} else {
showToast(`Hata ${resp.status}`, 'error');
}
} catch (e) {
showToast('Sunucuya ulaşılamadı', 'error');
const res = await apiAuth(`/api/alerts/${id}`, { method: 'PATCH', body: JSON.stringify({ is_resolved: true }) });
if (res) {
showToast('Uyarı çözüldü olarak işaretlendi', 'success');
loadAlerts();
}
}

Expand Down Expand Up @@ -115,7 +106,7 @@ export async function refreshBell() {
<div class="notif-item severity-${_escAttr(a.severity)}">
<div class="notif-item-msg">${_escAttr(a.message)}</div>
<div class="notif-item-foot">
<span class="notif-item-sev">${_escAttr(a.severity)}</span>
<span class="notif-item-sev">${_escAttr(severityLabel(a.severity))}</span>
<button class="btn-mini" data-action="resolveFromBell" data-id="${a.id}">Çöz</button>
</div>
</div>`).join('');
Expand Down
11 changes: 8 additions & 3 deletions frontend/src/lib/pages/dashboard.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
import { _fmtDate, _fmtNumber, _escAttr, showToast, updateStatus, _STATUS_EMOJI, _STATUS_LABEL } from "../utils.js";
import { api, apiAuth, getAuthToken } from "../api.js";
import { _skeletonCards, _setBusy } from "../skeleton.js";
import { irrigationStatusLabel, diagnosisLabel } from "../labels.js";
import { renderMoistureChart, renderTempHumChart, renderPrecipChart } from "../charts.js";
import { getCurrentUser, getApiOnline, setApiOnline } from "../session.js";
import { _getRoleTips } from "../filiz.js";
Expand Down Expand Up @@ -41,7 +42,7 @@ function _renderSummaryCards(summary) {
? `<span class="severity-chip severity-${cls}">${cnt} ${label}</span>`
: '';
}).join(' ');
const diseaseDx = disease.diagnosis || '—';
const diseaseDx = diagnosisLabel(disease.diagnosis);
const diseaseDetail = disease.diagnosis
? `${disease.field_name || 'Tarla'} · ${_fmtDate(disease.captured_at)} · güven %${_fmtNumber((disease.confidence_score || 0) * 100, 0)}`
: 'Henüz yaprak görseli analiz edilmedi';
Expand All @@ -66,7 +67,7 @@ function _renderSummaryCards(summary) {
</div>
<div class="metric-value">${irrAmount}</div>
<div class="metric-context"><strong>${_escAttr(irr.field_name || '—')}</strong> · ${_fmtDate(irr.scheduled_date)}</div>
<div class="metric-context">${_escAttr(irr.status || (irr.irrigation_id ? '—' : 'Sulama kaydı yok'))}</div>
<div class="metric-context">${_escAttr(irr.status ? irrigationStatusLabel(irr.status) : (irr.irrigation_id ? '—' : 'Sulama kaydı yok'))}</div>
<div class="metric-scope">${scopeNote}</div>
</div>

Expand Down Expand Up @@ -146,6 +147,10 @@ export async function loadDashboard() {
]);
setApiOnline(summary !== null);
updateStatus(getApiOnline());
if (summary) {
const heroGreet = document.getElementById('heroGreetName');
if (heroGreet) heroGreet.textContent = summary.user_name || 'çiftçi';
}

if (summary && summary.scope === 'user' && summary.farm_count === 0) {
// Boş hesap — onboarding banner (REBUILD Faz 6)
Expand Down Expand Up @@ -173,7 +178,7 @@ export async function loadDashboard() {

// Trend chart'lar — weather Bearer üzerinden gelir (api() artık _authHeaders kullanıyor).
const w = weather || [];
renderMoistureChart(w);
renderMoistureChart((summary && summary.soil_moisture_trend) || []);
renderTempHumChart(w);
renderPrecipChart(w);
}
Expand Down
12 changes: 7 additions & 5 deletions frontend/src/lib/pages/fields.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
import { _fmtNumber, _escAttr, showToast } from "../utils.js";
import { apiAuth, getAuthToken, API_BASE } from "../api.js";
import { _skeletonBlock, _setBusy } from "../skeleton.js";
import { diagnosisLabel, severityLabel } from "../labels.js";
import { charts, chartTick, chartLegend, chartGrid } from "../charts.js";
import { renderFieldDetail } from "../render.js";
import { pageTitles } from "../router.js";
Expand Down Expand Up @@ -141,7 +142,7 @@ export async function submitNewFarm() {
area_hectares: parseFloat(document.getElementById('nfArea').value) || null,
};
const res = await apiAuth('/api/farms/', { method: 'POST', body: JSON.stringify(body) });
if (res) { showToast('Çiftlik eklendi ✅', 'success'); loadFields(); }
if (res) { ['nfName', 'nfCity', 'nfRegion', 'nfArea'].forEach(id => { const el = document.getElementById(id); if (el) el.value = ''; }); showToast('Çiftlik eklendi ✅', 'success'); loadFields(); }
}

export async function submitNewField() {
Expand All @@ -155,7 +156,7 @@ export async function submitNewField() {
area_hectares: parseFloat(document.getElementById('ndArea').value) || null,
};
const res = await apiAuth('/api/fields', { method: 'POST', body: JSON.stringify(body) });
if (res) { showToast('Tarla eklendi ✅', 'success'); loadFields(); }
if (res) { ['ndName', 'ndSoil', 'ndArea'].forEach(id => { const el = document.getElementById(id); if (el) el.value = ''; }); showToast('Tarla eklendi ✅', 'success'); loadFields(); }
}

export async function editFarm(farmId, currentName) {
Expand Down Expand Up @@ -211,6 +212,7 @@ export async function submitNewSensor(fieldId) {
};
const res = await apiAuth('/api/sensors/', { method: 'POST', body: JSON.stringify(body) });
if (res) {
['nsSerial', 'nsDepth'].forEach(id => { const el = document.getElementById(id); if (el) el.value = ''; });
showToast('Sensör eklendi ✅', 'success');
if (currentFieldId) loadFieldDetail(currentFieldId);
}
Expand Down Expand Up @@ -321,11 +323,11 @@ export async function analyzeFieldLeaf() {
}
const data = await resp.json();
const sev = data.severity || 'none';
const sevColor = sev === 'severe' ? '#ef4444' : sev === 'moderate' ? '#f97316' : sev === 'mild' ? '#eab308' : '#22c55e';
const sevColor = sev === 'high' ? '#ef4444' : sev === 'medium' ? '#f97316' : sev === 'low' ? '#eab308' : '#22c55e';
const box = document.getElementById('fieldLeafResult');
box.innerHTML = `<div style="border-left:4px solid ${sevColor};padding-left:12px;">
<h4 style="margin:0 0 6px;">🧪 ${_escAttr(data.diagnosis)}</h4>
<p style="margin:2px 0;">Güven: <strong>%${_fmtNumber(data.confidence_score * 100, 0)}</strong> · Şiddet: <strong style="color:${sevColor};">${_escAttr(sev)}</strong></p>
<h4 style="margin:0 0 6px;">🧪 ${_escAttr(diagnosisLabel(data.diagnosis))}</h4>
<p style="margin:2px 0;">Güven: <strong>${data.confidence_score != null ? '%' + _fmtNumber(data.confidence_score * 100, 0) : '—'}</strong> · Şiddet: <strong style="color:${sevColor};">${_escAttr(severityLabel(sev))}</strong></p>
</div>`;
box.style.display = 'block';
showToast('Analiz tamamlandı ✅', 'success');
Expand Down
Loading
Loading