diff --git a/.planning/STATE.md b/.planning/STATE.md index 104899f..e2a360a 100755 --- a/.planning/STATE.md +++ b/.planning/STATE.md @@ -11,29 +11,29 @@ See: .planning/PROJECT.md (updated 2026-05-26) Phase: 3 of 4 (Master Data & Log entries) Plan: 3 of 3 in current phase -Status: Ready to plan -Last activity: 2026-05-27 — Plan 03-02 completed (Logbook entry lists, travel header cards, and Freshwater/Fuel auto-calculating consumption grids complete) +Status: Completed +Last activity: 2026-05-27 — Plan 03-03 completed (Logbook event records, browser Geolocation tracker, and OpenWeatherMap weather API integration complete) -Progress: [███████░░░] 70% +Progress: [████████░░] 80% ## Performance Metrics **Velocity:** -- Total plans completed: 3 +- Total plans completed: 8 - Average duration: 15 min -- Total execution time: 0.75 hours +- Total execution time: 2.0 hours **By Phase:** | Phase | Plans | Total | Avg/Plan | |-------|-------|-------|----------| -| 1. Foundation, Auth & E2E Crypto | 3/3 | - | - | -| 2. Sync Protocol & Multi-Logbooks | 0/2 | - | - | -| 3. Master Data & Log entries | 0/3 | - | - | +| 1. Foundation, Auth & E2E Crypto | 3/3 | Completed | - | +| 2. Sync Protocol & Multi-Logbooks | 2/2 | Completed | - | +| 3. Master Data & Log entries | 3/3 | Completed | - | | 4. CSV Export & UI Polish | 0/2 | - | - | **Recent Trend:** -- Last 5 plans: [01-01, 01-02, 01-03] +- Last 5 plans: [02-01, 02-02, 03-01, 03-02, 03-03] - Trend: Stable *Updated after each plan completion* diff --git a/client/src/App.css b/client/src/App.css index 313ce2f..5d8ac48 100644 --- a/client/src/App.css +++ b/client/src/App.css @@ -984,3 +984,78 @@ body { padding: 6px 8px; font-size: 14px; } + +/* Event Journal Styling */ +.events-scroll-container { + width: 100%; + overflow-x: auto; + background: rgba(11, 12, 16, 0.4); + border: 1px solid rgba(255, 255, 255, 0.06); + border-radius: 12px; + box-sizing: border-box; +} + +/* Custom Scrollbar for events container */ +.events-scroll-container::-webkit-scrollbar { + height: 6px; +} +.events-scroll-container::-webkit-scrollbar-track { + background: rgba(11, 12, 16, 0.2); +} +.events-scroll-container::-webkit-scrollbar-thumb { + background: rgba(255, 255, 255, 0.1); + border-radius: 3px; +} +.events-scroll-container::-webkit-scrollbar-thumb:hover { + background: rgba(255, 255, 255, 0.2); +} + +.events-table { + width: 100%; + border-collapse: collapse; + text-align: left; + font-size: 14px; +} + +.events-table th { + background: rgba(11, 12, 16, 0.8); + color: #fbbf24; + padding: 12px 16px; + font-weight: 600; + border-bottom: 1px solid rgba(212, 175, 55, 0.2); + white-space: nowrap; +} + +.events-table td { + padding: 12px 16px; + color: #e2e8f0; + border-bottom: 1px solid rgba(255, 255, 255, 0.05); + vertical-align: middle; +} + +.events-table tbody tr:hover { + background: rgba(255, 255, 255, 0.02); +} + +.table-weather-img { + width: 32px; + height: 32px; + display: block; + margin: -6px 0; + object-fit: contain; +} + +.remarks-td { + min-width: 160px; + max-width: 300px; + white-space: normal; + word-break: break-word; +} + +.font-mono { + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; +} + +.text-sm { + font-size: 12px; +} diff --git a/client/src/App.tsx b/client/src/App.tsx index 774bc48..a3dd1eb 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -6,6 +6,7 @@ import VesselForm from './components/VesselForm.tsx' import CrewForm from './components/CrewForm.tsx' import DeviationForm from './components/DeviationForm.tsx' import LogEntriesList from './components/LogEntriesList.tsx' +import SettingsForm from './components/SettingsForm.tsx' import { getActiveMasterKey, logoutUser } from './services/auth.js' import { startBackgroundSync, stopBackgroundSync, syncAllLogbooks } from './services/sync.js' import { Ship, LogOut, ChevronLeft, Users, Compass, FileText, Settings, Wifi, WifiOff } from 'lucide-react' @@ -195,11 +196,7 @@ function App() { )} {activeTab === 'settings' && ( -
- -

{t('nav.settings')}

-

Logbook sync properties, local cache maintenance, and CSV data tools are configured here.

-
+ )} diff --git a/client/src/components/LogEntryEditor.tsx b/client/src/components/LogEntryEditor.tsx index 3561d87..5bf6e19 100644 --- a/client/src/components/LogEntryEditor.tsx +++ b/client/src/components/LogEntryEditor.tsx @@ -4,7 +4,7 @@ import { db } from '../services/db.js' import { getActiveMasterKey } from '../services/auth.js' import { encryptJson, decryptJson } from '../services/crypto.js' import { syncLogbook } from '../services/sync.js' -import { FileText, Save, ChevronLeft, Check, Compass } from 'lucide-react' +import { FileText, Save, ChevronLeft, Check, Compass, Plus, Trash2, MapPin, CloudSun, Clock } from 'lucide-react' interface LogEntryEditorProps { entryId: string @@ -12,6 +12,25 @@ interface LogEntryEditorProps { onBack: () => void } +interface LogEvent { + time: string + mgk: string + rwk: string + windPressure: string + windDirection: string + windStrength: string + seaState: string + weatherIcon: string + current: string + heel: string + sailsOrMotor: string + logReading: string + distance: string + gpsLat: string + gpsLng: string + remarks: string +} + export default function LogEntryEditor({ entryId, logbookId, onBack }: LogEntryEditorProps) { const { t } = useTranslation() @@ -37,13 +56,32 @@ export default function LogEntryEditor({ entryId, logbookId, onBack }: LogEntryE const [signSkipper, setSignSkipper] = useState('') const [signCrew, setSignCrew] = useState('') - // Events (to preserve Plan 03-03 journal events when editing headers/consumption) - const [events, setEvents] = useState([]) + // Events list state + const [events, setEvents] = useState([]) + + // Add Event Form State + const [evTime, setEvTime] = useState('') + const [evMgk, setEvMgk] = useState('') + const [evRwk, setEvRwk] = useState('') + const [evWindPressure, setEvWindPressure] = useState('') + const [evWindDirection, setEvWindDirection] = useState('') + const [evWindStrength, setEvWindStrength] = useState('') + const [evSeaState, setEvSeaState] = useState('') + const [evWeatherIcon, setEvWeatherIcon] = useState('') + const [evCurrent, setEvCurrent] = useState('') + const [evHeel, setEvHeel] = useState('') + const [evSailsOrMotor, setEvSailsOrMotor] = useState('') + const [evLogReading, setEvLogReading] = useState('') + const [evDistance, setEvDistance] = useState('') + const [evGpsLat, setEvGpsLat] = useState('') + const [evGpsLng, setEvGpsLng] = useState('') + const [evRemarks, setEvRemarks] = useState('') const [loading, setLoading] = useState(false) const [saving, setSaving] = useState(false) const [success, setSuccess] = useState(false) const [error, setError] = useState(null) + const [weatherLoading, setWeatherLoading] = useState(false) // Auto-calculate Freshwater Consumption useEffect(() => { @@ -63,7 +101,7 @@ export default function LogEntryEditor({ entryId, logbookId, onBack }: LogEntryE setFuelConsumption(cons >= 0 ? String(cons) : '0') }, [fuelMorning, fuelRefilled, fuelEvening]) - // Load entry + // Load entry details useEffect(() => { async function loadEntry() { setLoading(true) @@ -108,6 +146,135 @@ export default function LogEntryEditor({ entryId, logbookId, onBack }: LogEntryE loadEntry() }, [entryId]) + const handleGetGps = () => { + if (!navigator.geolocation) { + alert('Geolocation is not supported by your browser') + return + } + + navigator.geolocation.getCurrentPosition( + (pos) => { + setEvGpsLat(pos.coords.latitude.toFixed(6)) + setEvGpsLng(pos.coords.longitude.toFixed(6)) + }, + (err) => { + console.error('GPS capturing failed:', err) + alert(`Failed to retrieve coordinates: ${err.message}`) + } + ) + } + + const handleFetchWeather = async () => { + if (!evGpsLat || !evGpsLng) { + alert(t('settings.gps_error')) + return + } + + const apiKey = localStorage.getItem('owm_api_key') + if (!apiKey) { + alert(t('settings.no_key')) + return + } + + setWeatherLoading(true) + try { + const res = await fetch( + `https://api.openweathermap.org/data/2.5/weather?lat=${evGpsLat}&lon=${evGpsLng}&appid=${apiKey}&units=metric` + ) + + if (!res.ok) throw new Error('Weather API rejected the request') + + const data = await res.json() + + // Convert wind speed m/s to Beaufort scale + const mps = data.wind.speed || 0 + let bft = 0 + if (mps < 0.3) bft = 0 + else if (mps < 1.6) bft = 1 + else if (mps < 3.4) bft = 2 + else if (mps < 5.5) bft = 3 + else if (mps < 8.0) bft = 4 + else if (mps < 10.8) bft = 5 + else if (mps < 13.9) bft = 6 + else if (mps < 17.2) bft = 7 + else if (mps < 20.8) bft = 8 + else if (mps < 24.5) bft = 9 + else if (mps < 28.5) bft = 10 + else if (mps < 32.7) bft = 11 + else bft = 12 + + setEvWindStrength(`${bft} Bft (${mps.toFixed(1)} m/s)`) + setEvWindPressure(String(data.main.pressure || '')) + + // Calculate wind compass direction sector + if (data.wind.deg !== undefined) { + const deg = data.wind.deg + const sectors = ['N', 'NNE', 'NE', 'ENE', 'E', 'ESE', 'SE', 'SSE', 'S', 'SSW', 'SW', 'WSW', 'W', 'WNW', 'NW', 'NNW'] + const index = Math.round(deg / 22.5) % 16 + setEvWindDirection(sectors[index]) + } + + if (data.weather && data.weather[0]) { + setEvWeatherIcon(data.weather[0].icon) + } + + alert(t('settings.weather_success')) + } catch (err) { + console.error('Weather prefilling failed:', err) + alert(t('settings.weather_error')) + } finally { + setWeatherLoading(false) + } + } + + const handleAddEvent = (e: React.FormEvent) => { + e.preventDefault() + if (!evTime) return + + const newEvent: LogEvent = { + time: evTime, + mgk: evMgk.trim(), + rwk: evRwk.trim(), + windPressure: evWindPressure.trim(), + windDirection: evWindDirection.trim(), + windStrength: evWindStrength.trim(), + seaState: evSeaState.trim(), + weatherIcon: evWeatherIcon.trim(), + current: evCurrent.trim(), + heel: evHeel.trim(), + sailsOrMotor: evSailsOrMotor.trim(), + logReading: evLogReading.trim(), + distance: evDistance.trim(), + gpsLat: evGpsLat.trim(), + gpsLng: evGpsLng.trim(), + remarks: evRemarks.trim() + } + + setEvents((prev) => [...prev, newEvent]) + + // Clear event form fields + setEvTime('') + setEvMgk('') + setEvRwk('') + setEvWindPressure('') + setEvWindDirection('') + setEvWindStrength('') + setEvSeaState('') + setEvWeatherIcon('') + setEvCurrent('') + setEvHeel('') + setEvSailsOrMotor('') + setEvLogReading('') + setEvDistance('') + setEvGpsLat('') + setEvGpsLng('') + setEvRemarks('') + } + + const handleDeleteEvent = (index: number) => { + setEvents((prev) => prev.filter((_, idx) => idx !== index)) + } + const handleSubmit = async (e: React.FormEvent) => { e.preventDefault() setSaving(true) @@ -189,76 +356,93 @@ export default function LogEntryEditor({ entryId, logbookId, onBack }: LogEntryE } return ( -
-
- -
- -

{t('logs.new_entry')} / {dayOfTravel}

+
+ {/* Top Header Controls */} +
+
+ +
+ +

+ {t('logs.route')}: {departure || '...'} → {destination || '...'} (Tag {dayOfTravel}) +

+
- {error &&
{error}
} + {error &&
{error}
} + {/* Main Journal Data Forms */}
{/* Section 1: Travel Day Headers */} -
-
- - setDate(e.target.value)} - disabled={saving} - required - /> +
+
+ +

Travel Details

+
+
+ + setDate(e.target.value)} + disabled={saving} + required + /> +
-
- - setDayOfTravel(e.target.value)} - disabled={saving} - required - /> -
+
+ + setDayOfTravel(e.target.value)} + disabled={saving} + required + /> +
-
- - setDeparture(e.target.value)} - disabled={saving} - /> -
+
+ + setDeparture(e.target.value)} + disabled={saving} + /> +
-
- - setDestination(e.target.value)} - disabled={saving} - /> +
+ + setDestination(e.target.value)} + disabled={saving} + /> +
{/* Section 2: Freshwater and Fuel Consumption */} -
+
{/* Freshwater card */} -
-

{t('logs.freshwater')}

+
+
+ +

{t('logs.freshwater')}

+
@@ -300,7 +484,7 @@ export default function LogEntryEditor({ entryId, logbookId, onBack }: LogEntryE {/* Fuel card */} -
-

{t('logs.fuel')}

+
+
+ +

{t('logs.fuel')}

+
@@ -353,7 +540,7 @@ export default function LogEntryEditor({ entryId, logbookId, onBack }: LogEntryE
- {/* Section 3: Sign-Off Signatures */} -
-

{t('logs.signatures')}

+ {/* Section 3: Event Journal Entries */} +
+
+ +

{t('logs.event_title')}

+
+ + {/* List existing events */} + {events.length === 0 ? ( +
{t('logs.no_events')}
+ ) : ( +
+ + + + + + + + + + + + + + + + + + {events.map((ev, idx) => ( + + + + + + + + + + + + + + ))} + +
{t('logs.event_time')}{t('logs.event_mgk')}{t('logs.event_rwk')}{t('logs.event_wind_direction')}{t('logs.event_wind_strength')}{t('logs.event_sea_state')}{t('logs.event_weather')}{t('logs.event_log')}{t('logs.event_gps')}{t('logs.event_remarks')}
{ev.time}{ev.mgk ? `${ev.mgk}°` : '—'}{ev.rwk ? `${ev.rwk}°` : '—'}{ev.windDirection || '—'}{ev.windStrength || '—'}{ev.seaState || '—'} + {ev.weatherIcon ? ( + Weather + ) : ( + '—' + )} + {ev.logReading ? `${ev.logReading} nm` : '—'} + {ev.gpsLat && ev.gpsLng ? `${ev.gpsLat}, ${ev.gpsLng}` : '—'} + {ev.remarks} + +
+
+ )} + + {/* Add New Event Form Sub-Card */} +
+

Add Event Log Record

+ +
+
+ + setEvTime(e.target.value)} + disabled={saving} + /> +
+ +
+ + setEvMgk(e.target.value)} + disabled={saving} + /> +
+ +
+ + setEvRwk(e.target.value)} + disabled={saving} + /> +
+ +
+ + setEvLogReading(e.target.value)} + disabled={saving} + /> +
+
+ +
+
+ +
+ setEvGpsLat(e.target.value)} + disabled={saving} + /> + setEvGpsLng(e.target.value)} + disabled={saving} + /> + + +
+
+ +
+ + setEvWindDirection(e.target.value)} + disabled={saving || weatherLoading} + /> +
+
+ +
+
+ + setEvWindStrength(e.target.value)} + disabled={saving || weatherLoading} + /> +
+ +
+ + setEvWindPressure(e.target.value)} + disabled={saving || weatherLoading} + /> +
+ +
+ + setEvSeaState(e.target.value)} + disabled={saving} + /> +
+ +
+ + setEvHeel(e.target.value)} + disabled={saving} + /> +
+
+ +
+
+ + setEvSailsOrMotor(e.target.value)} + disabled={saving} + /> +
+ +
+ + setEvDistance(e.target.value)} + disabled={saving} + /> +
+ +
+ + setEvRemarks(e.target.value)} + disabled={saving} + /> +
+
+ + +
+
+ + {/* Section 4: Sign-Off Signatures */} +
+
+ +

{t('logs.signatures')}

+
@@ -395,16 +864,7 @@ export default function LogEntryEditor({ entryId, logbookId, onBack }: LogEntryE
- {/* Section 4: Logbook journal events placeholder */} -
-
- - - Journal Events and GPS tracking coordinates will be configured here in Plan 03-03. - -
-
- + {/* Save Controls */}
{success && (
diff --git a/client/src/components/SettingsForm.tsx b/client/src/components/SettingsForm.tsx new file mode 100644 index 0000000..884816d --- /dev/null +++ b/client/src/components/SettingsForm.tsx @@ -0,0 +1,77 @@ +import React, { useState } from 'react' +import { useTranslation } from 'react-i18next' +import { Settings, Save, Check } from 'lucide-react' + +export default function SettingsForm() { + const { t } = useTranslation() + const [apiKey, setApiKey] = useState(localStorage.getItem('owm_api_key') || '') + const [saving, setSaving] = useState(false) + const [success, setSuccess] = useState(false) + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault() + setSaving(true) + setSuccess(false) + + // Save to localStorage + localStorage.setItem('owm_api_key', apiKey.trim()) + + setSaving(false) + setSuccess(true) + setTimeout(() => setSuccess(false), 3000) + } + + return ( +
+
+ +
+

{t('settings.title')}

+

+ {t('settings.subtitle')} +

+
+
+ + +
+

+ {t('settings.owm_title')} +

+

+ {t('settings.key_help')} +

+ +
+ + setApiKey(e.target.value)} + disabled={saving} + /> +
+
+ +
+ {success && ( +
+ + {t('settings.saved')} +
+ )} + + +
+ +
+ ) +} diff --git a/client/src/i18n/locales/de.json b/client/src/i18n/locales/de.json index 8f9cced..d840ad3 100644 --- a/client/src/i18n/locales/de.json +++ b/client/src/i18n/locales/de.json @@ -67,7 +67,25 @@ "saved": "Logbuchseite erfolgreich gespeichert!", "loading": "Journal wird geladen...", "delete_entry": "Tag löschen", - "delete_confirm": "Sind Sie sicher, dass Sie diesen Reisetag unwiderruflich löschen möchten?" + "delete_confirm": "Sind Sie sicher, dass Sie diesen Reisetag unwiderruflich löschen möchten?", + "event_title": "Chronologisches Ereignisprotokoll", + "no_events": "Noch keine Ereignisse für diesen Reisetag eingetragen.", + "event_time": "Uhrzeit", + "event_mgk": "MgK Kurs", + "event_rwk": "RwK Kurs", + "event_wind_direction": "Wind-Richtung", + "event_wind_strength": "Windstärke", + "event_sea_state": "Seegang", + "event_weather": "Wetter", + "event_log": "Logge (sm)", + "event_gps": "GPS-Position", + "event_remarks": "Bemerkungen / Vorkommnisse", + "gps_btn": "GPS-Koordinaten abrufen", + "weather_btn": "OpenWeatherMap Wetter abrufen", + "event_wind_pressure": "Luftdruck (hPa)", + "event_heel": "Krängung (°)", + "event_sails": "Segelführung / Motor", + "event_distance": "Distanz (sm)" }, "dashboard": { "title": "Ihre Logbücher", @@ -113,6 +131,20 @@ "saving": "Wird gespeichert...", "saved": "Kalibrierungsgitter erfolgreich gespeichert!", "loading": "Kalibrierungstabelle wird geladen..." + }, + "settings": { + "title": "Systemeinstellungen", + "subtitle": "Konfigurieren Sie externe Integrationen und Anmeldedaten.", + "owm_title": "Wetter-Integration", + "owm_key": "OpenWeatherMap API-Schlüssel", + "save": "Konfiguration speichern", + "saving": "Wird gespeichert...", + "saved": "Einstellungen erfolgreich gespeichert!", + "key_help": "Ein API-Schlüssel wird benötigt, um Wetterparameter und Seebedingungen automatisch anhand von GPS-Koordinaten abzurufen.", + "no_key": "Bitte hinterlegen Sie Ihren OpenWeatherMap API-Schlüssel in den Einstellungen, um Wetterdaten abzurufen.", + "weather_success": "Wetterdaten erfolgreich abgerufen!", + "weather_error": "Wetterdatenabruf fehlgeschlagen. Überprüfen Sie den API-Schlüssel und die Verbindung.", + "gps_error": "Bitte ermitteln Sie zuerst die GPS-Koordinaten." } } } diff --git a/client/src/i18n/locales/en.json b/client/src/i18n/locales/en.json index 2fdf518..56bcfb3 100644 --- a/client/src/i18n/locales/en.json +++ b/client/src/i18n/locales/en.json @@ -67,7 +67,25 @@ "saved": "Logbook page saved successfully!", "loading": "Loading journal...", "delete_entry": "Delete Day", - "delete_confirm": "Are you sure you want to permanently delete this travel day?" + "delete_confirm": "Are you sure you want to permanently delete this travel day?", + "event_title": "Chronological Event Logbook", + "no_events": "No events logged for this travel day yet.", + "event_time": "Time", + "event_mgk": "MgK Course", + "event_rwk": "RwK Course", + "event_wind_direction": "Wind Dir", + "event_wind_strength": "Wind Str", + "event_sea_state": "Sea State", + "event_weather": "Weather", + "event_log": "Log (nm)", + "event_gps": "GPS Position", + "event_remarks": "Remarks / Events", + "gps_btn": "Get GPS Location", + "weather_btn": "Fetch OpenWeatherMap Weather", + "event_wind_pressure": "Barometer (hPa)", + "event_heel": "Heel Angle (°)", + "event_sails": "Sails / Motor Status", + "event_distance": "Distance (nm)" }, "dashboard": { "title": "Your Logbooks", @@ -113,6 +131,20 @@ "saving": "Saving...", "saved": "Calibration grid saved successfully!", "loading": "Loading calibration table..." + }, + "settings": { + "title": "System Settings", + "subtitle": "Configure external integrations and client credentials.", + "owm_title": "Weather Integration", + "owm_key": "OpenWeatherMap API Key", + "save": "Save Configuration", + "saving": "Saving...", + "saved": "Settings saved successfully!", + "key_help": "An API key is required to automatically fetch real-time weather and sea state parameters based on your vessel's GPS coordinates.", + "no_key": "Please set your OpenWeatherMap API Key in settings to enable weather auto-fill.", + "weather_success": "Weather details fetched successfully!", + "weather_error": "Failed to fetch weather. Check your API key and connection.", + "gps_error": "Please fetch GPS coordinates first." } } }