From 2c1047e624037293ba74701b22cd9b558eb6dddb Mon Sep 17 00:00:00 2001 From: florianfederspiel Date: Tue, 8 Sep 2026 23:08:36 +0200 Subject: [PATCH 1/9] KI-AGENT: Kalender, Terminbearbeitung und Dateivorschau verbessern --- mobile/app/(tabs)/index.tsx | 133 ++++++++++++++++++- mobile/app/more/customer/[id].tsx | 17 ++- mobile/app/more/event/[id].tsx | 63 ++++++++- mobile/app/more/events.tsx | 2 + mobile/app/more/plant/[id].tsx | 17 ++- mobile/app/project/[id].tsx | 17 ++- mobile/components/event-create-modal.tsx | 157 +++++++++++++++++------ mobile/package-lock.json | 10 ++ mobile/package.json | 1 + mobile/src/lib/api.ts | 18 ++- mobile/src/lib/file-opening.ts | 29 +++++ 11 files changed, 390 insertions(+), 74 deletions(-) diff --git a/mobile/app/(tabs)/index.tsx b/mobile/app/(tabs)/index.tsx index 5f813d78..f8d84fca 100644 --- a/mobile/app/(tabs)/index.tsx +++ b/mobile/app/(tabs)/index.tsx @@ -2,7 +2,8 @@ import { useCallback, useEffect, useMemo, useState } from 'react'; import { ActivityIndicator, Pressable, RefreshControl, ScrollView, StyleSheet, Text, View } from 'react-native'; import { router } from 'expo-router'; -import { fetchStaffTimeSpans, fetchTasks, Task } from '@/src/lib/api'; +import { EventCreateModal } from '@/components/event-create-modal'; +import { CalendarEvent, fetchEvents, fetchProjects, fetchStaffTimeSpans, fetchTasks, Project, Task } from '@/src/lib/api'; import { useAuth } from '@/src/providers/auth-provider'; const PRIMARY = '#69c350'; @@ -38,12 +39,25 @@ function formatDateTime(value: string | null): string { }); } +function dateKey(date: Date): string { + return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`; +} + +function formatEventTime(value: string): string { + const date = new Date(value); + return Number.isNaN(date.getTime()) ? '-' : date.toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' }); +} + export default function DashboardScreen() { const { token, user } = useAuth(); const [loading, setLoading] = useState(true); const [refreshing, setRefreshing] = useState(false); const [error, setError] = useState(null); + const [events, setEvents] = useState([]); + const [projects, setProjects] = useState([]); + const [createEventOpen, setCreateEventOpen] = useState(false); + const [selectedCalendarDate, setSelectedCalendarDate] = useState(() => new Date()); const [data, setData] = useState({ tasks: [], openTasks: 0, @@ -62,9 +76,11 @@ export default function DashboardScreen() { setError(null); try { - const [taskRows, spans] = await Promise.all([ + const [taskRows, spans, eventRows, projectRows] = await Promise.all([ fetchTasks(token), fetchStaffTimeSpans(token, currentUserId), + fetchEvents(token), + fetchProjects(token), ]); const tasks = taskRows || []; @@ -92,6 +108,8 @@ export default function DashboardScreen() { pendingSubmissions, todayMinutes, }); + setEvents(eventRows); + setProjects(projectRows); } catch (err) { setError(err instanceof Error ? err.message : 'Dashboard konnte nicht geladen werden.'); } finally { @@ -112,6 +130,26 @@ export default function DashboardScreen() { await loadDashboard(false); } + const calendarDays = useMemo(() => { + const start = new Date(); + start.setHours(0, 0, 0, 0); + return Array.from({ length: 7 }, (_, index) => { + const date = new Date(start); + date.setDate(start.getDate() + index); + return date; + }); + }, []); + + const selectedEvents = useMemo(() => { + const selectedKey = dateKey(selectedCalendarDate); + return events + .filter((event) => { + const start = new Date(event.startDate); + return !Number.isNaN(start.getTime()) && dateKey(start) === selectedKey; + }) + .sort((a, b) => new Date(a.startDate).getTime() - new Date(b.startDate).getTime()); + }, [events, selectedCalendarDate]); + return ( + + + + Kalender + + {selectedCalendarDate.toLocaleDateString('de-DE', { weekday: 'long', day: '2-digit', month: 'long' })} + + + router.push('/more/calendar' as any)}> + Monat öffnen + + + + + {calendarDays.map((date) => { + const key = dateKey(date); + const selected = key === dateKey(selectedCalendarDate); + const count = events.filter((event) => { + const start = new Date(event.startDate); + return !Number.isNaN(start.getTime()) && dateKey(start) === key; + }).length; + return ( + setSelectedCalendarDate(date)}> + + {date.toLocaleDateString('de-DE', { weekday: 'short' }).replace('.', '')} + + {date.getDate()} + 0 ? styles.calendarDotActive : null]} /> + + ); + })} + + + {selectedEvents.length === 0 ? Keine Termine an diesem Tag. : null} + {selectedEvents.slice(0, 3).map((event) => ( + router.push(`/more/event/${event.id}` as any)}> + + {formatEventTime(event.startDate)} + {event.name} + {event.state === 'Entwurf' ? Entwurf : null} + › + + ))} + setCreateEventOpen(true)}> + + Termin eintragen + + + Schnellzugriff @@ -175,6 +267,14 @@ export default function DashboardScreen() { + + setCreateEventOpen(false)} + onCreated={() => loadDashboard(false)} + /> ) : null} @@ -232,6 +332,35 @@ const styles = StyleSheet.create({ padding: 12, gap: 10, }, + calendarCard: { + backgroundColor: '#ffffff', + borderRadius: 12, + borderWidth: 1, + borderColor: '#e5e7eb', + padding: 12, + gap: 10, + }, + calendarHeader: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', gap: 8 }, + calendarTitle: { color: '#111827', fontSize: 16, fontWeight: '700' }, + calendarSubtitle: { color: '#6b7280', fontSize: 12, marginTop: 2 }, + calendarLink: { color: '#3d7a30', fontSize: 12, fontWeight: '700' }, + calendarDays: { flexDirection: 'row', gap: 4 }, + calendarDay: { flex: 1, minHeight: 62, borderRadius: 9, alignItems: 'center', justifyContent: 'center', gap: 2 }, + calendarDaySelected: { backgroundColor: PRIMARY }, + calendarWeekday: { color: '#6b7280', fontSize: 10, fontWeight: '600', textTransform: 'uppercase' }, + calendarDayNumber: { color: '#111827', fontSize: 16, fontWeight: '700' }, + calendarDayTextSelected: { color: '#ffffff' }, + calendarDot: { width: 5, height: 5, borderRadius: 3, backgroundColor: 'transparent' }, + calendarDotActive: { backgroundColor: '#f59e0b' }, + calendarEmpty: { color: '#6b7280', fontSize: 13, paddingVertical: 4 }, + calendarEvent: { minHeight: 44, flexDirection: 'row', alignItems: 'center', borderWidth: 1, borderColor: '#e5e7eb', borderRadius: 9, overflow: 'hidden' }, + calendarEventMark: { width: 4, alignSelf: 'stretch' }, + calendarEventTime: { color: '#3d7a30', fontSize: 12, fontWeight: '700', paddingHorizontal: 8 }, + calendarEventName: { flex: 1, color: '#111827', fontSize: 13, fontWeight: '600' }, + calendarEventDraft: { color: '#92400e', backgroundColor: '#fef3c7', borderRadius: 999, paddingHorizontal: 7, paddingVertical: 3, fontSize: 10, fontWeight: '700' }, + calendarEventArrow: { color: '#9ca3af', fontSize: 21, paddingHorizontal: 7 }, + calendarAddButton: { minHeight: 40, borderRadius: 9, backgroundColor: PRIMARY, alignItems: 'center', justifyContent: 'center' }, + calendarAddButtonText: { color: '#ffffff', fontSize: 13, fontWeight: '700' }, quickActionsTitle: { color: '#111827', fontSize: 15, diff --git a/mobile/app/more/customer/[id].tsx b/mobile/app/more/customer/[id].tsx index cba4b28b..e55de140 100644 --- a/mobile/app/more/customer/[id].tsx +++ b/mobile/app/more/customer/[id].tsx @@ -14,7 +14,6 @@ import { } from 'react-native'; import { router, useLocalSearchParams } from 'expo-router'; import * as DocumentPicker from 'expo-document-picker'; -import * as WebBrowser from 'expo-web-browser'; import { BarcodeScanningResult, BarcodeType, CameraView, useCameraPermissions } from 'expo-camera'; import { HistorySection } from '@/components/history-section'; @@ -32,7 +31,7 @@ import { uploadCustomerFile, } from '@/src/lib/api'; import { useAuth } from '@/src/providers/auth-provider'; -import { getOpenableFileUrl } from '@/src/lib/file-opening'; +import { openAuthenticatedFile } from '@/src/lib/file-opening'; const PRIMARY = '#69c350'; @@ -276,13 +275,13 @@ export default function CustomerDetailScreen() { } async function onOpenFile(file: ProjectFile) { - if (!file.url) return; - await WebBrowser.openBrowserAsync(getOpenableFileUrl(file.url), { - presentationStyle: WebBrowser.WebBrowserPresentationStyle.FORM_SHEET, - controlsColor: PRIMARY, - showTitle: true, - enableDefaultShareMenuItem: true, - }); + if (!token || !file.url) return; + setError(null); + try { + await openAuthenticatedFile(file, token); + } catch (err) { + setError(err instanceof Error ? err.message : 'Das Dokument konnte nicht geöffnet werden.'); + } } async function onPickAndUpload() { diff --git a/mobile/app/more/event/[id].tsx b/mobile/app/more/event/[id].tsx index 3146996c..15e75582 100644 --- a/mobile/app/more/event/[id].tsx +++ b/mobile/app/more/event/[id].tsx @@ -3,8 +3,9 @@ import { ActivityIndicator, Pressable, RefreshControl, ScrollView, StyleSheet, T import { useLocalSearchParams } from 'expo-router'; import * as WebBrowser from 'expo-web-browser'; +import { EventCreateModal } from '@/components/event-create-modal'; import { HistorySection } from '@/components/history-section'; -import { CalendarEvent, fetchEventById } from '@/src/lib/api'; +import { CalendarEvent, EventState, fetchEventById, fetchProjects, Project, updateEvent } from '@/src/lib/api'; import { useAuth } from '@/src/providers/auth-provider'; const PRIMARY = '#69c350'; @@ -37,6 +38,9 @@ export default function EventDetailScreen() { const eventId = Number(params.id); const { token } = useAuth(); const [event, setEvent] = useState(null); + const [projects, setProjects] = useState([]); + const [editOpen, setEditOpen] = useState(false); + const [updatingState, setUpdatingState] = useState(false); const [loading, setLoading] = useState(true); const [refreshing, setRefreshing] = useState(false); const [error, setError] = useState(null); @@ -47,7 +51,12 @@ export default function EventDetailScreen() { if (showSpinner) setLoading(true); setError(null); try { - setEvent(await fetchEventById(token, eventId)); + const [eventRow, projectRows] = await Promise.all([ + fetchEventById(token, eventId), + fetchProjects(token), + ]); + setEvent(eventRow); + setProjects(projectRows); } catch (err) { setError(err instanceof Error ? err.message : 'Der Termin konnte nicht geladen werden.'); } finally { @@ -72,13 +81,26 @@ export default function EventDetailScreen() { await WebBrowser.openBrowserAsync(url, { controlsColor: PRIMARY, showTitle: true }); } + async function changeState(nextState: EventState) { + if (!token || !event || updatingState || event.state === nextState) return; + setUpdatingState(true); + setError(null); + try { + const updated = await updateEvent(token, eventId, { state: nextState }); + setEvent((current) => current ? { ...current, state: updated.state } : updated); + } catch (err) { + setError(err instanceof Error ? err.message : 'Der Status konnte nicht geändert werden.'); + } finally { + setUpdatingState(false); + } + } + const rows = event ? [ { label: 'Beginn', value: formatDateTime(event.startDate) }, { label: 'Ende', value: formatDateTime(event.endDate) }, { label: 'Projekt', value: relationName(event.project) }, { label: 'Kunde', value: relationName(event.customer) }, - { label: 'Status', value: String(event.state || '-') }, { label: 'Wiederholung', value: String(event.repeatInterval || '-') }, ] : []; @@ -97,6 +119,25 @@ export default function EventDetailScreen() { {event.name} + setEditOpen(true)}> + Bearbeiten + + + + Status + + {(['Entwurf', 'Final'] as EventState[]).map((option) => ( + changeState(option)}> + + {option} + + + ))} + {rows.map((row) => ( @@ -110,6 +151,13 @@ export default function EventDetailScreen() { {event.link ? Link öffnen : null} + setEditOpen(false)} + onUpdated={() => load(false)} + /> ) : null} @@ -122,6 +170,15 @@ const styles = StyleSheet.create({ headingRow: { flexDirection: 'row', alignItems: 'center', gap: 9 }, colorMark: { width: 6, height: 28, borderRadius: 3 }, title: { flex: 1, color: '#111827', fontSize: 19, fontWeight: '700' }, + editButton: { borderRadius: 8, backgroundColor: PRIMARY, paddingHorizontal: 10, paddingVertical: 7 }, + editButtonText: { color: '#ffffff', fontSize: 12, fontWeight: '700' }, + statusSection: { gap: 6 }, + statusLabel: { color: '#6b7280', fontSize: 12, textTransform: 'uppercase', fontWeight: '600' }, + statusRow: { flexDirection: 'row', gap: 8 }, + statusButton: { flex: 1, minHeight: 40, borderRadius: 9, borderWidth: 1, borderColor: '#d1d5db', alignItems: 'center', justifyContent: 'center' }, + statusButtonActive: { borderColor: PRIMARY, backgroundColor: '#eff9ea' }, + statusButtonText: { color: '#6b7280', fontSize: 13, fontWeight: '700' }, + statusButtonTextActive: { color: '#3d7a30' }, table: { borderWidth: 1, borderColor: '#e5e7eb', borderRadius: 10, overflow: 'hidden' }, row: { borderBottomWidth: 1, borderBottomColor: '#e5e7eb', paddingHorizontal: 10, paddingVertical: 8, gap: 2 }, label: { color: '#6b7280', fontSize: 12, textTransform: 'uppercase', fontWeight: '600' }, diff --git a/mobile/app/more/events.tsx b/mobile/app/more/events.tsx index f2c849cb..5ebdc7d5 100644 --- a/mobile/app/more/events.tsx +++ b/mobile/app/more/events.tsx @@ -134,6 +134,7 @@ export default function EventsScreen() { {projectName(event) ? {projectName(event)} : null} {event.notes ? {String(event.notes)} : null} + {event.state === 'Entwurf' ? Entwurf : null} › )) @@ -183,6 +184,7 @@ const styles = StyleSheet.create({ eventDate: { color: '#374151', fontSize: 13 }, eventProject: { color: '#3d7a30', fontSize: 12, fontWeight: '600' }, eventNotes: { color: '#6b7280', fontSize: 12 }, + eventDraft: { alignSelf: 'center', color: '#92400e', backgroundColor: '#fef3c7', borderRadius: 999, paddingHorizontal: 8, paddingVertical: 4, fontSize: 10, fontWeight: '700' }, arrow: { alignSelf: 'center', color: '#9ca3af', fontSize: 25, paddingRight: 12 }, loadingBox: { paddingVertical: 24, alignItems: 'center', gap: 8 }, loadingText: { color: '#6b7280' }, diff --git a/mobile/app/more/plant/[id].tsx b/mobile/app/more/plant/[id].tsx index 05000af6..8a2d778c 100644 --- a/mobile/app/more/plant/[id].tsx +++ b/mobile/app/more/plant/[id].tsx @@ -3,12 +3,11 @@ import { ActivityIndicator, Pressable, RefreshControl, ScrollView, StyleSheet, T import { router, useLocalSearchParams } from 'expo-router'; import * as DocumentPicker from 'expo-document-picker'; import * as ImagePicker from 'expo-image-picker'; -import * as WebBrowser from 'expo-web-browser'; import { HistorySection } from '@/components/history-section'; import { fetchPlantById, fetchPlantFiles, Plant, ProjectFile, uploadPlantFile } from '@/src/lib/api'; import { useAuth } from '@/src/providers/auth-provider'; -import { getOpenableFileUrl } from '@/src/lib/file-opening'; +import { openAuthenticatedFile } from '@/src/lib/file-opening'; const PRIMARY = '#69c350'; @@ -90,13 +89,13 @@ export default function PlantDetailScreen() { } async function onOpenFile(file: ProjectFile) { - if (!file.url) return; - await WebBrowser.openBrowserAsync(getOpenableFileUrl(file.url), { - presentationStyle: WebBrowser.WebBrowserPresentationStyle.FORM_SHEET, - controlsColor: PRIMARY, - showTitle: true, - enableDefaultShareMenuItem: true, - }); + if (!token || !file.url) return; + setError(null); + try { + await openAuthenticatedFile(file, token); + } catch (err) { + setError(err instanceof Error ? err.message : 'Das Dokument konnte nicht geöffnet werden.'); + } } async function onPickAndUpload() { diff --git a/mobile/app/project/[id].tsx b/mobile/app/project/[id].tsx index 240fc2ae..7b9d65cd 100644 --- a/mobile/app/project/[id].tsx +++ b/mobile/app/project/[id].tsx @@ -15,7 +15,6 @@ import { import { router, useLocalSearchParams } from 'expo-router'; import * as DocumentPicker from 'expo-document-picker'; import * as ImagePicker from 'expo-image-picker'; -import * as WebBrowser from 'expo-web-browser'; import { EventCreateModal } from '@/components/event-create-modal'; import { HistorySection } from '@/components/history-section'; @@ -34,7 +33,7 @@ import { updateTask, } from '@/src/lib/api'; import { useAuth } from '@/src/providers/auth-provider'; -import { getOpenableFileUrl } from '@/src/lib/file-opening'; +import { openAuthenticatedFile } from '@/src/lib/file-opening'; const PRIMARY = '#69c350'; const TASK_STATUS_ORDER: TaskStatus[] = ['Offen', 'In Bearbeitung', 'Abgeschlossen']; @@ -193,13 +192,13 @@ export default function ProjectDetailScreen() { } async function onOpenFile(file: ProjectFile) { - if (!file.url) return; - await WebBrowser.openBrowserAsync(getOpenableFileUrl(file.url), { - presentationStyle: WebBrowser.WebBrowserPresentationStyle.FORM_SHEET, - controlsColor: PRIMARY, - showTitle: true, - enableDefaultShareMenuItem: true, - }); + if (!token || !file.url) return; + setError(null); + try { + await openAuthenticatedFile(file, token); + } catch (err) { + setError(err instanceof Error ? err.message : 'Das Dokument konnte nicht geöffnet werden.'); + } } async function onCompleteTask(task: Task) { diff --git a/mobile/components/event-create-modal.tsx b/mobile/components/event-create-modal.tsx index fafd7c77..e8c07d2c 100644 --- a/mobile/components/event-create-modal.tsx +++ b/mobile/components/event-create-modal.tsx @@ -2,6 +2,7 @@ import { useEffect, useMemo, useState } from 'react'; import DateTimePicker, { DateTimePickerEvent } from '@react-native-community/datetimepicker'; import { KeyboardAvoidingView, + Keyboard, Modal, Platform, Pressable, @@ -12,7 +13,7 @@ import { View, } from 'react-native'; -import { CalendarEvent, createEvent, Project } from '@/src/lib/api'; +import { CalendarEvent, createEvent, EventState, Project, updateEvent } from '@/src/lib/api'; import { useAuth } from '@/src/providers/auth-provider'; const PRIMARY = '#69c350'; @@ -22,8 +23,10 @@ type EventCreateModalProps = { projects?: Project[]; initialProjectId?: number | null; initialDate?: Date | null; + event?: CalendarEvent | null; onClose: () => void; - onCreated: (event: CalendarEvent) => void | Promise; + onCreated?: (event: CalendarEvent) => void | Promise; + onUpdated?: (event: CalendarEvent) => void | Promise; }; function pad(value: number): string { @@ -64,13 +67,16 @@ export function EventCreateModal({ projects = [], initialProjectId = null, initialDate = null, + event = null, onClose, onCreated, + onUpdated, }: EventCreateModalProps) { const { token } = useAuth(); const [name, setName] = useState(''); const [notes, setNotes] = useState(''); const [link, setLink] = useState(''); + const [state, setState] = useState('Final'); const [start, setStart] = useState(() => createDefaults(initialDate).start); const [end, setEnd] = useState(() => createDefaults(initialDate).end); const [activePicker, setActivePicker] = useState<'startDate' | 'startTime' | 'endDate' | 'endTime' | null>(null); @@ -82,16 +88,19 @@ export function EventCreateModal({ useEffect(() => { if (!visible) return; const defaults = createDefaults(initialDate); - setName(''); - setNotes(''); - setLink(''); - setStart(defaults.start); - setEnd(defaults.end); + const eventStart = event?.startDate ? new Date(event.startDate) : null; + const eventEnd = event?.endDate ? new Date(event.endDate) : null; + setName(event?.name || ''); + setNotes(String(event?.notes || '')); + setLink(String(event?.link || '')); + setState(event?.state === 'Entwurf' ? 'Entwurf' : 'Final'); + setStart(eventStart && !Number.isNaN(eventStart.getTime()) ? eventStart : defaults.start); + setEnd(eventEnd && !Number.isNaN(eventEnd.getTime()) ? eventEnd : defaults.end); setActivePicker(null); - setSelectedProjectId(initialProjectId); + setSelectedProjectId(relationId(event?.project) ?? initialProjectId); setProjectSearch(''); setError(null); - }, [initialDate, initialProjectId, visible]); + }, [event, initialDate, initialProjectId, visible]); const selectedProject = useMemo( () => projects.find((project) => Number(project.id) === selectedProjectId) || null, @@ -130,6 +139,11 @@ export function EventCreateModal({ } } + function openPicker(picker: 'startDate' | 'startTime' | 'endDate' | 'endTime') { + Keyboard.dismiss(); + setActivePicker(picker); + } + async function save() { if (!token || saving) return; const title = name.trim(); @@ -146,18 +160,22 @@ export function EventCreateModal({ setSaving(true); setError(null); try { - const event = await createEvent(token, { + const payload = { name: title, startDate: start.toISOString(), endDate: end.toISOString(), project: selectedProjectId, - customer: relationId(selectedProject?.customer), + customer: relationId(selectedProject?.customer) ?? relationId(event?.customer), notes: notes.trim() || null, link: link.trim() || null, - state: 'Final', - repeatInterval: 'Keine Wiederholung', - }); - await onCreated(event); + state, + repeatInterval: event?.repeatInterval || 'Keine Wiederholung', + }; + const savedEvent = event + ? await updateEvent(token, Number(event.id), payload) + : await createEvent(token, payload); + if (event) await onUpdated?.(savedEvent); + else await onCreated?.(savedEvent); onClose(); } catch (err) { setError(err instanceof Error ? err.message : 'Der Termin konnte nicht gespeichert werden.'); @@ -171,7 +189,7 @@ export function EventCreateModal({ - Neuer Termin + {event ? 'Termin bearbeiten' : 'Neuer Termin'} Beginn - setActivePicker('startDate')}> + openPicker('startDate')}> {formatDate(start)} - setActivePicker('startTime')}> + openPicker('startTime')}> {formatTime(start)} Ende - setActivePicker('endDate')}> + openPicker('endDate')}> {formatDate(end)} - setActivePicker('endTime')}> + openPicker('endTime')}> {formatTime(end)} - {activePicker ? ( - - - {Platform.OS === 'ios' ? ( - setActivePicker(null)}> - Übernehmen - - ) : null} - - ) : null} + Status + + {(['Entwurf', 'Final'] as EventState[]).map((option) => ( + setState(option)}> + + {option} + + + ))} + Projekt {selectedProject ? ( @@ -279,11 +293,45 @@ export function EventCreateModal({ Abbrechen - {saving ? 'Speichere...' : 'Anlegen'} + {saving ? 'Speichere...' : event ? 'Speichern' : 'Anlegen'} + + {activePicker && Platform.OS === 'ios' ? ( + + setActivePicker(null)} /> + + + + {activePicker.startsWith('start') ? 'Beginn' : 'Ende'} · {activePicker.endsWith('Date') ? 'Datum' : 'Uhrzeit'} + + setActivePicker(null)}> + Fertig + + + + + + ) : null} + + {activePicker && Platform.OS === 'android' ? ( + + ) : null} ); @@ -321,9 +369,38 @@ const styles = StyleSheet.create({ pickerButtonText: { color: '#111827', fontSize: 15, fontWeight: '600' }, dateInput: { flex: 1 }, timeInput: { width: 92 }, - pickerWrap: { borderWidth: 1, borderColor: '#e5e7eb', borderRadius: 10, padding: 8 }, - pickerDoneButton: { alignSelf: 'flex-end', paddingHorizontal: 10, paddingVertical: 7 }, + pickerOverlay: { ...StyleSheet.absoluteFillObject, justifyContent: 'flex-end', zIndex: 10 }, + pickerBackdrop: { ...StyleSheet.absoluteFillObject, backgroundColor: 'rgba(17, 24, 39, 0.25)' }, + pickerSheet: { + backgroundColor: '#ffffff', + borderTopLeftRadius: 18, + borderTopRightRadius: 18, + paddingHorizontal: 12, + paddingTop: 8, + paddingBottom: 22, + shadowColor: '#111827', + shadowOpacity: 0.2, + shadowRadius: 10, + shadowOffset: { width: 0, height: -4 }, + }, + pickerHeader: { minHeight: 42, flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between' }, + pickerTitle: { color: '#374151', fontSize: 14, fontWeight: '700' }, + pickerDoneButton: { paddingHorizontal: 10, paddingVertical: 7 }, pickerDoneText: { color: '#3d7a30', fontSize: 14, fontWeight: '700' }, + statusRow: { flexDirection: 'row', gap: 8 }, + statusButton: { + flex: 1, + minHeight: 42, + borderWidth: 1, + borderColor: '#d1d5db', + borderRadius: 10, + alignItems: 'center', + justifyContent: 'center', + backgroundColor: '#ffffff', + }, + statusButtonActive: { borderColor: PRIMARY, backgroundColor: '#eff9ea' }, + statusButtonText: { color: '#6b7280', fontSize: 14, fontWeight: '700' }, + statusButtonTextActive: { color: '#3d7a30' }, multiline: { minHeight: 90, textAlignVertical: 'top' }, selectedProject: { flexDirection: 'row', diff --git a/mobile/package-lock.json b/mobile/package-lock.json index 4d30e214..53d3fd61 100644 --- a/mobile/package-lock.json +++ b/mobile/package-lock.json @@ -27,6 +27,7 @@ "expo-router": "~6.0.24", "expo-secure-store": "^15.0.8", "expo-share-intent": "^5.1.1", + "expo-sharing": "~14.0.8", "expo-splash-screen": "~31.0.13", "expo-status-bar": "~3.0.9", "expo-symbols": "~1.0.8", @@ -6835,6 +6836,15 @@ "node": ">=10" } }, + "node_modules/expo-sharing": { + "version": "14.0.8", + "resolved": "https://registry.npmjs.org/expo-sharing/-/expo-sharing-14.0.8.tgz", + "integrity": "sha512-A1pPr2iBrxypFDCWVAESk532HK+db7MFXbvO2sCV9ienaFXAk7lIBm6bkqgE6vzRd9O3RGdEGzYx80cYlc089Q==", + "license": "MIT", + "peerDependencies": { + "expo": "*" + } + }, "node_modules/expo-splash-screen": { "version": "31.0.13", "resolved": "https://registry.npmjs.org/expo-splash-screen/-/expo-splash-screen-31.0.13.tgz", diff --git a/mobile/package.json b/mobile/package.json index d856f28e..6172915d 100644 --- a/mobile/package.json +++ b/mobile/package.json @@ -40,6 +40,7 @@ "expo-router": "~6.0.24", "expo-secure-store": "^15.0.8", "expo-share-intent": "^5.1.1", + "expo-sharing": "~14.0.8", "expo-splash-screen": "~31.0.13", "expo-status-bar": "~3.0.9", "expo-symbols": "~1.0.8", diff --git a/mobile/src/lib/api.ts b/mobile/src/lib/api.ts index 1f5ad587..ee0bbdec 100644 --- a/mobile/src/lib/api.ts +++ b/mobile/src/lib/api.ts @@ -54,13 +54,15 @@ export type Project = { [key: string]: unknown; }; +export type EventState = 'Entwurf' | 'Final'; + export type CalendarEvent = { id: number; name: string; startDate: string; endDate?: string | null; eventtype?: string | null; - state?: string | null; + state?: EventState | null; repeatInterval?: string | null; project?: number | { id?: number; name?: string } | null; customer?: number | { id?: number; name?: string } | null; @@ -724,7 +726,7 @@ export async function createEvent( notes?: string | null; link?: string | null; eventtype?: string; - state?: 'Entwurf' | 'Final'; + state?: EventState; repeatInterval?: string; } ): Promise { @@ -735,6 +737,18 @@ export async function createEvent( }); } +export async function updateEvent( + token: string, + eventId: number, + payload: Partial> +): Promise { + return apiRequest(`/api/resource/events/${eventId}`, { + method: 'PUT', + token, + body: payload, + }); +} + export async function fetchEventById(token: string, eventId: number): Promise { return apiRequest(`/api/resource/events/${eventId}`, { token }); } diff --git a/mobile/src/lib/file-opening.ts b/mobile/src/lib/file-opening.ts index 53dde3a9..a794cb17 100644 --- a/mobile/src/lib/file-opening.ts +++ b/mobile/src/lib/file-opening.ts @@ -1,3 +1,7 @@ +import { File, Paths } from 'expo-file-system'; +import * as Sharing from 'expo-sharing'; + +import type { ProjectFile } from '@/src/lib/api'; import { getApiBaseUrlSync } from '@/src/lib/server-config'; export function getOpenableFileUrl(rawUrl: string): string { @@ -7,3 +11,28 @@ export function getOpenableFileUrl(rawUrl: string): string { const path = rawUrl.startsWith('/') ? rawUrl : `/${rawUrl}`; return `${apiBaseUrl}${path}`; } + +function safeFilename(file: ProjectFile): string { + const fallback = `dokument-${file.id}`; + const rawName = String(file.name || file.path?.split('/').pop() || fallback); + return rawName.replace(/[^a-zA-Z0-9äöüÄÖÜß._-]/g, '_'); +} + +export async function openAuthenticatedFile(file: ProjectFile, token: string): Promise { + if (!file.url) throw new Error('Für dieses Dokument ist kein Download verfügbar.'); + + const target = new File(Paths.cache, `${file.id}-${safeFilename(file)}`); + const downloaded = await File.downloadFileAsync(getOpenableFileUrl(file.url), target, { + headers: { Authorization: `Bearer ${token}` }, + idempotent: true, + }); + + if (!(await Sharing.isAvailableAsync())) { + throw new Error('Die Dokumentvorschau ist auf diesem Gerät nicht verfügbar.'); + } + + await Sharing.shareAsync(downloaded.uri, { + mimeType: file.mimeType || undefined, + dialogTitle: safeFilename(file), + }); +} From aa08f5c358866351fa31da320ccd7ad7118d911a Mon Sep 17 00:00:00 2001 From: florianfederspiel Date: Tue, 8 Sep 2026 23:13:44 +0200 Subject: [PATCH 2/9] =?UTF-8?q?KI-AGENT:=20iOS-Buildnummer=20f=C3=BCr=20Te?= =?UTF-8?q?stFlight=20auf=2014=20erh=C3=B6hen?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- mobile/app.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mobile/app.json b/mobile/app.json index aa090212..d35b8795 100644 --- a/mobile/app.json +++ b/mobile/app.json @@ -11,7 +11,7 @@ "ios": { "supportsTablet": true, "bundleIdentifier": "software.federspiel.fedeo", - "buildNumber": "13", + "buildNumber": "14", "infoPlist": { "NSCameraUsageDescription": "Die Kamera wird benötigt, um Fotos zu Projekten und Objekten als Dokumente hochzuladen.", "NSPhotoLibraryUsageDescription": "Der Zugriff auf Fotos wird benötigt, um Bilder als Dokumente hochzuladen.", From 89185b4b086d782b59146c9c7cf45392b0078cc7 Mon Sep 17 00:00:00 2001 From: florianfederspiel Date: Sat, 12 Sep 2026 20:05:49 +0200 Subject: [PATCH 3/9] KI-AGENT: Direkte Teltonika-Fahrtenbucharchitektur konzipiert --- docs/fahrtenbuch-konzept.md | 309 ++++++++++++++++++++++++++++++++++++ 1 file changed, 309 insertions(+) create mode 100644 docs/fahrtenbuch-konzept.md diff --git a/docs/fahrtenbuch-konzept.md b/docs/fahrtenbuch-konzept.md new file mode 100644 index 00000000..afca86bb --- /dev/null +++ b/docs/fahrtenbuch-konzept.md @@ -0,0 +1,309 @@ +# Konzept: Fahrtenbuch mit direkter Teltonika-Anbindung + +## Zielbild + +Die Teltonika-OBD-II-Tracker senden ihre AVL-Daten direkt an FEDEO. Traccar entfällt vollständig aus der produktiven Kette. + +```text +Teltonika-Tracker + │ TCP/TLS, Codec 8 Extended + ▼ +FEDEO Tracking-Ingest + │ atomare Speicherung, danach ACK + ▼ +PostgreSQL ──► Fahrtbildungs-Worker ──► Fahrtenbuch-API ──► Nuxt-Oberfläche/PDF-Export +``` + +Die empfohlene Lösung besteht aus einem kleinen, eigenständigen Dienst `tracking-server`, den bestehenden FEDEO-Diensten und PostgreSQL. Der Empfangsdienst darf nicht Teil des HTTP-Prozesses sein: Teltonika verwendet langlebige TCP-Verbindungen und ein Binärprotokoll, während das bestehende Backend Fastify/HTTP bedient. Die getrennten Prozesse können unabhängig neu gestartet und skaliert werden. + +Ein zusätzlicher Message-Broker ist für die derzeit erkennbare Größenordnung nicht nötig. Der Tracker puffert Daten bei Verbindungsproblemen; PostgreSQL übernimmt die dauerhafte Übergabe an den Fahrtbildungs-Worker. + +## Annahmen und noch zu bestätigende Angaben + +- Mit „Teltonika OBD2“ ist voraussichtlich ein Gerät der FMx003-Familie gemeint, zum Beispiel FMB003, FMC003 oder FMM003. Vor der Implementierung müssen exaktes Modell, Firmwarestand und Mobilfunkvariante feststehen. +- Die Geräte werden auf **Codec 8 Extended über TCP/TLS** eingestellt. Teltonika empfiehlt Codec 8 Extended, weil nur damit auch AVL-IDs oberhalb von 255 und variable I/O-Werte vollständig übertragen werden können. +- Das Fahrtenbuch soll nicht nur eine Streckenübersicht, sondern bei Bedarf ein steuerlich nutzbares elektronisches Fahrtenbuch für Deutschland sein. +- Ein OBD-Tracker erkennt das Fahrzeug, aber nicht automatisch die fahrende Person. Ohne zusätzlichen BLE-/iButton-Fahrerschlüssel wird der im Fahrzeug hinterlegte Standardfahrer vorgeschlagen und muss gegebenenfalls geändert werden. + +## 1. Tracking-Ingest + +### Aufgabe + +Ein neues Paket beziehungsweise ein neuer Dienst `tracking-server/` wird in TypeScript implementiert. Er verwendet `node:tls` beziehungsweise während der lokalen Entwicklung `node:net` und übernimmt ausschließlich: + +1. TCP-Verbindung und IMEI-Handshake, +2. Prüfung, ob die IMEI einem aktiven FEDEO-Fahrzeug zugeordnet ist, +3. Framing und Parsen von Codec 8 (`0x08`) und Codec 8 Extended (`0x8E`), +4. Prüfung von Paketlänge, doppelter Datensatzanzahl und CRC-16/IBM, +5. normalisierte Speicherung aller AVL-Datensätze in einer Datenbanktransaktion, +6. Teltonika-ACK erst nach erfolgreichem Commit, +7. Aktualisierung von `last_seen_at` und einfachen Betriebsmetriken. + +Der produktive Gerätevertrag ist Codec 8 Extended. Codec 8 sollte der Parser trotzdem beherrschen, damit ein falsch oder noch nicht umkonfiguriertes Gerät sichtbar diagnostiziert werden kann. + +Bei Datenbankfehlern wird **kein** positiver ACK gesendet. Das Gerät behält die Datensätze und versucht die Übertragung erneut. Doppelte Zustellungen müssen durch einen fachlichen Idempotenzschlüssel wirkungslos bleiben. Unbekannte IMEIs werden abgewiesen und nur als begrenztes Sicherheitsereignis protokolliert. + +### Parser-Grenzen + +Der Binärparser darf nicht mit Fachlogik vermischt werden. Sein Ergebnis ist ein neutrales Objekt: + +```ts +type AvlRecord = { + recordedAt: Date + priority: number + longitude: number + latitude: number + altitude: number + angle: number + satellites: number + speedKmh: number + eventIoId: number + io: Record +} +``` + +Für die Fachlogik gibt es danach ein modell- und firmwareabhängiges I/O-Mapping. Relevante Standardwerte sind typischerweise: + +- AVL 239: Zündung, +- AVL 240: Bewegung, +- AVL 16: Teltonika-Gesamtkilometerzähler, +- AVL 389: OEM-Gesamtkilometerstand, +- AVL 256: VIN, +- AVL 134: Fahrtdistanz. + +Einheiten und Verfügbarkeit werden nicht im Parser fest verdrahtet, sondern über getestete Definitionen je Gerätefamilie normalisiert. Insbesondere der OEM-Kilometerstand ist vom unterstützten Fahrzeug und der auf das Gerät geladenen OBD-Konfiguration abhängig. + +### Sicherheit und Betrieb + +- Eigener TCP-Port, zum Beispiel `5027`, mit TLS 1.2; die konkrete TLS-Unterstützung muss für Modell und Firmware geprüft werden. +- IMEI-Zulassungsliste aus der Datenbank, Rate-Limit pro Quell-IP und harte Grenzen für Paketgröße und Verbindungsdauer. +- Keine Befehlssteuerung in Version 1. Codec 12/14 kann später getrennt ergänzt werden. +- Metriken: aktive Verbindungen, akzeptierte/abgelehnte Pakete, CRC-Fehler, unbekannte IMEIs, Verzögerung `received_at - recorded_at`, letzter Datensatz je Tracker und offene Fahrten. +- Alarm, wenn ein aktiver Tracker länger als eine konfigurierbare Zeit keine Daten liefert. + +## 2. Datenmodell + +Die vorhandene Tabelle `vehicles` bleibt die fachliche Fahrzeugquelle. Die generische Tabelle `devices` sollte nicht mit GPS-Sonderfeldern überladen werden. + +### `vehicle_trackers` + +Zuordnung und Gerätekonfiguration: + +- `id uuid`, `tenant bigint`, `vehicle bigint` +- `imei text unique`, `model text`, `firmware text` +- `protocol text`, `enabled boolean` +- `installed_at`, `removed_at`, `last_seen_at` +- `io_profile_version text` +- eindeutige aktive Zuordnung eines Trackers zu einem Fahrzeug + +Eine zeitliche Zuordnung ist wichtig: Beim Umbau eines Trackers dürfen alte Positionen nicht nachträglich dem neuen Fahrzeug zugerechnet werden. + +### `tracking_packets` + +Technische Nachvollziehbarkeit und gezielte Fehleranalyse: + +- Tracker, Empfangszeit, Codec, CRC, Datensatzanzahl +- SHA-256 des vollständigen Frames als Idempotenzschlüssel +- optionaler Rohframe als `bytea` mit kurzer, konfigurierbarer Aufbewahrung, zum Beispiel 30 oder 90 Tage +- Parse-Status und Fehlercode + +### `tracking_positions` + +Unveränderliche normalisierte Messpunkte: + +- Mandant, Tracker und die zum Messzeitpunkt gültige Fahrzeugzuordnung +- Gerätezeit `recorded_at` und Serverzeit `received_at` +- Koordinaten, Höhe, Richtung, Geschwindigkeit, Satelliten, Priorität +- normalisierte Werte `ignition`, `movement`, `odometer_m`, `oem_odometer_m` +- `event_io_id` und vollständige I/O-Werte als `jsonb` +- Qualitätsmerkmale wie `valid_gnss`, Zeitabweichung und Kilometerstandsquelle + +Eindeutigkeit entsteht aus Tracker, Gerätezeit und einem stabilen Datensatz-Fingerabdruck. Für größere Datenmengen kann die Tabelle später monatsweise nach `recorded_at` partitioniert werden. Indizes werden mindestens auf `(vehicle, recorded_at)` und `(tracker, recorded_at)` benötigt. + +### `tracking_trips` + +Das fachliche Fahrtenbuch: + +- Fahrzeug, Fahrer, Start/Ende und Start-/Zielkoordinaten +- Start-/Zieladresse als zum Abschlusszeitpunkt gespeicherter Text +- Kilometerstand Beginn/Ende, berechnete Distanz und jeweilige Quelle +- Klassifizierung `betrieblich`, `privat` oder `wohnung_erste_taetigkeitsstaette` +- Reisezweck, Kunde/Geschäftspartner sowie optionale FEDEO-Verknüpfungen zu Kunde, Projekt und Termin +- Status `offen`, `zu_pruefen`, `bestaetigt`, `gesperrt` +- Qualitätsstatus mit konkreten Hinweisen, zum Beispiel fehlender OEM-Kilometerstand, GPS-Lücke oder unplausibler Sprung +- Referenzen auf ersten und letzten Messpunkt + +### `tracking_trip_revisions` und `tracking_logbook_exports` + +Fahrten werden nie unbemerkt überschrieben. Jede Änderung erzeugt eine unveränderliche Revision mit altem und neuem Wert, Benutzer, Zeit, Änderungsgrund sowie einer Hash-Verkettung zur vorherigen Revision. Auch Zusammenführen, Teilen und Löschen sind fachliche Revisionen; ein physisches Löschen bestätigter Fahrten ist nicht erlaubt. + +Ein abgeschlossener Monats- oder Jahresexport speichert PDF, maschinenlesbare CSV/JSON-Daten und ein Manifest mit SHA-256-Prüfsummen. Der Export enthält die Änderungshistorie in gewöhnlich einsehbarer Form. „Gesperrt“ bedeutet: weitere Korrekturen erzeugen eine sichtbare Folgerevision und gegebenenfalls eine neue Exportversion. + +## 3. Fahrtbildung + +Die Fahrtbildung läuft als wiederholbarer Worker auf Ereigniszeit, nicht auf Empfangszeit. Das ist wichtig, weil Tracker bei Funklöchern ältere Datensätze nachliefern. + +### Zustandsautomat je Tracker + +```text +STEHT + └─ Zündung an + Bewegung/Geschwindigkeit ─► FÄHRT + +FÄHRT + ├─ weitere Punkte ────────────────────────► FÄHRT + └─ Zündung aus + Nachlaufzeit ────────────► STEHT + Fahrt abschließen +``` + +- Primäres Start-/Endesignal ist AVL 239 (Zündung), plausibilisiert mit Bewegung und Geschwindigkeit. +- Eine konfigurierbare Nachlaufzeit von zunächst 120 Sekunden verhindert Fahrtteilungen an Ampeln oder bei kurzen Signalwechseln. +- Eine Fahrt wird nicht sofort endgültig: Ein Watermark, zum Beispiel zehn Minuten nach dem letzten bekannten Gerätezeitpunkt, erlaubt verspätete Datensätze. Betroffene offene Zeitfenster werden deterministisch neu berechnet. +- Bei fehlendem Zündungssignal greift ein klar markierter Fallback aus Bewegung, Geschwindigkeit und Stillstandszeit. +- Kilometerstandsquelle in absteigender Qualität: OEM-Gesamtkilometerstand (AVL 389), kalibrierter Teltonika-Gesamtkilometerzähler (AVL 16), Fahrtdistanz/Positionssumme. FEDEO speichert Quelle und Abweichung, statt Werte still zu vermischen. +- Unplausible GNSS-Punkte werden nicht für die Distanz verwendet, bleiben aber unverändert gespeichert. +- Zeitrücksprünge, Kilometerzählersprünge, GPS-Lücken und Tracker-Ausbau erzeugen Prüffälle und niemals stillschweigend „saubere“ Fahrten. + +Die Zählerwerte müssen im Pilotfahrzeug gegen den Tachostand geprüft werden. Liefert ein Fahrzeug keinen OEM-Gesamtkilometerstand, wird einmalig ein dokumentierter Tachostand mit dem Teltonika-Zähler verknüpft. Der daraus entstehende Offset bleibt versioniert. + +## 4. Bedienung in FEDEO + +Der vorhandene, bislang nur konfigurierte Ressourcenschlüssel `trackingtrips` kann weiterverwendet werden. Für das Fahrtenbuch ist dennoch eine eigene Seite sinnvoller als die generische Standard-Entity-Maske. + +### Ansichten + +- **Fahrten:** chronologische Liste mit Fahrzeug, Fahrer, Start/Ziel, Zeit, Kilometerständen, Distanz, Klassifizierung, Zweck und Prüfstatus. +- **Prüfen:** schnelle Stapelbearbeitung aller offenen Fahrten; Vorschläge aus Kalender, Kunde/Projekt und wiederkehrenden Zielen. +- **Karte:** Route einer ausgewählten Fahrt, nicht standardmäßig die vollständige Mitarbeiterhistorie. +- **Fahrzeug:** Trackerstatus, letzter Empfang, Kilometerstandsqualität und offene Warnungen. +- **Export:** Monats-/Jahresabschluss, PDF-Vorschau und Prüfdateien. + +Eine betriebliche Fahrt verlangt Ziel, Reisezweck und Geschäftspartner beziehungsweise einen konkreten betrieblichen Vorgang. Privatfahrten speichern für normale Benutzer nur die steuerlich erforderlichen Werte; die Sichtbarkeit genauer Privat-Routen sollte über eine eigene Berechtigung begrenzt werden. + +### API-Schnittstellen + +Spezialisierte, mandantengeprüfte Routen sind der generischen Ressourcen-API vorzuziehen: + +- `GET /api/tracking/trips` +- `GET /api/tracking/trips/:id` +- `PATCH /api/tracking/trips/:id/classification` +- `POST /api/tracking/trips/:id/confirm` +- `POST /api/tracking/trips/:id/split` +- `POST /api/tracking/trips/merge` +- `GET /api/tracking/vehicles/:id/status` +- `POST /api/tracking/exports` +- `GET /api/tracking/exports/:id` + +Ändernde Aufrufe verlangen einen Änderungsgrund, sobald eine Fahrt bereits bestätigt war. Berechtigungen sollten mindestens `tracking.view`, `tracking.edit_own`, `tracking.edit_all`, `tracking.view_private_routes`, `tracking.manage_devices`, `tracking.lock` und `tracking.export` unterscheiden. + +## 5. Steuerliche Nachvollziehbarkeit und Datenschutz + +Für ein deutsches elektronisches Fahrtenbuch reichen GPS-Punkte allein nicht. Nach der Rechtsprechung müssen die Aufzeichnungen zeitnah, fortlaufend und in geschlossener Form vorliegen. Änderungen müssen technisch ausgeschlossen oder in ihrer Reichweite direkt im Fahrtenbuch erkennbar sein. Für betriebliche Fahrten gehören grundsätzlich Datum, Kilometerstand zu Beginn und Ende, Reiseziel, Reisezweck und aufgesuchte Geschäftspartner dazu. + +Deshalb sind folgende Punkte keine spätere Zusatzfunktion, sondern Bestandteil der ersten fachlich nutzbaren Version: + +- append-only Messdaten und Änderungshistorie, +- sichtbare Revisionsangaben im Fahrtenbuch und im Export, +- Erinnerung und Eskalation für zeitnahe Klassifizierung, +- Vollständigkeits- und Plausibilitätsprüfung pro Fahrzeug, +- reproduzierbare, versionierte Monatsabschlüsse, +- dokumentierte Verfahrensbeschreibung und Wiederherstellungstests. + +Das Konzept ist eine technische Auslegung, keine Zusicherung der steuerlichen Anerkennung. Export und Verfahrensdokumentation sollten vor dem produktiven steuerlichen Einsatz mit der Steuerberatung abgestimmt werden. + +Standortdaten von Beschäftigten erfordern zudem ein Rollen- und Löschkonzept, Zweckbindung, transparente Information und gegebenenfalls Beteiligung des Betriebsrats. Rohpunkte sollten nur so lange wie fachlich und rechtlich erforderlich gespeichert werden. Reverse-Geocoding erfolgt nur für Start und Ziel, asynchron und mit Cache; ein selbst betriebener Dienst oder ein vertraglich geprüfter Anbieter vermeidet unnötige Weitergabe vollständiger Bewegungsprofile. + +## 6. Gerätekonfiguration + +Für einen Pilot-Tracker wird ein versioniertes Teltonika-Konfigurationsprofil erstellt: + +- Hauptserver: FEDEO-Domain und Tracking-Port +- Transport: TCP mit TLS, sofern Modell/Firmware dies unterstützen +- Datenprotokoll: Codec 8 Extended +- Datenerfassung in Bewegung zunächst alle 30 Sekunden oder 100 Meter sowie bei relevanter Richtungsänderung +- Ereignisdatensatz bei Zündung an/aus +- AVL 239, 240, 16, 389, 256 und 134 aktivieren, soweit vom Gerät unterstützt +- lokale Pufferung und älteste Datensätze zuerst senden +- Serverbestätigung auf AVL-Ebene + +Das Standardintervall von mehreren Minuten ist für belastbare Start-/Zielpunkte und Routen zu grob. Die endgültigen Intervalle werden im Pilot gegen Datenvolumen, SIM-Tarif und Fahrzeugbatterie getestet. + +## 7. Einführung ohne Datenverlust + +### Phase 0 – Geräte-Spike + +Ein reales Gerät und Fahrzeug werden festgelegt. Modell, Firmware, IMEI, empfangene AVL-IDs, Zündungslogik und Kilometerstandsquellen werden dokumentiert. Ein aufgezeichneter Binärdatenbestand aus definierten Testfahrten bildet anschließend einen dauerhaft nutzbaren Parser- und Fahrtbildungs-Testvektor. + +### Phase 1 – Shadow-Ingest + +Teltonika unterstützt einen zweiten Server und einen Duplikatmodus. Während der Pilotphase sendet das Gerät an Traccar **und** FEDEO. FEDEO bestätigt und speichert Daten, beeinflusst aber noch kein produktives Fahrtenbuch. Über mehrere Wochen werden Paketvollständigkeit, Kilometerstände und erkannte Fahrten verglichen. + +### Phase 2 – Fahrtenbuch-Pilot + +Fahrtbildung, Prüfansicht, Klassifizierung, Revisionen und Export werden für wenige Fahrzeuge aktiviert. Kriterien für die Freigabe: + +- kein Paketverlust bei Neustart oder Datenbankunterbrechung, +- idempotente Verarbeitung wiederholter Pakete, +- reproduzierbare Fahrtbildung bei ungeordneter Zustellung, +- Kilometerstandsabweichungen erklärt und sichtbar, +- vollständiger Export inklusive Änderungen, +- Rechte- und Mandantentrennung getestet. + +### Phase 3 – Umschaltung + +Nach erfolgreichem Parallelbetrieb wird FEDEO Hauptserver. Traccar bleibt zunächst als Teltonika-Backupserver konfiguriert, anschließend wird es entfernt. Der Rückfall besteht in der ersten Zeit lediglich aus dem Zurückschalten des Geräteprofils; ein Datenbank-Rollback ist nicht erforderlich. + +## 8. Empfohlener Implementierungsumfang + +### Iteration 1: belastbarer Empfang + +- Schemata `vehicle_trackers`, `tracking_packets`, `tracking_positions` +- Codec-8/8E-Parser mit CRC- und offiziellen Beispielvektoren +- TCP/TLS-Dienst, IMEI-Zuordnung, ACK nach Commit +- Trackerstatus im Backend und technische Metriken +- Docker-Compose-Erweiterung und Betriebsdokumentation + +### Iteration 2: Fahrten + +- Schemata `tracking_trips` und Qualitätsmeldungen +- deterministischer Fahrtbildungs-Worker +- Listen-, Detail- und Kartenansicht +- manuelle Klassifizierung und Fahrerzuordnung + +### Iteration 3: belastbares Fahrtenbuch + +- unveränderliche Revisionen und Hash-Verkettung +- zeitnahe Erinnerungen, Bestätigung und Monatsabschluss +- PDF/CSV/JSON-Export mit Prüfmanifest +- Projekt-/Kunden-/Kalender-Vorschläge +- Rechte-, Datenschutz- und Verfahrensdokumentation + +## 9. Tests, die vor Abschaltung von Traccar bestehen müssen + +- fragmentierte und zusammengefasste TCP-Frames +- ungültige Länge, CRC, Codec-ID, IMEI und Datensatzanzahl +- Disconnect vor und nach ACK +- dasselbe Paket mehrfach und gleichzeitig zugestellt +- mehrere AVL-Datensätze pro Paket +- negative Koordinaten und variable I/O-Felder aus Codec 8 Extended +- Offline-Nachlieferung über mehrere Stunden in falscher Empfangsreihenfolge +- Zündung an ohne Bewegung, kurze Stopps, Tunnel, GPS-Sprung und Mitternacht +- fehlender oder zurückgesetzter Kilometerzähler +- Trackerwechsel zwischen Fahrzeugen +- Mandantentrennung jeder API-Route +- Änderung einer bestätigten und einer gesperrten Fahrt +- Export-Reproduzierbarkeit und Prüfung der Hash-Kette + +## Empfehlung + +Zuerst sollte **nicht** die Oberfläche gebaut werden, sondern ein zwei- bis dreitägiger Geräte-Spike mit einem konkreten Tracker. Danach ist Iteration 1 überschaubar und risikoarm. Der direkte Teltonika-Empfang selbst ist technisch klar definiert; die eigentliche Sorgfalt liegt in verspäteten Daten, Kilometerstandsqualität, Fahreridentität und der unveränderlich sichtbaren Bearbeitungshistorie. + +## Quellen + +- [Teltonika: Codec 8 und Codec 8 Extended](https://wiki.teltonika-gps.com/view/Teltonika_Data_Sending_Protocols) +- [Teltonika: Empfehlung für Codec 8 Extended](https://wiki.teltonika-gps.com/view/Which_data_protocol_to_choose_Codec_8_or_Codec_8_Extended) +- [Teltonika: FMC003 Data Sending Parameters](https://wiki.teltonika-gps.com/view/FMC003_Teltonika_Data_Sending_Parameters_ID) +- [Teltonika: FMB003 Trip-/Odometer-Einstellungen](https://wiki.teltonika-gps.com/view/FMB003_Trip/Odometer_settings) +- [Teltonika: FMB003 GPRS-, Zweitserver- und TLS-Einstellungen](https://wiki.teltonika-gps.com/view/FMB003_GPRS_settings) +- [Teltonika: OEM-Gesamtkilometerstand und Fahrzeugkompatibilität](https://wiki.teltonika-gps.com/view/Pay_as_You_Drive) +- [Bundesfinanzhof: Anforderungen an ein elektronisches Fahrtenbuch, VI B 37/23](https://www.bundesfinanzhof.de/de/entscheidung/entscheidungen-online/detail/STRE202450011/) +- [Bundesfinanzhof: Inhalt und geschlossene Form des Fahrtenbuchs, VI R 33/10](https://www.bundesfinanzhof.de/de/entscheidung/entscheidungen-online/detail/STRE201210116/) +- [BMF: Einkommensteuer-Handbuch 2025, Anhang 16 III](https://lsth.bundesfinanzministerium.de/esth/2025/B-Anhaenge/Anhang-16/III/inhalt.html) From bfba29b15e7b457aa1a42d5ea6e97db6f1e9bcf7 Mon Sep 17 00:00:00 2001 From: florianfederspiel Date: Mon, 19 Oct 2026 11:09:59 +0200 Subject: [PATCH 4/9] =?UTF-8?q?build(ios):=20KI-AGENT:=20TestFlight-Buildn?= =?UTF-8?q?ummer=20auf=2015=20erh=C3=B6hen?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- mobile/app.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mobile/app.json b/mobile/app.json index d35b8795..46bf5187 100644 --- a/mobile/app.json +++ b/mobile/app.json @@ -11,7 +11,7 @@ "ios": { "supportsTablet": true, "bundleIdentifier": "software.federspiel.fedeo", - "buildNumber": "14", + "buildNumber": "15", "infoPlist": { "NSCameraUsageDescription": "Die Kamera wird benötigt, um Fotos zu Projekten und Objekten als Dokumente hochzuladen.", "NSPhotoLibraryUsageDescription": "Der Zugriff auf Fotos wird benötigt, um Bilder als Dokumente hochzuladen.", From da84e98b8a28c8d59a265a5848c2de76659c282e Mon Sep 17 00:00:00 2001 From: florianfederspiel Date: Mon, 19 Oct 2026 11:40:26 +0200 Subject: [PATCH 5/9] =?UTF-8?q?feat(mobile):=20KI-AGENT:=20F=C3=B6derierte?= =?UTF-8?q?=20Chats=20und=20gemeinsame=20Sichtbarkeit=20erg=C3=A4nzen?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- mobile/README.md | 8 + mobile/app.json | 2 +- mobile/app/(tabs)/_layout.tsx | 49 ++- mobile/app/(tabs)/communication.tsx | 452 ++++++++++-------------- mobile/components/chat-conversation.tsx | 181 ++++++++++ mobile/hooks/use-chat-conversation.ts | 149 ++++++++ mobile/package-lock.json | 41 +++ mobile/package.json | 6 +- mobile/src/lib/chat-api.ts | 4 + mobile/src/lib/chat-image.ts | 30 ++ mobile/src/lib/chat.ts | 164 +++++++++ mobile/tests/chat-conversation.test.mjs | 121 +++++++ mobile/tests/chat-picker.test.mjs | 56 +++ mobile/tests/chat.test.mjs | 133 +++++++ 14 files changed, 1107 insertions(+), 289 deletions(-) create mode 100644 mobile/components/chat-conversation.tsx create mode 100644 mobile/hooks/use-chat-conversation.ts create mode 100644 mobile/src/lib/chat-api.ts create mode 100644 mobile/src/lib/chat-image.ts create mode 100644 mobile/src/lib/chat.ts create mode 100644 mobile/tests/chat-conversation.test.mjs create mode 100644 mobile/tests/chat-picker.test.mjs create mode 100644 mobile/tests/chat.test.mjs diff --git a/mobile/README.md b/mobile/README.md index 139cdc3b..4c78f246 100644 --- a/mobile/README.md +++ b/mobile/README.md @@ -23,6 +23,8 @@ xcrun simctl launch booted software.federspiel.fedeo ## TestFlight vorbereiten +Der Preflight prüft Lint, die mobilen Chat-Regressionstests und Expo Doctor. Für eine separate Typprüfung: `npx tsc --noEmit`. + ```bash npm run preflight:testflight npm run build:ios:testflight @@ -35,3 +37,9 @@ npm run submit:ios:testflight ``` Der TestFlight-Build nutzt das EAS-Profil `testflight` aus `eas.json` mit Store-Distribution und automatischer Buildnummer-Erhöhung. + +## Mobile Chats + +Die Kommunikation zeigt interne Gruppen-, Projekt- und Direktchats sowie lokale und föderierte Unterräume mit bestehender Mitgliedschaft. Einladungen können angenommen oder abgelehnt werden. Leserechte, freigegebene Projektinformationen und wartende beziehungsweise fehlgeschlagene Zustellungen werden berücksichtigt. Bilder werden vor dem Senden verkleinert; Reaktionen funktionieren in beiden Chatarten. + +Ausgeblendete Chats verwenden dieselben benutzer- und mandantenbezogenen Einstellungen wie das Web. Die App lädt sie beim Öffnen, beim Zurückkehren in den Vordergrund und während der Nutzung regelmäßig neu. Ausgeblendete Räume zählen nicht zum Chat-Badge und können über das Augensymbol wieder eingeblendet werden. Nachrichtenentwürfe bleiben beim Raumwechsel innerhalb derselben Sitzung erhalten und werden bei einem Sitzungswechsel verworfen. diff --git a/mobile/app.json b/mobile/app.json index 46bf5187..45f83a56 100644 --- a/mobile/app.json +++ b/mobile/app.json @@ -11,7 +11,7 @@ "ios": { "supportsTablet": true, "bundleIdentifier": "software.federspiel.fedeo", - "buildNumber": "15", + "buildNumber": "16", "infoPlist": { "NSCameraUsageDescription": "Die Kamera wird benötigt, um Fotos zu Projekten und Objekten als Dokumente hochzuladen.", "NSPhotoLibraryUsageDescription": "Der Zugriff auf Fotos wird benötigt, um Bilder als Dokumente hochzuladen.", diff --git a/mobile/app/(tabs)/_layout.tsx b/mobile/app/(tabs)/_layout.tsx index b6703537..83d12cf7 100644 --- a/mobile/app/(tabs)/_layout.tsx +++ b/mobile/app/(tabs)/_layout.tsx @@ -1,12 +1,13 @@ import * as Notifications from 'expo-notifications'; import { Redirect, Tabs } from 'expo-router'; -import { useCallback, useEffect, useMemo, useState } from 'react'; +import { useEffect, useMemo, useState } from 'react'; +import { AppState, DeviceEventEmitter } from 'react-native'; import { HapticTab } from '@/components/haptic-tab'; import { IconSymbol } from '@/components/ui/icon-symbol'; import { Colors } from '@/constants/theme'; import { useColorScheme } from '@/hooks/use-color-scheme'; -import { fetchChatUnreadCounts } from '@/src/lib/api'; +import { chatApi } from '@/src/lib/chat-api'; import { useAuth } from '@/src/providers/auth-provider'; export default function TabLayout() { @@ -14,31 +15,27 @@ export default function TabLayout() { const { activeTenantId, isBootstrapping, token, requiresTenantSelection } = useAuth(); const [communicationUnread, setCommunicationUnread] = useState(0); - const refreshCommunicationUnread = useCallback(async () => { - if (!token || requiresTenantSelection) { - setCommunicationUnread(0); - await Notifications.setBadgeCountAsync(0); - return; - } - - try { - const unread = await fetchChatUnreadCounts(token); - const total = Object.values(unread).reduce((sum, room) => sum + (room.count || 0), 0); - setCommunicationUnread(total); - await Notifications.setBadgeCountAsync(total); - } catch { - // Badge loading must not block tab navigation. - } - }, [requiresTenantSelection, token]); - useEffect(() => { - void refreshCommunicationUnread(); - - if (!token || requiresTenantSelection) return undefined; - - const id = setInterval(() => void refreshCommunicationUnread(), 30000); - return () => clearInterval(id); - }, [activeTenantId, refreshCommunicationUnread, requiresTenantSelection, token]); + let active = true; + let revision = 0; + setCommunicationUnread(0); + void Notifications.setBadgeCountAsync(0).catch(() => undefined); + async function refresh() { + if (!token || !activeTenantId || requiresTenantSelection || AppState.currentState !== 'active') return; + const request = ++revision; + try { + const total = await chatApi.unread(token); + if (!active || request !== revision) return; + setCommunicationUnread(total); + await Notifications.setBadgeCountAsync(total); + } catch { /* Badge loading must not block tab navigation. */ } + } + void refresh(); + const timer = setInterval(() => void refresh(), 30000); + const foreground = AppState.addEventListener('change', state => { if (state === 'active') void refresh(); }); + const changed = DeviceEventEmitter.addListener('fedeo:chat-changed', () => void refresh()); + return () => { active = false; ++revision; clearInterval(timer); foreground.remove(); changed.remove(); }; + }, [activeTenantId, requiresTenantSelection, token]); const communicationBadge = useMemo(() => { if (!communicationUnread) return undefined; diff --git a/mobile/app/(tabs)/communication.tsx b/mobile/app/(tabs)/communication.tsx index 17c66118..8547710b 100644 --- a/mobile/app/(tabs)/communication.tsx +++ b/mobile/app/(tabs)/communication.tsx @@ -1,294 +1,224 @@ +import { Ionicons } from '@expo/vector-icons'; +import { useIsFocused } from '@react-navigation/native'; +import { randomUUID } from 'expo-crypto'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { - ActivityIndicator, - FlatList, - KeyboardAvoidingView, - Modal, - Platform, - Pressable, - RefreshControl, - StyleSheet, - Text, - TextInput, - View, + ActivityIndicator, AppState, DeviceEventEmitter, FlatList, KeyboardAvoidingView, Modal, Platform, + Pressable, ScrollView, StyleSheet, Text, TextInput, View, } from 'react-native'; -import { - ChatMember, - ChatMessage, - ChatRoom, - createChatRoom, - fetchChatMembers, - fetchChatMessages, - fetchChatRooms, - markChatRoomRead, - provisionChatRoom, - sendChatMessage, - syncChatRoom, -} from '@/src/lib/api'; +import { ChatConversation, type ChatDraft } from '@/components/chat-conversation'; +import { apiRequest } from '@/src/lib/api'; +import { chatApi } from '@/src/lib/chat-api'; +import { collaborationKey, isChatAccessError, matchesChatSearch, visibleChatRooms, type ChatDirectory, type MobileChatRoom } from '@/src/lib/chat'; +import { getApiBaseUrlSync } from '@/src/lib/server-config'; import { useAuth } from '@/src/providers/auth-provider'; -const PRIMARY = '#69c350'; - -function normalizeRoomKey(value: string): string { - return value.trim().toLowerCase().normalize('NFD').replace(/[\u0300-\u036f]/g, '') - .replace(/ß/g, 'ss').replace(/[^a-z0-9._=-]+/g, '_').replace(/^_+|_+$/g, '').slice(0, 48); -} - -function messageTime(value: ChatMessage['timestamp']): string { - if (!value) return ''; - return new Date(value).toLocaleString('de-DE', { day: '2-digit', month: '2-digit', hour: '2-digit', minute: '2-digit' }); -} - -function mergeMessages(current: ChatMessage[], incoming: ChatMessage[]): ChatMessage[] { - const byId = new Map(); - for (const message of current) byId.set(message.id, message); - for (const message of incoming) byId.set(message.id, message); - return Array.from(byId.values()).sort((a, b) => a.id - b.id); -} - export default function CommunicationScreen() { - const { token } = useAuth(); - const listRef = useRef>(null); - const [rooms, setRooms] = useState([]); - const [members, setMembers] = useState([]); - const [messages, setMessages] = useState([]); - const [activeRoomKey, setActiveRoomKey] = useState('allgemein'); - const [draft, setDraft] = useState(''); + const { token, activeTenantId } = useAuth(); + if (!token || !activeTenantId) return null; + return ; +} + +function ChatScreen({ token }: { token: string }) { + const focused = useIsFocused(); + const [foreground, setForeground] = useState(AppState.currentState === 'active'); + const enabled = focused && foreground; + const [directory, setDirectory] = useState(null); + const [activeKey, setActiveKey] = useState(''); const [search, setSearch] = useState(''); + const [error, setError] = useState(''); const [loading, setLoading] = useState(true); - const [refreshing, setRefreshing] = useState(false); - const [sending, setSending] = useState(false); - const [error, setError] = useState(null); - const [membersOpen, setMembersOpen] = useState(false); + const [busy, setBusy] = useState(''); + const [showHidden, setShowHidden] = useState(false); const [createOpen, setCreateOpen] = useState(false); const [roomName, setRoomName] = useState(''); const [roomTopic, setRoomTopic] = useState(''); - const [roomKey, setRoomKey] = useState(''); - const afterIdRef = useRef(0); - - const activeRoom = useMemo( - () => rooms.find((room) => room.key === activeRoomKey) || rooms[0] || null, - [activeRoomKey, rooms] - ); - - const visibleRooms = useMemo(() => { - const query = search.trim().toLowerCase(); - return rooms.filter((room) => !query || [room.name, room.topic, room.email, room.projectNumber] - .filter(Boolean).some((value) => String(value).toLowerCase().includes(query))); - }, [rooms, search]); - - const loadRooms = useCallback(async () => { - if (!token) return []; - const result = await fetchChatRooms(token); - setRooms(result); - return result; - }, [token]); - - const loadRoom = useCallback(async (key: string) => { - if (!token) return; - const [nextMessages, nextMembers] = await Promise.all([ - fetchChatMessages(token, key), - fetchChatMembers(token, key), - ]); - setMessages(nextMessages); - setMembers(nextMembers); - afterIdRef.current = nextMessages[nextMessages.length - 1]?.id || 0; - await markChatRoomRead(token, key, afterIdRef.current); - }, [token]); + const [roomType, setRoomType] = useState<'internal' | 'collaboration'>('internal'); + const [projectId, setProjectId] = useState(null); + const [drafts, setDrafts] = useState>({}); + const alive = useRef(true); + const directoryRevision = useRef(0); + const selectionRevision = useRef(0); + const operation = useRef(false); + const loadingDirectory = useRef(false); + const directoryRef = useRef(directory); + directoryRef.current = directory; + const [emptyDraft] = useState(() => ({ text: '', image: null, clientId: randomUUID() })); + const rooms = useMemo(() => directory ? visibleChatRooms(directory) : [], [directory]); + const filteredRooms = useMemo(() => rooms.filter(room => matchesChatSearch(room, search)), [rooms, search]); + const hiddenRooms = useMemo(() => directory?.rooms.filter(room => directory.hiddenRoomKeys.includes(room.key)) || [], [directory]); + const activeRoom = rooms.find(room => room.key === activeKey && room.exists); const refresh = useCallback(async () => { - if (!token) return; + if (loadingDirectory.current || operation.current) return; + const version = ++directoryRevision.current; + loadingDirectory.current = true; try { - setError(null); - const nextRooms = await loadRooms(); - const selected = nextRooms.find((room) => room.key === activeRoomKey) || nextRooms[0]; - if (selected?.exists) { - setActiveRoomKey(selected.key); - await loadRoom(selected.key); - } - } catch (nextError) { - setError(nextError instanceof Error ? nextError.message : 'Chat konnte nicht geladen werden.'); + const result = await chatApi.directory(token); + if (!alive.current || version !== directoryRevision.current) return; + directoryRef.current = result; setDirectory(result); setError(''); + const available = visibleChatRooms(result); + setActiveKey(previous => available.some(room => room.key === previous && room.exists) + ? previous : available.find(room => room.exists)?.key || ''); + } catch (err) { + if (!alive.current || version !== directoryRevision.current) return; + setError(err instanceof Error ? err.message : 'Chats konnten nicht geladen werden.'); + if (isChatAccessError(err)) { directoryRef.current = null; setDirectory(null); setActiveKey(''); setDrafts({}); } } finally { - setLoading(false); - setRefreshing(false); + loadingDirectory.current = false; + if (alive.current && version === directoryRevision.current) setLoading(false); } - }, [activeRoomKey, loadRoom, loadRooms, token]); - - useEffect(() => { void refresh(); }, [refresh]); + }, [token]); useEffect(() => { - if (!token || !activeRoom?.exists) return undefined; - const id = setInterval(async () => { - try { - const result = await syncChatRoom(token, activeRoom.key, afterIdRef.current); - if (!result.messages?.length) return; - setMessages((current) => mergeMessages(current, result.messages || [])); - afterIdRef.current = result.nextId || afterIdRef.current; - await markChatRoomRead(token, activeRoom.key, afterIdRef.current); - } catch { - // Beim nächsten Intervall erneut versuchen. - } - }, 3000); - return () => clearInterval(id); - }, [activeRoom, token]); + alive.current = true; + const listener = AppState.addEventListener('change', state => setForeground(state === 'active')); + return () => { alive.current = false; listener.remove(); }; + }, []); + useEffect(() => { + if (!enabled) return; + void refresh(); + const timer = setInterval(() => void refresh(), 15000); + return () => clearInterval(timer); + }, [enabled, refresh]); - async function selectRoom(room: ChatRoom) { - if (!token) return; + async function select(room: MobileChatRoom) { + if (directoryRef.current?.hiddenRoomKeys.includes(room.key)) return; + const version = ++selectionRevision.current; + setError(''); + if (room.exists) { setActiveKey(room.key); return; } try { - setLoading(true); - const readyRoom = room.exists ? room : await provisionChatRoom(token, room); - setActiveRoomKey(readyRoom.key); - await loadRooms(); - await loadRoom(readyRoom.key); - } catch (nextError) { - setError(nextError instanceof Error ? nextError.message : 'Chatraum konnte nicht geöffnet werden.'); - } finally { - setLoading(false); - } + setBusy(room.key); + const ready = await chatApi.provision(token, room); + if (!alive.current || version !== selectionRevision.current || directoryRef.current?.hiddenRoomKeys.includes(room.key)) return; + setDirectory(previous => previous && ({ ...previous, rooms: previous.rooms.map(item => item.key === room.key ? ready : item) })); + setActiveKey(ready.key); + } catch (err) { if (alive.current && version === selectionRevision.current) setError(err instanceof Error ? err.message : 'Chat konnte nicht geöffnet werden.'); } + finally { if (alive.current && version === selectionRevision.current) setBusy(''); } } - async function submitMessage() { - if (!token || !activeRoom?.exists || !draft.trim() || sending) return; - const text = draft.trim(); - setDraft(''); - setSending(true); + async function mutate(label: string, action: () => Promise) { + if (operation.current) return; + operation.current = true; ++directoryRevision.current; + setBusy(label); setError(''); try { - const message = await sendChatMessage(token, activeRoom.key, text); - setMessages((current) => mergeMessages(current, [message])); - afterIdRef.current = Math.max(afterIdRef.current, message.id); - await markChatRoomRead(token, activeRoom.key, message.id); - } catch (nextError) { - setDraft(text); - setError(nextError instanceof Error ? nextError.message : 'Nachricht konnte nicht gesendet werden.'); - } finally { - setSending(false); - } + const selectKey = await action(); + if (!alive.current) return; + DeviceEventEmitter.emit('fedeo:chat-changed'); + const result = await chatApi.directory(token); + if (!alive.current) return; + directoryRef.current = result; setDirectory(result); + const available = visibleChatRooms(result); + setActiveKey(previous => available.find(room => room.key === (selectKey || previous) && room.exists)?.key + || available.find(room => room.exists)?.key || ''); + } catch (err) { if (alive.current) setError(err instanceof Error ? err.message : 'Änderung konnte nicht gespeichert werden.'); } + finally { operation.current = false; if (alive.current) { setBusy(''); setLoading(false); } } } - - async function submitRoom() { - if (!token || !roomName.trim()) return; - try { - const created = await createChatRoom(token, { - key: normalizeRoomKey(roomKey || roomName), - name: roomName.trim(), - topic: roomTopic.trim() || null, + async function hide(room: MobileChatRoom, hidden: boolean) { + await mutate(room.key, async () => { + await chatApi.hide(token, room.key, hidden); + if (!alive.current) return; + // Update immediately after success, even if refreshing the directory subsequently fails. + setDirectory(previous => { + if (!previous) return previous; + const next = { ...previous, hiddenRoomKeys: hidden ? [...new Set([...previous.hiddenRoomKeys, room.key])] + : previous.hiddenRoomKeys.filter(key => key !== room.key) }; + directoryRef.current = next; + return next; }); - setCreateOpen(false); - setRoomName(''); - setRoomKey(''); - setRoomTopic(''); - await loadRooms(); - await selectRoom(created); - } catch (nextError) { - setError(nextError instanceof Error ? nextError.message : 'Chatraum konnte nicht erstellt werden.'); - } + if (hidden) setActiveKey(previous => previous === room.key ? '' : previous); + else { setShowHidden(false); return room.key; } + }); } - - function renderRoom({ item }: { item: ChatRoom }) { - const active = item.key === activeRoom?.key; - return ( - void selectRoom(item)}> - {item.group || 'Räume'} - {item.name} - {item.unread ? {item.unread} : null} - - ); + async function respond(id: string, accept: boolean) { + await mutate(id, async () => { + const result = await chatApi.respond(token, id, accept); + return accept && result.roomId ? collaborationKey(result.roomId) : undefined; + }); } - - function renderMessage({ item }: { item: ChatMessage }) { - return ( - - - {!item.own ? {item.senderDisplayName || 'Benutzer'} : null} - {item.body} - {messageTime(item.timestamp)} - - - ); + async function create() { + if (!roomName.trim()) return; + await mutate('create', async () => { + let key: string; + if (roomType === 'collaboration') { + const created = await apiRequest<{ id: string }>('/api/collaboration/rooms', { token, method: 'POST', body: { name: roomName.trim(), topic: roomTopic.trim(), projectId } }); + key = collaborationKey(created.id); + } else { + const normalized = roomName.toLowerCase().normalize('NFKD').replace(/[\u0300-\u036f]/g, '').replace(/ß/g, 'ss').replace(/[^a-z0-9._=-]+/g, '_').replace(/^[._=-]+|[._=-]+$/g, '').slice(0, 48); + const created = await apiRequest<{ key: string }>('/api/communication/chat/rooms', { token, method: 'POST', body: { name: roomName.trim(), topic: roomTopic.trim(), key: normalized } }); + key = created.key; + } + if (alive.current) { setCreateOpen(false); setRoomName(''); setRoomTopic(''); } + return key; + }); } - - return ( - - - - Chat - {activeRoom?.name || 'FEDEO Kommunikation'} - - setCreateOpen(true)}>+ - setMembersOpen((value) => !value)}>☰ - - - {error ? {error} : null} - - - - item.key} renderItem={renderRoom} - showsHorizontalScrollIndicator={false} contentContainerStyle={styles.roomList} /> - - - {membersOpen ? ( - - Teilnehmer · {members.length} - {members.map((member) => member.own ? 'Du' : member.displayName).join(' · ')} - - ) : null} - - {loading ? ( - Nachrichten werden geladen … - ) : ( - String(item.id)} renderItem={renderMessage} - contentContainerStyle={styles.messageList} - refreshControl={ { setRefreshing(true); void refresh(); }} />} - ListEmptyComponent={Noch keine Nachrichten in diesem Raum.} - onContentSizeChange={() => listRef.current?.scrollToEnd({ animated: true })} /> - )} - - - - void submitMessage()}> - {sending ? '…' : 'Senden'} - - - - setCreateOpen(false)}> - - Chatraum erstellen - - - - - setCreateOpen(false)}>Abbrechen - void submitRoom()}>Erstellen - - - - - ); + return + + + setCreateOpen(true)} disabled={!directory || !!busy} style={styles.icon}> + setShowHidden(true)} style={styles.icon}> + + {!!error && {error} void refresh()}>Erneut laden} + {!!directory?.invitations.length && + {directory.invitations.map(invitation => + Einladung: {invitation.name} + {invitation.ownerName}{invitation.remote ? ' · Extern' : ''} · {invitation.role === 'reader' ? 'Nur lesen' : 'Lesen und schreiben'} + {invitation.overviewAllowed && Projektübersicht freigegeben} + void respond(invitation.id, true)} style={styles.button}>{busy === invitation.id ? 'Bitte warten …' : 'Annehmen'} + void respond(invitation.id, false)} style={styles.secondary}>Ablehnen + )} + } + room.key} showsHorizontalScrollIndicator={false} contentContainerStyle={styles.roomList} + ListEmptyComponent={!loading ? {search ? 'Keine passenden Chats.' : 'Keine sichtbaren Chats.'} : null} + renderItem={({ item }) => void select(item)} style={[styles.room, activeKey === item.key && styles.selectedRoom]}> + {item.group}{item.remote ? ' · EXTERN' : ''}{item.name} + {!!item.unread && {item.unread}} + } /> + {loading ? Chats werden geladen … : activeRoom ? + void hide(activeRoom, true)} + onDraft={value => setDrafts(previous => ({ ...previous, [activeKey]: { ...value, clientId: randomUUID() } }))} + onSent={clientId => setDrafts(previous => { if (previous[activeKey]?.clientId !== clientId) return previous; const next = { ...previous }; delete next[activeKey]; return next; })} /> + : Wähle einen Chat aus.} + setShowHidden(false)}> + Ausgeblendete ChatsDiese Einstellung gilt auch im Web. + matchesChatSearch(room, search))} keyExtractor={room => room.key} style={{ maxHeight: 380 }} + ListEmptyComponent={Keine ausgeblendeten Chats gefunden.} + renderItem={({ item }) => {item.name}{item.group} + void hide(item, false)} style={styles.secondary}>Einblenden} /> + setShowHidden(false)} style={styles.secondary}>Schließen + + + setCreateOpen(false)}> + + Chat erstellen + {directory?.canManage && {(['internal', 'collaboration'] as const).map(type => setRoomType(type)} style={[styles.secondary, roomType === type && styles.selectedRoom]}>{type === 'internal' ? 'Gruppenchat' : 'Unterraum'})}} + + + {roomType === 'collaboration' && <>Projekt zuordnen (optional) + setProjectId(null)} style={styles.secondary}>{projectId === null ? '✓ ' : ''}Ohne Projekt + {directory?.projects.map(project => setProjectId(project.id)} style={styles.secondary}>{projectId === project.id ? '✓ ' : ''}{project.name})} + } + {!!error && {error}} + setCreateOpen(false)} style={styles.secondary}>Abbrechen + void create()} style={styles.button}>{busy === 'create' ? 'Wird erstellt …' : 'Erstellen'} + + + ; } const styles = StyleSheet.create({ - screen: { flex: 1, backgroundColor: '#f9fafb' }, - header: { flexDirection: 'row', alignItems: 'center', gap: 8, padding: 16, backgroundColor: '#fff', borderBottomWidth: 1, borderBottomColor: '#e5e7eb' }, - headerMain: { flex: 1 }, title: { fontSize: 20, fontWeight: '800', color: '#111827' }, subtitle: { color: '#6b7280', marginTop: 2 }, - headerButton: { width: 36, height: 36, borderRadius: 18, alignItems: 'center', justifyContent: 'center', borderWidth: 1, borderColor: '#d1d5db' }, - headerButtonText: { fontSize: 20, fontWeight: '700' }, error: { margin: 10, padding: 10, borderRadius: 8, color: '#991b1b', backgroundColor: '#fee2e2' }, - roomPanel: { paddingVertical: 10, backgroundColor: '#fff', borderBottomWidth: 1, borderBottomColor: '#e5e7eb' }, - searchInput: { marginHorizontal: 16, padding: 9, borderWidth: 1, borderColor: '#d1d5db', borderRadius: 10 }, - roomList: { paddingHorizontal: 12, paddingTop: 10, gap: 8 }, roomChip: { width: 150, padding: 10, borderWidth: 1, borderColor: '#e5e7eb', borderRadius: 10 }, - roomChipActive: { backgroundColor: '#eff9ea', borderColor: PRIMARY }, roomGroup: { color: '#6b7280', fontSize: 10, textTransform: 'uppercase' }, - roomName: { color: '#111827', fontSize: 14, fontWeight: '700', marginTop: 2 }, roomTextActive: { color: '#2f6f25' }, - roomBadge: { position: 'absolute', right: 6, top: 6, backgroundColor: PRIMARY, color: '#fff', borderRadius: 9, paddingHorizontal: 6, overflow: 'hidden', fontSize: 11 }, - membersPanel: { padding: 12, backgroundColor: '#fff', borderBottomWidth: 1, borderBottomColor: '#e5e7eb' }, membersTitle: { fontWeight: '700', color: '#111827' }, membersText: { marginTop: 4, color: '#6b7280', fontSize: 12 }, - loading: { flex: 1, alignItems: 'center', justifyContent: 'center', gap: 8 }, loadingText: { color: '#6b7280' }, - messageList: { flexGrow: 1, padding: 14, gap: 10 }, emptyText: { marginTop: 40, textAlign: 'center', color: '#9ca3af' }, - messageRow: { flexDirection: 'row' }, messageRowOwn: { justifyContent: 'flex-end' }, messageBubble: { maxWidth: '82%', padding: 11, borderRadius: 16, backgroundColor: '#f3f4f6' }, - messageBubbleOwn: { backgroundColor: PRIMARY }, messageAuthor: { color: '#2f6f25', fontSize: 12, fontWeight: '700', marginBottom: 3 }, messageBody: { color: '#111827', fontSize: 15 }, messageBodyOwn: { color: '#fff' }, - messageTime: { marginTop: 4, color: '#9ca3af', fontSize: 10, textAlign: 'right' }, messageTimeOwn: { color: '#e7f7e2' }, - composer: { flexDirection: 'row', alignItems: 'flex-end', gap: 8, padding: 12, backgroundColor: '#fff', borderTopWidth: 1, borderTopColor: '#e5e7eb' }, - composerInput: { flex: 1, maxHeight: 120, borderWidth: 1, borderColor: '#d1d5db', borderRadius: 18, paddingHorizontal: 13, paddingVertical: 9 }, - sendButton: { backgroundColor: PRIMARY, borderRadius: 18, paddingHorizontal: 15, paddingVertical: 10 }, sendButtonDisabled: { opacity: 0.45 }, sendButtonText: { color: '#fff', fontWeight: '700' }, - modalBackdrop: { flex: 1, justifyContent: 'center', padding: 24, backgroundColor: 'rgba(0,0,0,0.45)' }, modalCard: { padding: 18, gap: 12, borderRadius: 14, backgroundColor: '#fff' }, - modalTitle: { fontSize: 18, fontWeight: '800', color: '#111827' }, modalInput: { borderWidth: 1, borderColor: '#d1d5db', borderRadius: 9, padding: 10 }, - modalActions: { flexDirection: 'row', justifyContent: 'flex-end', alignItems: 'center', gap: 18 }, linkText: { color: '#2f6f25', fontWeight: '700' }, primaryButton: { paddingHorizontal: 14, paddingVertical: 10, borderRadius: 9, backgroundColor: PRIMARY }, primaryButtonText: { color: '#fff', fontWeight: '700' }, + screen: { flex: 1, backgroundColor: '#f9fafb' }, toolbar: { flexDirection: 'row', alignItems: 'center', padding: 10, gap: 5, backgroundColor: '#fff' }, + search: { flex: 1, minWidth: 0, minHeight: 42, padding: 9, borderWidth: 1, borderColor: '#d1d5db', borderRadius: 10, fontSize: 13, color: '#111827' }, + icon: { padding: 8, minWidth: 40, minHeight: 44, justifyContent: 'center', alignItems: 'center' }, roomPanel: { backgroundColor: '#fff', borderBottomWidth: 1, borderColor: '#e5e7eb' }, + roomList: { paddingHorizontal: 12, paddingBottom: 10, gap: 8 }, room: { width: 165, padding: 10, borderWidth: 1, borderColor: '#e5e7eb', borderRadius: 10 }, + selectedRoom: { backgroundColor: '#eff9ea', borderColor: '#69c350', borderWidth: 1 }, group: { fontSize: 10, color: '#6b7280', textTransform: 'uppercase', marginBottom: 4 }, + bold: { fontWeight: '700', color: '#111827', fontSize: 13 }, badge: { marginTop: 4, alignSelf: 'flex-start', paddingHorizontal: 6, borderRadius: 8, backgroundColor: '#dcfce7', color: '#166534', fontSize: 11 }, + empty: { flex: 1, justifyContent: 'center', alignItems: 'center', gap: 10 }, hint: { fontSize: 12, color: '#6b7280' }, error: { fontSize: 12, color: '#991b1b' }, errorBox: { backgroundColor: '#fef2f2', padding: 10, gap: 4 }, + link: { color: '#2f6f25', fontWeight: '600' }, invitations: { maxHeight: 170, flexGrow: 0, padding: 10, backgroundColor: '#f0fdf4' }, invitation: { gap: 5, paddingBottom: 8 }, + actions: { flexDirection: 'row', alignItems: 'center', gap: 8 }, button: { paddingVertical: 11, paddingHorizontal: 16, borderRadius: 8, backgroundColor: '#438b30' }, + white: { color: '#fff', fontWeight: '600' }, secondary: { padding: 11, borderRadius: 8 }, overlay: { flex: 1, backgroundColor: 'rgba(0,0,0,0.4)', justifyContent: 'center', padding: 20 }, + modal: { maxHeight: '85%', backgroundColor: '#fff', borderRadius: 16, padding: 18, gap: 12 }, title: { fontSize: 18, fontWeight: '700', color: '#111827' }, + hiddenRow: { flexDirection: 'row', alignItems: 'center', gap: 8, paddingVertical: 9, borderBottomWidth: 1, borderColor: '#e5e7eb' }, input: { borderWidth: 1, borderColor: '#d1d5db', borderRadius: 8, padding: 12, color: '#111827' }, }); diff --git a/mobile/components/chat-conversation.tsx b/mobile/components/chat-conversation.tsx new file mode 100644 index 00000000..93287bbe --- /dev/null +++ b/mobile/components/chat-conversation.tsx @@ -0,0 +1,181 @@ +import { Ionicons } from '@expo/vector-icons'; +import { Image } from 'expo-image'; +import * as ImagePicker from 'expo-image-picker'; +import { useCallback, useEffect, useRef, useState } from 'react'; +import { ActivityIndicator, FlatList, Modal, Pressable, ScrollView, StyleSheet, Text, TextInput, View } from 'react-native'; + +import { useChatConversation } from '@/hooks/use-chat-conversation'; +import { chatApi } from '@/src/lib/chat-api'; +import { prepareMobileChatImage } from '@/src/lib/chat-image'; +import { reactionEmojis, type ChatAttachment, type MobileChatMessage, type MobileChatRoom } from '@/src/lib/chat'; + +export type ChatDraft = { text: string; image: ChatAttachment | null; clientId: string }; +type Props = { + token: string; room: MobileChatRoom; enabled: boolean; draft: ChatDraft; + onDraft: (value: Pick) => void; + onSent: (clientId: string) => void; onHide: () => void; hiding: boolean; +}; + +function MessageImage({ token, room, message }: { token: string; room: MobileChatRoom; message: MobileChatMessage }) { + const [uri, setUri] = useState(''); + const [error, setError] = useState(false); + const [expanded, setExpanded] = useState(false); + const [retry, setRetry] = useState(0); + const roomRef = useRef(room); + roomRef.current = room; + useEffect(() => { + let active = true; + setUri(''); setError(false); setExpanded(false); + if (message.pending || message.failed) return; + chatApi.image(token, roomRef.current, message.id).then(value => { if (active) setUri(value); }) + .catch(() => { if (active) setError(true); }); + return () => { active = false; }; + }, [message.failed, message.id, message.pending, retry, room.key, token]); + const image = message.image!; + const width = Math.min(260, image.width); + const height = Math.min(220, width * image.height / image.width); + if (message.pending || message.failed) return 📷 {image.name}; + return + {uri ? setExpanded(true)}> + + : error ? setRetry(value => value + 1)}> + Bild erneut laden + : } + setExpanded(false)}> + + setExpanded(false)} style={styles.closeImage}>Schließen + + + + ; +} + +export function ChatConversation({ token, room, enabled, draft, onDraft, onSent, onHide, hiding }: Props) { + const chat = useChatConversation(token, room, enabled); + const [membersOpen, setMembersOpen] = useState(false); + const [reactionMessage, setReactionMessage] = useState(null); + const [preparing, setPreparing] = useState(false); + const [imageError, setImageError] = useState(''); + const list = useRef>(null); + const alive = useRef(true); + const nearBottom = useRef(true); + const pickerGeneration = useRef(0); + const allowed = enabled && !!chat.detail?.canSend && !chat.loading; + useEffect(() => { alive.current = true; return () => { alive.current = false; }; }, []); + useEffect(() => { if (!allowed) setReactionMessage(null); }, [allowed]); + + const attach = async () => { + if (!allowed || preparing || chat.sending) return; + const version = ++pickerGeneration.current; + setPreparing(true); setImageError(''); + try { + const result = await ImagePicker.launchImageLibraryAsync({ mediaTypes: ['images'], quality: 1 }); + if (result.canceled || !alive.current || version !== pickerGeneration.current) return; + const image = await prepareMobileChatImage(result.assets[0]); + // The system photo picker can temporarily background the app. Keep the local + // attachment; sending still requires refreshed room permissions afterwards. + if (alive.current && version === pickerGeneration.current) onDraft({ text: draft.text, image }); + } catch (error) { + if (alive.current && version === pickerGeneration.current) setImageError(error instanceof Error ? error.message : 'Bild konnte nicht geöffnet werden.'); + } finally { if (alive.current && version === pickerGeneration.current) setPreparing(false); } + }; + const send = async () => { + if (!allowed || preparing || (!draft.text.trim() && !draft.image)) return; + const sent = await chat.send(draft.text.trim(), draft.image, draft.clientId); + if (sent) { onSent(draft.clientId); nearBottom.current = true; list.current?.scrollToEnd({ animated: true }); } + }; + const time = (message: MobileChatMessage) => { + const value = message.createdAt || message.timestamp; + return value ? new Date(value).toLocaleString('de-DE', { day: '2-digit', month: '2-digit', hour: '2-digit', minute: '2-digit' }) : ''; + }; + const older = () => { nearBottom.current = false; void chat.older(); }; + const renderMessage = ({ item }: { item: MobileChatMessage }) => { + const canReact = allowed && !chat.detail?.offline && !item.pending && !item.failed; + return + + {item.own ? 'Du' : item.senderName || item.senderDisplayName || 'Benutzer'} + {item.image && } + {!!item.body && {item.body}} + {time(item)}{item.pending ? ' · Wartet auf Zustellung' : item.failed ? ' · Zustellung fehlgeschlagen' : ''} + + {(chat.reactions[item.id] || []).map(reaction => void chat.react(item, reaction.emoji)} style={[styles.reaction, reaction.own && styles.ownReaction]}> + {reaction.emoji} {reaction.count} + )} + {canReact && setReactionMessage(item)} style={styles.reaction}>☺ +} + + + ; + }; + const scrollToEnd = useCallback(() => { if (nearBottom.current) list.current?.scrollToEnd({ animated: false }); }, []); + return + + {chat.detail?.name || room.name} + {room.remote ? 'Extern · ' : ''}{room.ownerName || room.group}{chat.detail?.status === 'archived' ? ' · Archiviert' : ''} + setMembersOpen(value => !value)} style={styles.iconButton}> + + + {membersOpen && Teilnehmer · {chat.members.length} + {chat.members.map(member => `${member.own ? 'Du' : member.name}${member.kind === 'guest' ? ' (Gast)' : ''}`).join(' · ') || 'Keine Teilnehmer verfügbar.'}} + {!!chat.error && {chat.error}Erneut laden} + {chat.detail?.offline && Die Partnerinstanz ist nicht erreichbar. Neue Nachrichten warten auf Zustellung.} + {!!chat.detail?.overview && Freigegebene Projektinformationen + {Object.entries(chat.detail.overview).filter(([, value]) => !!value).map(([key, value]) => {key === 'status' ? 'Status: ' : key === 'date' ? 'Termin: ' : ''}{value})} + } + {chat.loading ? Nachrichten werden geladen … : + String(item.id)} renderItem={renderMessage} + style={styles.grow} contentContainerStyle={styles.messages} keyboardShouldPersistTaps="handled" + maintainVisibleContentPosition={{ minIndexForVisible: 0 }} + onScroll={event => { const { contentOffset, contentSize, layoutMeasurement } = event.nativeEvent; nearBottom.current = contentSize.height - contentOffset.y - layoutMeasurement.height < 100; }} + scrollEventThrottle={100} onContentSizeChange={scrollToEnd} + ListHeaderComponent={chat.hasMore ? {chat.olderLoading ? 'Wird geladen …' : 'Ältere Nachrichten laden'} : null} + ListEmptyComponent={Noch keine Nachrichten in diesem Chat.} />} + {chat.detail && + {!chat.detail.canSend && Dieser Chat ist nur lesbar.} + {!!imageError && {imageError}} + {draft.image && + {draft.image.name} + onDraft({ text: draft.text, image: null })} style={styles.iconButton}>} + + void attach()} style={styles.iconButton}> + {preparing ? : } + onDraft({ text, image: draft.image })} /> + void send()} + style={[styles.send, (!allowed || preparing || chat.sending || (!draft.text.trim() && !draft.image)) && styles.disabled]}> + {chat.sending ? : } + + } + setReactionMessage(null)}> + setReactionMessage(null)}> + Reaktion auswählen + {reactionEmojis.map(emoji => { if (reactionMessage) void chat.react(reactionMessage, emoji); setReactionMessage(null); }}>{emoji})} + + + ; +} + +const styles = StyleSheet.create({ + container: { flex: 1, minHeight: 0 }, grow: { flex: 1 }, + header: { flexDirection: 'row', alignItems: 'center', padding: 12, gap: 8, backgroundColor: '#fff', borderBottomWidth: 1, borderColor: '#e5e7eb' }, + title: { fontSize: 16, fontWeight: '700', color: '#111827' }, hint: { color: '#6b7280', fontSize: 12 }, + iconButton: { padding: 10, minHeight: 44, minWidth: 44, alignItems: 'center', justifyContent: 'center' }, + messages: { padding: 12, gap: 12 }, messageRow: { flexDirection: 'row' }, ownRow: { justifyContent: 'flex-end' }, + bubble: { maxWidth: '90%', padding: 12, borderRadius: 14, backgroundColor: '#fff', borderWidth: 1, borderColor: '#e5e7eb' }, + ownBubble: { backgroundColor: '#eff9ea', borderColor: '#c7e6bb' }, author: { fontWeight: '700', fontSize: 12, color: '#374151', marginBottom: 4 }, + body: { color: '#111827', fontSize: 15, lineHeight: 21 }, time: { color: '#6b7280', fontSize: 10, marginTop: 6, textAlign: 'right' }, + reactions: { flexDirection: 'row', flexWrap: 'wrap', gap: 6, marginTop: 8 }, reaction: { paddingHorizontal: 9, paddingVertical: 5, borderWidth: 1, borderColor: '#d1d5db', borderRadius: 16, backgroundColor: '#fff' }, + ownReaction: { backgroundColor: '#dcfce7', borderColor: '#69c350' }, info: { padding: 12, gap: 3, backgroundColor: '#f0fdf4' }, + notice: { padding: 12, backgroundColor: '#fffbeb', color: '#92400e', fontSize: 12 }, errorBox: { padding: 10, backgroundColor: '#fef2f2', gap: 5 }, error: { color: '#991b1b', fontSize: 12 }, + link: { color: '#2f6f25', fontWeight: '600', paddingVertical: 5 }, empty: { flex: 1, alignItems: 'center', justifyContent: 'center', gap: 10 }, + composer: { padding: 10, backgroundColor: '#fff', borderTopWidth: 1, borderColor: '#e5e7eb', gap: 8 }, composerRow: { flexDirection: 'row', alignItems: 'flex-end', gap: 6 }, + input: { flex: 1, minHeight: 44, maxHeight: 120, borderWidth: 1, borderColor: '#d1d5db', borderRadius: 12, padding: 10, fontSize: 15, color: '#111827' }, + send: { width: 44, height: 44, borderRadius: 12, backgroundColor: '#438b30', alignItems: 'center', justifyContent: 'center' }, disabled: { opacity: 0.4 }, + attachment: { flexDirection: 'row', alignItems: 'center', gap: 8 }, thumbnail: { width: 52, height: 52 }, loadOlder: { alignItems: 'center', padding: 10 }, + overlay: { flex: 1, justifyContent: 'center', padding: 20, backgroundColor: 'rgba(0,0,0,0.4)' }, reactionPicker: { backgroundColor: '#fff', borderRadius: 16, padding: 16 }, emoji: { padding: 9 }, + imageModal: { flex: 1, backgroundColor: '#111827', paddingTop: 60, paddingBottom: 30 }, closeImage: { alignSelf: 'flex-end', padding: 20 }, white: { color: '#fff' }, fullImage: { flex: 1 }, +}); diff --git a/mobile/hooks/use-chat-conversation.ts b/mobile/hooks/use-chat-conversation.ts new file mode 100644 index 00000000..2c522a4e --- /dev/null +++ b/mobile/hooks/use-chat-conversation.ts @@ -0,0 +1,149 @@ +import { useCallback, useEffect, useRef, useState } from 'react'; + +import { chatApi } from '@/src/lib/chat-api'; +import { + applyChatAccess, chatCursor, isChatAccessError, mergeChatMessages, + type ChatAttachment, type ChatDetail, type ChatPerson, type ChatReactions, + type MobileChatMessage, type MobileChatRoom, +} from '@/src/lib/chat'; + +const errorText = (error: unknown) => error instanceof Error ? error.message : 'Chat konnte nicht geladen werden.'; + +// Each mounted conversation belongs to one session and one room. Late responses cannot +// repopulate an unmounted conversation or a conversation whose access was revoked. +export function useChatConversation(token: string, room: MobileChatRoom, enabled: boolean) { + const [detail, setDetail] = useState(null); + const [messages, setMessages] = useState([]); + const [members, setMembers] = useState([]); + const [reactions, setReactions] = useState({}); + const [loading, setLoading] = useState(true); + const [olderLoading, setOlderLoading] = useState(false); + const [hasMore, setHasMore] = useState(false); + const [sending, setSending] = useState(false); + const [reacting, setReacting] = useState(false); + const [error, setError] = useState(''); + const [reload, setReload] = useState(0); + const generation = useRef(0); + const state = useRef<{ detail: ChatDetail | null; messages: MobileChatMessage[] }>({ detail: null, messages: [] }); + const busy = useRef({ send: false, reaction: false, older: false }); + const reactionRevision = useRef(0); + const roomRef = useRef(room); + roomRef.current = room; + const current = useCallback((version: number) => generation.current === version, []); + const update = useCallback((next: typeof state.current) => { + state.current = next; setDetail(next.detail); setMessages(next.messages); + }, []); + const fail = useCallback((err: unknown, version: number) => { + if (!current(version)) return; + setError(errorText(err)); + if (isChatAccessError(err)) { + ++generation.current; ++reactionRevision.current; + update({ detail: null, messages: [] }); setMembers([]); setReactions({}); + setLoading(false); setSending(false); setReacting(false); setOlderLoading(false); setHasMore(false); + } + }, [current, update]); + + useEffect(() => { + const version = ++generation.current; + const target = roomRef.current; + busy.current = { send: false, reaction: false, older: false }; + setSending(false); setReacting(false); setOlderLoading(false); + if (!enabled) return; + let polling = false; + async function read(nextMessages: MobileChatMessage[]) { + const cursor = chatCursor(target, nextMessages); + if (current(version) && cursor) { + try { await chatApi.read(token, target, cursor); } catch { /* Read receipts do not block the chat. */ } + } + } + async function refreshReactions(nextMessages: MobileChatMessage[]) { + if (busy.current.reaction) return; + const revision = ++reactionRevision.current; + try { + const result = await chatApi.reactions(token, target, nextMessages); + if (current(version) && reactionRevision.current === revision) setReactions(result); + } catch { /* A temporary reaction failure must not hide the conversation. */ } + } + async function load() { + setLoading(true); setError(''); setHasMore(false); + update({ detail: null, messages: [] }); setMembers([]); setReactions({}); + try { + const [nextDetail, page, people] = await Promise.all([ + chatApi.detail(token, target), chatApi.messages(token, target), chatApi.members(token, target), + ]); + if (!current(version)) return; + const nextMessages = target.source === 'collaboration' ? applyChatAccess(page.messages, nextDetail) : page.messages; + update({ detail: nextDetail, messages: nextMessages }); setMembers(people); setHasMore(!!page.hasMore); + await Promise.all([read(nextMessages), refreshReactions(nextMessages)]); + } catch (err) { fail(err, version); } + finally { if (current(version)) setLoading(false); } + } + async function poll() { + if (!current(version) || polling || !state.current.detail || busy.current.older) return; + polling = true; + try { + const [nextDetail, page, people] = await Promise.all([ + chatApi.detail(token, target), chatApi.messages(token, target, { after: chatCursor(target, state.current.messages) }), + chatApi.members(token, target), + ]); + if (!current(version)) return; + const collaboration = target.source === 'collaboration'; + const retained = collaboration ? applyChatAccess(state.current.messages, nextDetail, state.current.detail) : state.current.messages; + let nextMessages = mergeChatMessages(retained, page.messages, collaboration); + if (collaboration) nextMessages = applyChatAccess(nextMessages, nextDetail); + update({ detail: nextDetail, messages: nextMessages }); setMembers(people); setError(''); + await Promise.all([read(nextMessages), refreshReactions(nextMessages)]); + } catch (err) { fail(err, version); } + finally { polling = false; } + } + void load(); + const timer = setInterval(() => void poll(), 3000); + return () => { ++generation.current; ++reactionRevision.current; clearInterval(timer); }; + }, [current, enabled, fail, reload, room.key, token, update]); + + async function send(text: string, image: ChatAttachment | null, clientId: string) { + if (!enabled || !state.current.detail?.canSend || busy.current.send) return false; + const version = generation.current; + const membershipRevision = state.current.detail.revision; + busy.current.send = true; setSending(true); setError(''); + try { + const message = await chatApi.send(token, room, text, image, clientId); + // Clear the matching draft after a successful delivery, even if its room is no + // longer open. Never append a late message after access/history has changed. + if (!current(version) || state.current.detail?.revision !== membershipRevision) return true; + const next = mergeChatMessages(state.current.messages, [message], room.source === 'collaboration'); + update({ ...state.current, messages: next }); + // The send is complete even if the read receipt cannot be delivered. + const cursor = chatCursor(room, next); + if (cursor) void chatApi.read(token, room, cursor).catch(() => undefined); + return true; + } catch (err) { fail(err, version); return false; } + finally { if (current(version)) { busy.current.send = false; setSending(false); } } + } + async function older() { + const before = state.current.messages.find(message => message.seq)?.seq; + if (!enabled || !before || !hasMore || busy.current.older) return; + const version = generation.current; + busy.current.older = true; setOlderLoading(true); + try { + const page = await chatApi.messages(token, room, { before }); + if (!current(version) || !state.current.detail) return; + const next = applyChatAccess(mergeChatMessages(state.current.messages, page.messages, true), state.current.detail); + update({ ...state.current, messages: next }); setHasMore(!!page.hasMore); + } catch (err) { fail(err, version); } + finally { if (current(version)) { busy.current.older = false; setOlderLoading(false); } } + } + async function react(message: MobileChatMessage, emoji: string) { + if (!enabled || !state.current.detail?.canSend || state.current.detail.offline || message.pending || message.failed || busy.current.reaction) return; + const version = generation.current; + ++reactionRevision.current; busy.current.reaction = true; setReacting(true); + try { + const active = !(reactions[message.id] || []).some(item => item.emoji === emoji && item.own); + const result = await chatApi.react(token, room, message.id, emoji, active); + if (current(version)) setReactions(previous => ({ ...previous, [message.id]: result.reactions[message.id] || [] })); + } catch (err) { if (current(version)) setError(errorText(err)); } + finally { if (current(version)) { busy.current.reaction = false; setReacting(false); } } + } + return { detail, messages, members, reactions, loading, olderLoading, hasMore, sending, reacting, error, + send, older, react, retry: () => setReload(value => value + 1) }; +} diff --git a/mobile/package-lock.json b/mobile/package-lock.json index 53d3fd61..da361277 100644 --- a/mobile/package-lock.json +++ b/mobile/package-lock.json @@ -16,11 +16,13 @@ "expo": "~54.0.37", "expo-camera": "~17.0.10", "expo-constants": "~18.0.14", + "expo-crypto": "~15.0.9", "expo-document-picker": "^14.0.8", "expo-file-system": "~19.0.24", "expo-font": "~14.0.12", "expo-haptics": "~15.0.8", "expo-image": "~3.0.11", + "expo-image-manipulator": "~14.0.8", "expo-image-picker": "~17.0.11", "expo-linking": "~8.0.12", "expo-notifications": "~0.32.17", @@ -49,6 +51,7 @@ "@types/react": "~19.1.0", "eslint": "^9.25.0", "eslint-config-expo": "~10.0.0", + "react-test-renderer": "19.1.0", "typescript": "~5.9.2" }, "engines": { @@ -6250,6 +6253,18 @@ "react-native": "*" } }, + "node_modules/expo-crypto": { + "version": "15.0.9", + "resolved": "https://registry.npmjs.org/expo-crypto/-/expo-crypto-15.0.9.tgz", + "integrity": "sha512-SNWKa2fXx7v9gkp1h/7nqXY5XN7qgNDn3yRc2aO0gWGbeMbvob/haMxxsPFe9f51aqH5NjNCqHf2kvLhvAd8KQ==", + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.0" + }, + "peerDependencies": { + "expo": "*" + } + }, "node_modules/expo-document-picker": { "version": "14.0.8", "resolved": "https://registry.npmjs.org/expo-document-picker/-/expo-document-picker-14.0.8.tgz", @@ -6318,6 +6333,18 @@ "expo": "*" } }, + "node_modules/expo-image-manipulator": { + "version": "14.0.8", + "resolved": "https://registry.npmjs.org/expo-image-manipulator/-/expo-image-manipulator-14.0.8.tgz", + "integrity": "sha512-sXsXjm7rIxLWZe0j2A41J/Ph53PpFJRdyzJ3EQ/qetxLUvS2m3K1sP5xy37px43qCf0l79N/i6XgFgenFV36/Q==", + "license": "MIT", + "dependencies": { + "expo-image-loader": "~6.0.0" + }, + "peerDependencies": { + "expo": "*" + } + }, "node_modules/expo-image-picker": { "version": "17.0.11", "resolved": "https://registry.npmjs.org/expo-image-picker/-/expo-image-picker-17.0.11.tgz", @@ -11188,6 +11215,20 @@ } } }, + "node_modules/react-test-renderer": { + "version": "19.1.0", + "resolved": "https://registry.npmjs.org/react-test-renderer/-/react-test-renderer-19.1.0.tgz", + "integrity": "sha512-jXkSl3CpvPYEF+p/eGDLB4sPoDX8pKkYvRl9+rR8HxLY0X04vW7hCm1/0zHoUSjPZ3bDa+wXWNTDVIw/R8aDVw==", + "dev": true, + "license": "MIT", + "dependencies": { + "react-is": "^19.1.0", + "scheduler": "^0.26.0" + }, + "peerDependencies": { + "react": "^19.1.0" + } + }, "node_modules/reflect.getprototypeof": { "version": "1.0.10", "resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz", diff --git a/mobile/package.json b/mobile/package.json index 6172915d..c996acb2 100644 --- a/mobile/package.json +++ b/mobile/package.json @@ -14,7 +14,8 @@ "web": "expo start --web", "lint": "expo lint", "doctor": "npx expo-doctor", - "preflight:testflight": "npm run lint && npm run doctor", + "test": "node --experimental-strip-types --test tests/*.test.mjs", + "preflight:testflight": "npm run lint && npm test && npm run doctor", "build:ios:dev": "eas build --profile development --platform ios", "build:ios:preview": "eas build --profile preview --platform ios", "build:ios:testflight": "eas build --profile testflight --platform ios", @@ -29,11 +30,13 @@ "expo": "~54.0.37", "expo-camera": "~17.0.10", "expo-constants": "~18.0.14", + "expo-crypto": "~15.0.9", "expo-document-picker": "^14.0.8", "expo-file-system": "~19.0.24", "expo-font": "~14.0.12", "expo-haptics": "~15.0.8", "expo-image": "~3.0.11", + "expo-image-manipulator": "~14.0.8", "expo-image-picker": "~17.0.11", "expo-linking": "~8.0.12", "expo-notifications": "~0.32.17", @@ -62,6 +65,7 @@ "@types/react": "~19.1.0", "eslint": "^9.25.0", "eslint-config-expo": "~10.0.0", + "react-test-renderer": "19.1.0", "typescript": "~5.9.2" }, "private": true diff --git a/mobile/src/lib/chat-api.ts b/mobile/src/lib/chat-api.ts new file mode 100644 index 00000000..c27cabb3 --- /dev/null +++ b/mobile/src/lib/chat-api.ts @@ -0,0 +1,4 @@ +import { apiRequest } from '@/src/lib/api'; +import { createChatApi } from '@/src/lib/chat'; + +export const chatApi = createChatApi(apiRequest); diff --git a/mobile/src/lib/chat-image.ts b/mobile/src/lib/chat-image.ts new file mode 100644 index 00000000..20fb01c6 --- /dev/null +++ b/mobile/src/lib/chat-image.ts @@ -0,0 +1,30 @@ +import { deleteAsync } from 'expo-file-system/legacy'; +import { ImageManipulator, SaveFormat } from 'expo-image-manipulator'; +import type { ImagePickerAsset } from 'expo-image-picker'; + +import type { ChatAttachment } from '@/src/lib/chat'; + +export async function prepareMobileChatImage(asset: ImagePickerAsset): Promise { + if ((asset.fileSize || 0) > 10 * 1024 * 1024) throw new Error('Das Originalbild darf höchstens 10 MB groß sein.'); + if (!asset.width || !asset.height) throw new Error('Das Bild konnte nicht gelesen werden.'); + // Convert camera formats such as HEIC to a bounded raster before sending to FEDEO. + for (const limit of [1600, 1000]) { + const context = ImageManipulator.manipulate(asset.uri); + const scale = Math.min(1, limit / Math.max(asset.width, asset.height)); + context.resize({ width: Math.max(1, Math.round(asset.width * scale)), height: Math.max(1, Math.round(asset.height * scale)) }); + try { + const image = await context.renderAsync(); + try { + for (const compress of [0.8, 0.55, 0.35]) { + const result = await image.saveAsync({ format: SaveFormat.JPEG, compress, base64: true }); + await deleteAsync(result.uri, { idempotent: true }).catch(() => undefined); + if (result.base64 && result.base64.length <= 480_000) return { + dataUrl: `data:image/jpeg;base64,${result.base64}`, + name: `${(asset.fileName || 'Bild').replace(/\.[^.]+$/, '').slice(0, 195)}.jpg`, + }; + } + } finally { image.release(); } + } finally { context.release(); } + } + throw new Error('Dieses Bild ist zu groß. Bitte einen kleineren Ausschnitt auswählen.'); +} diff --git a/mobile/src/lib/chat.ts b/mobile/src/lib/chat.ts new file mode 100644 index 00000000..f5c15d8e --- /dev/null +++ b/mobile/src/lib/chat.ts @@ -0,0 +1,164 @@ +export type ChatImage = { name: string; width: number; height: number }; +export type ChatAttachment = { name: string; dataUrl: string }; +export type ChatReaction = { emoji: string; count: number; own: boolean }; +export type ChatReactions = Record; +export type MobileChatRoom = { + key: string; id?: string; name: string; topic?: string | null; + source: 'internal' | 'collaboration'; group: string; exists: boolean; + type?: string; projectId?: number; projectNumber?: string | null; userId?: string; email?: string | null; + ownerName?: string; remote?: boolean; status?: string; unread?: number; +}; +export type ChatInvitation = { + id: string; name: string; ownerName?: string; role: string; + overviewAllowed?: boolean; remote?: boolean; expiresAt?: string; +}; +export type ChatDetail = { + name: string; topic?: string | null; canSend: boolean; status?: string; + offline?: boolean; historyFromSeq?: number; revision?: number; + overview?: { title?: string; status?: string; date?: string; description?: string } | null; +}; +export type MobileChatMessage = { + id: string | number; clientId?: string; seq?: number | null; body?: string | null; + own?: boolean; senderDisplayName?: string | null; senderName?: string; + timestamp?: string | number | null; createdAt?: string; + image?: ChatImage | null; pending?: boolean; failed?: boolean; +}; +export type ChatPerson = { name: string; own?: boolean; kind?: string }; +export type ChatPage = { messages: MobileChatMessage[]; hasMore?: boolean }; +export type ChatDirectory = { + rooms: MobileChatRoom[]; hiddenRoomKeys: string[]; invitations: ChatInvitation[]; + canManage: boolean; projects: { id: number; name: string }[]; +}; +type NativeRoom = Omit; +type CollaborationRoom = Omit & { id: string }; +type CollaborationList = { + rooms: CollaborationRoom[]; invitations: ChatInvitation[]; + canManage?: boolean; projects?: ChatDirectory['projects']; +}; +type Request = (path: string, options: { + token: string; method?: 'GET' | 'POST' | 'PUT'; body?: unknown; +}) => Promise; + +export const reactionEmojis = ['👍', '❤️', '😂', '🎉', '😮', '😢', '🙏', '🔥', '✅', '👎']; +export const collaborationKey = (id: string) => `collaboration:${id}`; +export const chatEndpoint = (room: MobileChatRoom, suffix = '') => room.source === 'collaboration' + ? `/api/collaboration/rooms/${encodeURIComponent(room.id!)}${suffix}` + : `/api/communication/chat/rooms/${encodeURIComponent(room.key)}${suffix}`; +export const visibleChatRooms = (directory: ChatDirectory) => directory.rooms.filter(room => !directory.hiddenRoomKeys.includes(room.key)); +export const chatCursor = (room: MobileChatRoom, messages: MobileChatMessage[]) => Math.max(0, + ...messages.map(message => room.source === 'collaboration' ? message.seq || 0 : Number(message.id))); +export function mergeChatMessages(current: MobileChatMessage[], incoming: MobileChatMessage[], collaboration: boolean) { + const byId = new Map(current.map(message => [message.id, message])); + for (const message of incoming) { + // A federation acknowledgement replaces its queued copy even when the server ID changes. + if (collaboration && message.own && message.clientId) { + if (!message.seq && [...byId.values()].some(old => old.own && old.seq && old.clientId === message.clientId)) continue; + if (message.seq) for (const [id, old] of byId) { + if (old.own && !old.seq && old.clientId === message.clientId) byId.delete(id); + } + } + byId.set(message.id, message); + } + return [...byId.values()].sort((a, b) => collaboration + ? (a.seq || Number.MAX_SAFE_INTEGER) - (b.seq || Number.MAX_SAFE_INTEGER) + : Number(a.id) - Number(b.id)); +} +export function applyChatAccess(messages: MobileChatMessage[], detail: ChatDetail, previous?: ChatDetail | null) { + return messages.filter(message => message.seq + ? message.seq >= (detail.historyFromSeq || 0) + : !previous || previous.revision === detail.revision); +} +export function isChatAccessError(error: unknown) { + return typeof error === 'object' && error !== null && 'status' in error && [401, 403, 404, 410].includes(Number(error.status)); +} +export function validChatImage(dataUrl: unknown): dataUrl is string { + return typeof dataUrl === 'string' && dataUrl.length <= 700_000 + && /^data:image\/(webp|png|jpeg);base64,[A-Za-z0-9+/]+={0,2}$/.test(dataUrl); +} +export function matchesChatSearch(room: MobileChatRoom, search: string) { + const normalize = (text: string) => text.toLowerCase().normalize('NFKD').replace(/[\u0300-\u036f]/g, '').replace(/ß/g, 'ss'); + const text = normalize([room.name, room.topic, room.projectNumber, room.email, room.ownerName].filter(Boolean).join(' ')); + return normalize(search).trim().split(/\s+/).every(term => text.includes(term)); +} + +export function createChatApi(request: Request) { + const preferences = '/api/communication/chat/room-preferences'; + return { + async directory(token: string): Promise { + // Do not expose any rooms before the server's visibility preferences are available. + const [base, projects, direct, unread, hidden, collaboration] = await Promise.all([ + request<{ rooms: NativeRoom[] }>('/api/communication/chat/rooms', { token }), + request<{ rooms: NativeRoom[] }>('/api/communication/chat/project-rooms', { token }), + request<{ rooms: NativeRoom[] }>('/api/communication/chat/direct-rooms', { token }), + request<{ rooms: Record }>('/api/communication/chat/unread', { token }), + request<{ hiddenRoomKeys: string[] }>(preferences, { token }), + request('/api/collaboration', { token }), + ]); + const decorate = (rooms: NativeRoom[], group: string): MobileChatRoom[] => rooms.map(room => ({ + ...room, source: 'internal', group, unread: unread.rooms?.[room.key]?.count || 0, + })); + return { + rooms: [...decorate(base.rooms, 'Räume'), ...decorate(projects.rooms, 'Projekte'), ...decorate(direct.rooms, 'Direkt'), + ...collaboration.rooms.map(room => ({ ...room, key: collaborationKey(room.id), + source: 'collaboration' as const, group: 'Zusammenarbeit', exists: true }))], + hiddenRoomKeys: hidden.hiddenRoomKeys, invitations: collaboration.invitations, + canManage: collaboration.canManage === true, projects: collaboration.projects || [], + }; + }, + async unread(token: string) { + const [native, hidden, collaboration] = await Promise.all([ + request<{ rooms: Record }>('/api/communication/chat/unread', { token }), + request<{ hiddenRoomKeys: string[] }>(preferences, { token }), + request('/api/collaboration', { token }), + ]); + const counts = [...Object.entries(native.rooms).map(([key, value]) => ({ key, count: value.count || 0 })), + ...collaboration.rooms.map(room => ({ key: collaborationKey(room.id), count: room.unread || 0 }))]; + return counts.filter(room => !hidden.hiddenRoomKeys.includes(room.key)).reduce((sum, room) => sum + room.count, 0); + }, + hide: (token: string, roomKey: string, hidden: boolean) => request(preferences, { token, method: 'PUT', body: { roomKey, hidden } }), + respond: (token: string, id: string, accept: boolean) => request<{ roomId?: string }>( + `/api/collaboration/invitations/${encodeURIComponent(id)}/${accept ? 'accept' : 'decline'}`, { token, method: 'POST' }), + async provision(token: string, room: MobileChatRoom): Promise { + if (room.exists || room.source === 'collaboration') return room; + const path = room.type === 'project' ? `/api/communication/chat/project-rooms/${room.projectId}/provision` + : `/api/communication/chat/direct-rooms/${encodeURIComponent(room.userId!)}/provision`; + return { ...room, ...await request(path, { token, method: 'POST' }), source: 'internal' }; + }, + detail: (token: string, room: MobileChatRoom) => room.source === 'collaboration' + ? request(chatEndpoint(room), { token }) + : Promise.resolve({ name: room.name, topic: room.topic, canSend: true }), + async members(token: string, room: MobileChatRoom): Promise { + const result = await request<{ members: (ChatPerson & { displayName?: string })[] }>(chatEndpoint(room, '/members'), { token }); + return result.members.map(member => ({ ...member, name: member.name || member.displayName || 'Benutzer' })); + }, + messages(token: string, room: MobileChatRoom, cursor?: { after?: number; before?: number }) { + const suffix = room.source === 'collaboration' + ? `/messages${cursor?.before ? `?before=${cursor.before}` : cursor?.after ? `?after=${cursor.after}` : ''}` + : cursor?.after ? `/sync?afterId=${cursor.after}` : '/messages'; + return request(chatEndpoint(room, suffix), { token }); + }, + send: (token: string, room: MobileChatRoom, text: string, image: ChatAttachment | null, clientId: string) => request( + chatEndpoint(room, '/messages'), { token, method: 'POST', body: { text, ...(image ? { image } : {}), + ...(room.source === 'collaboration' ? { clientId } : {}) } }), + read: (token: string, room: MobileChatRoom, cursor: number) => request(chatEndpoint(room, '/read'), { + token, method: 'POST', body: room.source === 'collaboration' ? { seq: cursor } : { messageId: cursor }, + }), + async image(token: string, room: MobileChatRoom, id: MobileChatMessage['id']) { + const result = await request<{ dataUrl: string }>(chatEndpoint(room, `/image?messageId=${encodeURIComponent(id)}`), { token }); + if (!validChatImage(result.dataUrl)) throw new Error('Ungültiges Chatbild.'); + return result.dataUrl; + }, + async reactions(token: string, room: MobileChatRoom, messages: MobileChatMessage[]): Promise { + const ids = messages.filter(message => !message.pending && !message.failed).map(message => message.id); + const reactions: ChatReactions = {}; + for (let index = 0; index < ids.length; index += 100) { + const result = await request<{ reactions: ChatReactions }>(chatEndpoint(room, + `/reactions?ids=${encodeURIComponent(ids.slice(index, index + 100).join(','))}`), { token }); + Object.assign(reactions, result.reactions); + } + return reactions; + }, + react: (token: string, room: MobileChatRoom, messageId: MobileChatMessage['id'], emoji: string, active: boolean) => request<{ reactions: ChatReactions }>( + chatEndpoint(room, '/reactions'), { token, method: 'PUT', body: { messageId, emoji, active } }), + }; +} diff --git a/mobile/tests/chat-conversation.test.mjs b/mobile/tests/chat-conversation.test.mjs new file mode 100644 index 00000000..1372d086 --- /dev/null +++ b/mobile/tests/chat-conversation.test.mjs @@ -0,0 +1,121 @@ +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import { createRequire } from 'node:module'; +import { test } from 'node:test'; +import React from 'react'; +import { act, create } from 'react-test-renderer'; +import ts from 'typescript'; +import * as chatModel from '../src/lib/chat.ts'; + +globalThis.IS_REACT_ACT_ENVIRONMENT = true; +const require = createRequire(import.meta.url); +const remote = { key: 'collaboration:a', id: 'a', source: 'collaboration', name: 'A', exists: true }; +const detail = { name: 'A', canSend: true, historyFromSeq: 1, revision: 1 }; +const deferred = () => { let resolve, reject; const promise = new Promise((yes, no) => { resolve = yes; reject = no; }); return { promise, resolve, reject }; }; + +function harness(overrides = {}) { + const reads = [], sends = [], timers = new Map(); + const api = { + detail: async () => detail, messages: async () => ({ messages: [{ id: 'first', seq: 1, body: 'Start' }] }), + members: async () => [{ name: 'Partner' }], reactions: async () => ({}), + read: async (...args) => { reads.push(args); }, + send: async (...args) => { sends.push(args); return { id: 'sent', seq: 2, own: true }; }, + react: async () => ({ reactions: {} }), ...overrides, + }; + const output = ts.transpileModule(readFileSync(new URL('../hooks/use-chat-conversation.ts', import.meta.url), 'utf8'), { + compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022 }, + }).outputText; + const exports = {}; + new Function('require', 'exports', 'setInterval', 'clearInterval', output)(name => { + if (name === '@/src/lib/chat-api') return { chatApi: api }; + if (name === '@/src/lib/chat') return chatModel; + return require(name); + }, exports, callback => { const id = Symbol(); timers.set(id, callback); return id; }, id => timers.delete(id)); + let latest, root; + function Probe(props) { latest = exports.useChatConversation(props.token || 'token', props.room, props.enabled ?? true); return null; } + return { + api, reads, sends, get state() { return latest; }, + async render(room = remote, enabled = true, token = 'token') { + await act(async () => { + const element = React.createElement(Probe, { key: `${token}:${room.key}`, room, enabled, token }); + if (root) root.update(element); else root = create(element); + }); + }, + async poll() { await act(async () => { for (const callback of timers.values()) callback(); }); }, + async dispose() { await act(async () => root?.unmount()); }, + }; +} + +test('Verspätete Antworten des vorherigen Raums werden nach Raumwechsel verworfen', async () => { + const pending = deferred(); + const h = harness({ detail: async (_token, room) => room.id === 'a' ? pending.promise : { ...detail, name: 'B' }, + messages: async (_token, room) => ({ messages: [{ id: room.id, seq: 1, body: room.name }] }) }); + try { + await h.render(); + await h.render({ ...remote, key: 'collaboration:b', id: 'b', name: 'B' }); + await act(async () => pending.resolve(detail)); + assert.equal(h.state.detail.name, 'B'); + assert.equal(h.state.messages[0].body, 'B'); + assert.ok(h.reads.every(([, room]) => room.id === 'b')); + } finally { await h.dispose(); } +}); +test('Mandantenwechsel verwirft laufende Antworten der alten Sitzung', async () => { + const pending = deferred(); + const h = harness({ messages: async token => token === 'old' ? pending.promise : { messages: [{ id: 'new', seq: 3 }] } }); + try { + await h.render(remote, true, 'old'); + await h.render(remote, true, 'new'); + await act(async () => pending.resolve({ messages: [{ id: 'private-old', seq: 9 }] })); + assert.deepEqual(h.state.messages.map(message => message.id), ['new']); + assert.ok(h.reads.every(([token]) => token === 'new')); + } finally { await h.dispose(); } +}); +test('Entzogener Zugriff entfernt Inhalt und ignoriert danach eintreffende Sendeantworten', async () => { + const pending = deferred(); let forbidden = false; + const h = harness({ detail: async () => { if (forbidden) throw Object.assign(new Error('Zugriff entzogen'), { status: 403 }); return detail; }, send: () => pending.promise }); + try { + await h.render(); + let send; + await act(async () => { send = h.state.send('Hallo', null, 'stable'); }); + forbidden = true; await h.poll(); + assert.equal(h.state.detail, null); + assert.deepEqual(h.state.messages, []); assert.deepEqual(h.state.members, []); + await act(async () => pending.resolve({ id: 'late', seq: 7 })); + assert.equal(await send, true); // The old draft can be cleared, but no content reappears. + assert.deepEqual(h.state.messages, []); + assert.equal(await h.state.send('Nicht erlaubt', null, 'blocked'), false); + } finally { await h.dispose(); } +}); +test('Neue Leserechte und Historiengrenzen werden während des Pollings übernommen', async () => { + let revision = 1; + const h = harness({ detail: async () => ({ ...detail, revision, canSend: revision === 1, historyFromSeq: revision === 1 ? 1 : 5 }), + messages: async () => ({ messages: revision === 1 ? [{ id: 'old', seq: 1 }, { id: 'allowed', seq: 5 }, { id: 'queued', seq: null, pending: true }] : [] }) }); + try { + await h.render(); revision = 2; await h.poll(); + assert.deepEqual(h.state.messages.map(message => message.id), ['allowed']); + assert.equal(h.state.detail.canSend, false); + assert.equal(await h.state.send('Gesperrt', null, 'no-send'), false); + assert.equal(h.sends.length, 0); + } finally { await h.dispose(); } +}); +test('Doppelklick sendet nur einmal; fehlgeschlagene Nachrichten können mit derselben ID wiederholt werden', async () => { + const pending = deferred(), ids = []; let attempt = 0; + const h = harness({ send: (_token, _room, _text, _image, clientId) => { ids.push(clientId); return ++attempt === 1 ? pending.promise : Promise.resolve({ id: 'sent', seq: 2 }); } }); + try { + await h.render(); let first, second; + await act(async () => { first = h.state.send('Hallo', null, 'same-id'); second = h.state.send('Hallo', null, 'same-id'); }); + assert.equal(await second, false); assert.equal(ids.length, 1); + await act(async () => pending.reject(new Error('Netzwerkfehler'))); + assert.equal(await first, false); + await act(async () => assert.equal(await h.state.send('Hallo', null, 'same-id'), true)); + assert.deepEqual(ids, ['same-id', 'same-id']); + } finally { await h.dispose(); } +}); +test('Im Hintergrund werden keine Nachrichten abgefragt oder als gelesen markiert', async () => { + const h = harness(); + try { + await h.render(remote, false); await h.poll(); assert.equal(h.reads.length, 0); + await h.render(remote, true); assert.equal(h.reads.length, 1); + await h.render(remote, false); await h.poll(); assert.equal(h.reads.length, 1); + } finally { await h.dispose(); } +}); diff --git a/mobile/tests/chat-picker.test.mjs b/mobile/tests/chat-picker.test.mjs new file mode 100644 index 00000000..276b9de3 --- /dev/null +++ b/mobile/tests/chat-picker.test.mjs @@ -0,0 +1,56 @@ +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import { createRequire } from 'node:module'; +import { test } from 'node:test'; +import React from 'react'; +import { act, create } from 'react-test-renderer'; +import ts from 'typescript'; +import * as chatModel from '../src/lib/chat.ts'; + +globalThis.IS_REACT_ACT_ENVIRONMENT = true; +const require = createRequire(import.meta.url); +function harness() { + let resolvePicker; + const picker = new Promise(resolve => { resolvePicker = resolve; }); + const drafts = []; + const chat = { detail: { name: 'Test', canSend: true }, messages: [], members: [], reactions: {}, loading: false }; + const output = ts.transpileModule(readFileSync(new URL('../components/chat-conversation.tsx', import.meta.url), 'utf8'), { + compilerOptions: { module: ts.ModuleKind.CommonJS, jsx: ts.JsxEmit.ReactJSX, target: ts.ScriptTarget.ES2022 }, + }).outputText; + const exports = {}; + new Function('require', 'exports', output)(name => { + if (name === 'react-native') return { ...Object.fromEntries(['ActivityIndicator', 'FlatList', 'Modal', 'Pressable', 'ScrollView', 'Text', 'TextInput', 'View'].map(key => [key, key])), StyleSheet: { create: value => value } }; + if (name === '@expo/vector-icons') return { Ionicons: 'Icon' }; + if (name === 'expo-image') return { Image: 'Image' }; + if (name === 'expo-image-picker') return { launchImageLibraryAsync: () => picker }; + if (name === '@/hooks/use-chat-conversation') return { useChatConversation: () => chat }; + if (name === '@/src/lib/chat-api') return { chatApi: {} }; + if (name === '@/src/lib/chat-image') return { prepareMobileChatImage: async () => ({ name: 'Bild.jpg', dataUrl: 'data:image/jpeg;base64,YQ==' }) }; + if (name === '@/src/lib/chat') return chatModel; + return require(name); + }, exports); + const props = { token: 't', room: { key: 'test', name: 'Test' }, enabled: true, + draft: { text: 'Bild dazu', image: null, clientId: 'draft' }, onDraft: draft => drafts.push(draft) }; + let root; + return { + drafts, + async render(enabled) { await act(async () => { const element = React.createElement(exports.ChatConversation, { ...props, enabled }); if (root) root.update(element); else root = create(element); }); }, + async pick() { await act(async () => root.root.findByProps({ accessibilityLabel: 'Bild anhängen' }).props.onPress()); }, + async finish() { await act(async () => resolvePicker({ canceled: false, assets: [{ uri: 'file:///test.jpg' }] })); }, + async dispose() { await act(async () => root.unmount()); }, + }; +} +test('System-Fotoauswahl darf die App vorübergehend in den Hintergrund versetzen', async () => { + const h = harness(); + try { + await h.render(true); await h.pick(); await h.render(false); await h.finish(); + assert.equal(h.drafts.length, 1); + assert.equal(h.drafts[0].text, 'Bild dazu'); + assert.equal(h.drafts[0].image.name, 'Bild.jpg'); + } finally { await h.dispose(); } +}); +test('Eine verspätete Fotoauswahl verändert nach Raumwechsel keinen anderen Entwurf', async () => { + const h = harness(); + await h.render(true); await h.pick(); await h.dispose(); await h.finish(); + assert.equal(h.drafts.length, 0); +}); diff --git a/mobile/tests/chat.test.mjs b/mobile/tests/chat.test.mjs new file mode 100644 index 00000000..ab137ed9 --- /dev/null +++ b/mobile/tests/chat.test.mjs @@ -0,0 +1,133 @@ +import assert from 'node:assert/strict'; +import { test } from 'node:test'; +import { + applyChatAccess, chatCursor, chatEndpoint, collaborationKey, createChatApi, matchesChatSearch, + mergeChatMessages, validChatImage, visibleChatRooms, +} from '../src/lib/chat.ts'; + +const id = '11111111-1111-4111-8111-111111111111'; +const internal = { key: 'allgemein', name: 'Allgemein', source: 'internal', exists: true }; +const remote = { key: collaborationKey(id), id, name: 'Partner', source: 'collaboration', exists: true }; +function fixture(overrides = {}) { + const responses = { + '/api/communication/chat/rooms': { rooms: [internal] }, + '/api/communication/chat/project-rooms': { rooms: [{ key: 'project_42', name: 'Projekt', exists: true }] }, + '/api/communication/chat/direct-rooms': { rooms: [] }, + '/api/communication/chat/unread': { rooms: { allgemein: { count: 7 }, project_42: { count: 2 } } }, + '/api/communication/chat/room-preferences': { hiddenRoomKeys: ['allgemein', collaborationKey(id)] }, + '/api/collaboration': { rooms: [{ id, name: 'Partner', remote: true, unread: 11 }], invitations: [{ id: 'invite', name: 'Team' }], + managedRooms: [{ id: 'not-a-member', name: 'Verwalteter Raum ohne Teilnahme' }], canManage: true, projects: [{ id: 42, name: 'Projekt' }] }, + ...overrides, + }; + const calls = []; + return { calls, api: createChatApi(async (path, options) => { + calls.push({ path, ...options }); + if (!(path in responses)) throw new Error(`Unexpected request: ${path}`); + if (responses[path] instanceof Error) throw responses[path]; + return structuredClone(responses[path]); + }) }; +} + +test('Web-Sichtbarkeit gilt auch für föderierte Räume und den Ungelesen-Zähler', async () => { + const { api, calls } = fixture(); + const directory = await api.directory('tenant-token'); + assert.deepEqual(visibleChatRooms(directory).map(room => room.key), ['project_42']); + assert.equal(directory.rooms.length, 3); // No implicit access via managedRooms. + assert.equal(directory.rooms[2].source, 'collaboration'); + assert.equal(directory.rooms[2].remote, true); + assert.equal(directory.invitations[0].id, 'invite'); + assert.equal(await api.unread('tenant-token'), 2); + assert.ok(calls.every(call => call.token === 'tenant-token')); +}); +test('Fehlende Sichtbarkeitseinstellungen zeigen keine ungefilterte Chatliste', async () => { + const { api } = fixture({ '/api/communication/chat/room-preferences': new Error('offline') }); + await assert.rejects(api.directory('token'), /offline/); + await assert.rejects(api.unread('token'), /offline/); +}); +test('Föderierte Endpunkte, Cursor und Lesebestätigungen verwenden Raum-ID und Sequenz', async () => { + const calls = []; + const api = createChatApi(async (path, options) => { calls.push({ path, ...options }); return { messages: [] }; }); + await api.messages('t', remote, { after: 50 }); + await api.messages('t', remote, { before: 20 }); + await api.read('t', remote, 60); + await api.messages('t', internal, { after: 12 }); + await api.read('t', internal, 14); + assert.equal(calls[0].path, `/api/collaboration/rooms/${id}/messages?after=50`); + assert.equal(calls[1].path, `/api/collaboration/rooms/${id}/messages?before=20`); + assert.deepEqual(calls[2].body, { seq: 60 }); + assert.equal(calls[3].path, '/api/communication/chat/rooms/allgemein/sync?afterId=12'); + assert.deepEqual(calls[4].body, { messageId: 14 }); + assert.equal(chatCursor(remote, [{ id: 'pending', seq: null }, { id: 'uuid', seq: 7 }]), 7); +}); +test('Bestätigungen ersetzen wartende Nachrichten ohne doppelte oder verspätete Kopien', () => { + const pending = { id: 'client-1', clientId: 'client-1', own: true, pending: true, seq: null }; + const delivered = { id: 'server-1', clientId: 'client-1', own: true, seq: 9 }; + const first = mergeChatMessages([pending], [delivered], true); + assert.deepEqual(first, [delivered]); + assert.deepEqual(mergeChatMessages(first, [pending], true), [delivered]); + assert.deepEqual(mergeChatMessages([], [delivered, pending], true), [delivered]); + assert.deepEqual(mergeChatMessages([{ id: 2 }], [{ id: 1 }, { id: 2, body: 'aktualisiert' }], false), [{ id: 1 }, { id: 2, body: 'aktualisiert' }]); +}); +test('Geänderte Teilnahmerechte entfernen frühere Historie und alte ausstehende Nachrichten', () => { + const messages = [{ id: 'old', seq: 1 }, { id: 'new', seq: 8 }, { id: 'pending', seq: null }]; + assert.deepEqual(applyChatAccess(messages, { historyFromSeq: 5, revision: 2 }, { revision: 1 }), [{ id: 'new', seq: 8 }]); + assert.equal(applyChatAccess(messages, { historyFromSeq: 5, revision: 1 }, { revision: 1 }).length, 2); +}); +test('Senden behält die Idempotenz-ID und überträgt Bilder an die passende Schnittstelle', async () => { + const calls = []; + const api = createChatApi(async (path, options) => { calls.push({ path, ...options }); return {}; }); + const image = { name: 'Foto.jpg', dataUrl: 'data:image/jpeg;base64,YQ==' }; + await api.send('t', remote, '', image, 'same-client-id'); + await api.send('t', remote, '', image, 'same-client-id'); + await api.send('t', internal, 'Hallo', null, 'unused'); + assert.deepEqual(calls[0], calls[1]); + assert.deepEqual(calls[0].body, { text: '', image, clientId: 'same-client-id' }); + assert.deepEqual(calls[2].body, { text: 'Hallo' }); +}); +test('Einladungen und ausgeblendete Räume verwenden dieselben Schlüssel wie das Web', async () => { + const calls = []; + const api = createChatApi(async (path, options) => { calls.push({ path, ...options }); return {}; }); + await api.respond('t', id, true); await api.respond('t', id, false); + await api.hide('t', remote.key, true); await api.hide('t', remote.key, false); + assert.equal(calls[0].path, `/api/collaboration/invitations/${id}/accept`); + assert.equal(calls[1].path, `/api/collaboration/invitations/${id}/decline`); + assert.deepEqual(calls[2].body, { roomKey: collaborationKey(id), hidden: true }); + assert.equal(calls[3].body.hidden, false); +}); +test('Reaktionen werden begrenzt geladen; wartende Nachrichten bleiben ausgeschlossen', async () => { + const calls = []; + const api = createChatApi(async (path, options) => { calls.push({ path, ...options }); return { reactions: {} }; }); + const messages = Array.from({ length: 205 }, (_, index) => ({ id: index + 1 })); + messages.push({ id: 'pending', pending: true }, { id: 'failed', failed: true }); + await api.reactions('t', remote, messages); + assert.equal(calls.length, 3); + for (const call of calls) { + const ids = new URL(call.path, 'https://example.invalid').searchParams.get('ids').split(','); + assert.ok(ids.length <= 100); assert.ok(ids.every(value => /^\d+$/.test(value))); + } + await api.react('t', remote, id, '👍', false); + assert.deepEqual(calls[3].body, { messageId: id, emoji: '👍', active: false }); +}); +test('Chatbilder akzeptieren keine externen URLs, SVGs oder übergroßen Inhalte', async () => { + for (const value of ['https://tracker.invalid/image.png', 'data:image/svg+xml;base64,YQ==', `data:image/png;base64,${'a'.repeat(700_000)}`]) { + assert.equal(validChatImage(value), false); + const api = createChatApi(async () => ({ dataUrl: value })); + await assert.rejects(api.image('t', remote, id), /Ungültiges Chatbild/); + } + assert.equal(validChatImage('data:image/webp;base64,YQ=='), true); +}); +test('Suche berücksichtigt mehrere Begriffe, Umlaute und Partnernamen', () => { + assert.equal(matchesChatSearch({ name: 'Büro Süd', ownerName: 'Straße GmbH' }, 'buro strasse'), true); + assert.equal(matchesChatSearch({ name: 'Büro Süd', ownerName: 'Straße GmbH' }, 'buro nord'), false); +}); +test('Noch nicht angelegte Direkt- und Projektchats werden gezielt provisioniert', async () => { + const calls = []; + const api = createChatApi(async (path, options) => { calls.push({ path, ...options }); return { exists: true }; }); + await api.provision('t', { ...internal, exists: false, type: 'project', projectId: 42 }); + await api.provision('t', { ...internal, exists: false, type: 'direct', userId: 'user/id' }); + await api.provision('t', remote); + assert.equal(calls.length, 2); + assert.equal(calls[0].path, '/api/communication/chat/project-rooms/42/provision'); + assert.equal(calls[1].path, '/api/communication/chat/direct-rooms/user%2Fid/provision'); + assert.equal(chatEndpoint(remote), `/api/collaboration/rooms/${id}`); +}); From e0a94f5e2961d720e68bf61a767fbdf80ef1dcc8 Mon Sep 17 00:00:00 2001 From: florianfederspiel Date: Mon, 19 Oct 2026 11:44:07 +0200 Subject: [PATCH 6/9] =?UTF-8?q?fix(mobile):=20KI-AGENT:=20Neu=20angelegte?= =?UTF-8?q?=20Chats=20vor=20veralteten=20Listen=20sch=C3=BCtzen?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- mobile/app/(tabs)/communication.tsx | 2 ++ mobile/tests/chat-directory.test.mjs | 53 ++++++++++++++++++++++++++++ 2 files changed, 55 insertions(+) create mode 100644 mobile/tests/chat-directory.test.mjs diff --git a/mobile/app/(tabs)/communication.tsx b/mobile/app/(tabs)/communication.tsx index 8547710b..15aa34a8 100644 --- a/mobile/app/(tabs)/communication.tsx +++ b/mobile/app/(tabs)/communication.tsx @@ -92,6 +92,8 @@ function ChatScreen({ token }: { token: string }) { setBusy(room.key); const ready = await chatApi.provision(token, room); if (!alive.current || version !== selectionRevision.current || directoryRef.current?.hiddenRoomKeys.includes(room.key)) return; + // A list requested before provisioning must not replace the new room with its placeholder. + ++directoryRevision.current; setDirectory(previous => previous && ({ ...previous, rooms: previous.rooms.map(item => item.key === room.key ? ready : item) })); setActiveKey(ready.key); } catch (err) { if (alive.current && version === selectionRevision.current) setError(err instanceof Error ? err.message : 'Chat konnte nicht geöffnet werden.'); } diff --git a/mobile/tests/chat-directory.test.mjs b/mobile/tests/chat-directory.test.mjs new file mode 100644 index 00000000..71de678a --- /dev/null +++ b/mobile/tests/chat-directory.test.mjs @@ -0,0 +1,53 @@ +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import { createRequire } from 'node:module'; +import { test } from 'node:test'; +import React from 'react'; +import { act, create } from 'react-test-renderer'; +import ts from 'typescript'; +import * as chatModel from '../src/lib/chat.ts'; + +globalThis.IS_REACT_ACT_ENVIRONMENT = true; +const require = createRequire(import.meta.url); +const deferred = () => { let resolve; const promise = new Promise(yes => { resolve = yes; }); return { promise, resolve }; }; + +test('Eine verspätete Raumliste verdrängt keinen gerade angelegten Projektchat', async () => { + const project = { key: 'project_42', name: 'Projekt', source: 'internal', type: 'project', exists: false, projectId: 42 }; + const directory = { rooms: [{ key: 'allgemein', name: 'Allgemein', source: 'internal', exists: true }, project], hiddenRoomKeys: [], invitations: [], projects: [] }; + const provision = deferred(), list = deferred(), timers = new Map(); + let directoryCalls = 0; + const api = { directory: () => ++directoryCalls === 1 ? Promise.resolve(directory) : list.promise, provision: () => provision.promise }; + const output = ts.transpileModule(readFileSync(new URL('../app/(tabs)/communication.tsx', import.meta.url), 'utf8'), { + compilerOptions: { module: ts.ModuleKind.CommonJS, jsx: ts.JsxEmit.ReactJSX, target: ts.ScriptTarget.ES2022 }, + }).outputText; + const exports = {}; + new Function('require', 'exports', 'setInterval', 'clearInterval', output)(name => { + if (name === 'react-native') return { + ...Object.fromEntries(['ActivityIndicator', 'FlatList', 'KeyboardAvoidingView', 'Modal', 'Pressable', 'ScrollView', 'Text', 'TextInput', 'View'].map(key => [key, key])), + StyleSheet: { create: value => value }, Platform: { OS: 'ios' }, DeviceEventEmitter: { emit() {} }, + AppState: { currentState: 'active', addEventListener: () => ({ remove() {} }) }, + }; + if (name === '@expo/vector-icons') return { Ionicons: 'Icon' }; + if (name === '@react-navigation/native') return { useIsFocused: () => true }; + if (name === 'expo-crypto') return { randomUUID: () => 'draft-id' }; + if (name === '@/components/chat-conversation') return { ChatConversation: 'Conversation' }; + if (name === '@/src/lib/api') return {}; + if (name === '@/src/lib/chat-api') return { chatApi: api }; + if (name === '@/src/lib/chat') return chatModel; + if (name === '@/src/lib/server-config') return { getApiBaseUrlSync: () => 'https://test.invalid' }; + if (name === '@/src/providers/auth-provider') return { useAuth: () => ({ token: 'test', activeTenantId: 1 }) }; + return require(name); + }, exports, callback => { const id = Symbol(); timers.set(id, callback); return id; }, id => timers.delete(id)); + let root; + try { + await act(async () => { root = create(React.createElement(exports.default)); }); + const roomList = root.root.findAllByType('FlatList').find(node => node.props.horizontal); + await act(async () => roomList.props.renderItem({ item: project }).props.onPress()); + await act(async () => { for (const callback of timers.values()) callback(); }); + assert.equal(directoryCalls, 2); + await act(async () => provision.resolve({ ...project, exists: true })); + assert.equal(root.root.findByType('Conversation').props.room.key, 'project_42'); + await act(async () => list.resolve(directory)); + assert.equal(root.root.findByType('Conversation').props.room.key, 'project_42'); + } finally { await act(async () => root?.unmount()); } +}); From f0775ad52aa2f78df43bb2a4407e8e27007eb1af Mon Sep 17 00:00:00 2001 From: florianfederspiel Date: Wed, 21 Oct 2026 13:40:09 +0200 Subject: [PATCH 7/9] =?UTF-8?q?fix(mobile):=20Chat=20startet=20bei=20neues?= =?UTF-8?q?ter=20Nachricht=20und=20Chats=20nach=20letzter=20Aktivit=C3=A4t?= =?UTF-8?q?=20sortieren?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Der Verlauf nutzt eine invertierte Liste und öffnet dadurch immer ganz unten. Raumlisten liefern lastActivityAt; die App sortiert alle Chats danach und zieht einen Chat nach dem Senden sofort nach oben. Co-Authored-By: Claude Opus 5.5 --- .../src/modules/collaboration/federation.ts | 6 ++++-- backend/src/modules/collaboration/service.ts | 2 ++ backend/src/routes/communication.ts | 18 ++++++++++++++-- mobile/app/(tabs)/communication.tsx | 7 +++++-- mobile/components/chat-conversation.tsx | 20 ++++++++---------- mobile/src/lib/chat.ts | 11 +++++++--- mobile/tests/chat.test.mjs | 21 ++++++++++++++++++- 7 files changed, 64 insertions(+), 21 deletions(-) diff --git a/backend/src/modules/collaboration/federation.ts b/backend/src/modules/collaboration/federation.ts index c51592ff..27c36511 100644 --- a/backend/src/modules/collaboration/federation.ts +++ b/backend/src/modules/collaboration/federation.ts @@ -242,9 +242,11 @@ export class FederationService { for (const b of bindings) { if (b.status === "pending" && new Date(b.invitation.expiresAt).getTime() > Date.now()) result.invitations.push({ ...b.invitation, id: b.id, remote: true }) if (b.status === "active") { - const [unread] = await rows(this.service.db, sql`SELECT count(*)::int AS count FROM collaboration_remote_messages WHERE binding_id=${b.id} AND seq>${b.last_read_seq} AND (payload->>'own')='false'`) + const [unread] = await rows(this.service.db, sql`SELECT count(*)::int AS count, + (SELECT payload->>'createdAt' FROM collaboration_remote_messages WHERE binding_id=${b.id} ORDER BY seq DESC LIMIT 1) AS "lastActivityAt" + FROM collaboration_remote_messages WHERE binding_id=${b.id} AND seq>${b.last_read_seq} AND (payload->>'own')='false'`) result.rooms.push({ id: b.id, name: b.snapshot?.name || b.invitation.name, topic: b.snapshot?.topic || b.invitation.topic, - ownerName: b.invitation.ownerName, status: b.snapshot?.status || 'active', remote: true, unread: unread.count }) + ownerName: b.invitation.ownerName, status: b.snapshot?.status || 'active', remote: true, unread: unread.count, lastActivityAt: unread.lastActivityAt || null }) } } return result diff --git a/backend/src/modules/collaboration/service.ts b/backend/src/modules/collaboration/service.ts index 3c763885..f5ef2118 100644 --- a/backend/src/modules/collaboration/service.ts +++ b/backend/src/modules/collaboration/service.ts @@ -144,6 +144,8 @@ export class CollaborationService { t.name AS "ownerName", m.overview_allowed AS "overviewAllowed", (SELECT count(*)::int FROM collaboration_messages cm WHERE cm.room_id=r.id AND cm.seq>=m.history_from_seq AND cm.seq>m.last_read_seq AND cm.principal_id<>${principal.id}) AS unread + ,coalesce((SELECT max(cm.created_at) FROM collaboration_messages cm WHERE cm.room_id=r.id + AND cm.seq>=m.history_from_seq), r.created_at) AS "lastActivityAt" FROM collaboration_members m JOIN collaboration_rooms r ON r.id=m.room_id JOIN tenants t ON t.id=r.tenant_id LEFT JOIN projects p ON p.id=r.project_id WHERE m.principal_id=${principal.id} AND m.status='active' AND (m.expires_at IS NULL OR m.expires_at>now()) diff --git a/backend/src/routes/communication.ts b/backend/src/routes/communication.ts index db58e290..12530b54 100644 --- a/backend/src/routes/communication.ts +++ b/backend/src/routes/communication.ts @@ -158,6 +158,16 @@ export default async function communicationRoutes(server: FastifyInstance) { entityUuid: room.entityUuid, exists: true, }) + // Rooms without messages fall back to their creation date so new rooms still sort by recency. + const withActivity = async (rows: T[]) => { + const ids = rows.map(({ room }) => room.id) + const latest = ids.length ? await server.db.select({ + roomId: communicationMessages.roomId, + at: sql`max(${communicationMessages.createdAt})`.mapWith(communicationMessages.createdAt), + }).from(communicationMessages).where(inArray(communicationMessages.roomId, ids)).groupBy(communicationMessages.roomId) : [] + const byRoom = new Map(latest.map((row) => [row.roomId, row.at])) + return (room: typeof communicationRooms.$inferSelect) => (byRoom.get(room.id) || room.createdAt).toISOString() + } const messagesForRoom = async (roomId: string, currentUserId: string, tenantId: number, afterId = 0, limit = 50) => { const conditions = [eq(communicationMessages.roomId, roomId)] @@ -295,7 +305,8 @@ export default async function communicationRoutes(server: FastifyInstance) { eq(communicationRooms.archived, false), eq(communicationRoomMembers.userId, req.user.user_id) )).orderBy(asc(communicationRooms.name)) - return { rooms: rows.map(({ room }) => roomToApi(room)) } + const activity = await withActivity(rows) + return { rooms: rows.map(({ room }) => ({ ...roomToApi(room), lastActivityAt: activity(room) })) } }) server.post("/communication/chat/rooms", async (req: any, reply) => { @@ -335,9 +346,10 @@ export default async function communicationRoutes(server: FastifyInstance) { projectChatAccess(server.db, req), ]) const roomsByProject = new Map(roomRows.map(({ room }) => [room.entityId, room])) + const activity = await withActivity(roomRows) return { rooms: projectRows.map((project) => { const room = roomsByProject.get(project.id) - return room ? { ...roomToApi(room), projectId: project.id, projectNumber: project.projectNumber } : { + return room ? { ...roomToApi(room), lastActivityAt: activity(room), projectId: project.id, projectNumber: project.projectNumber } : { key: `project_${project.id}`, name: project.projectNumber ? `${project.projectNumber} · ${project.name}` : project.name, topic: `Projektkommunikation zu ${project.name}`, @@ -387,11 +399,13 @@ export default async function communicationRoutes(server: FastifyInstance) { .innerJoin(communicationRoomMembers, eq(communicationRoomMembers.roomId, communicationRooms.id)) .where(and(eq(communicationRooms.tenantId, tenantId), eq(communicationRooms.type, "direct"), eq(communicationRoomMembers.userId, req.user.user_id))) const roomsByKey = new Map(existingRows.map(({ room }) => [room.key, room])) + const activity = await withActivity(existingRows) return { rooms: users.map((user) => { const key = directRoomKey(req.user.user_id, user.userId) const room = roomsByKey.get(key) return room ? { ...roomToApi(room), + lastActivityAt: activity(room), name: displayName(user), topic: `Direktnachricht mit ${displayName(user)}`, entityUuid: user.userId, diff --git a/mobile/app/(tabs)/communication.tsx b/mobile/app/(tabs)/communication.tsx index 15aa34a8..8cce0684 100644 --- a/mobile/app/(tabs)/communication.tsx +++ b/mobile/app/(tabs)/communication.tsx @@ -10,7 +10,7 @@ import { import { ChatConversation, type ChatDraft } from '@/components/chat-conversation'; import { apiRequest } from '@/src/lib/api'; import { chatApi } from '@/src/lib/chat-api'; -import { collaborationKey, isChatAccessError, matchesChatSearch, visibleChatRooms, type ChatDirectory, type MobileChatRoom } from '@/src/lib/chat'; +import { collaborationKey, isChatAccessError, matchesChatSearch, touchChatRoom, visibleChatRooms, type ChatDirectory, type MobileChatRoom } from '@/src/lib/chat'; import { getApiBaseUrlSync } from '@/src/lib/server-config'; import { useAuth } from '@/src/providers/auth-provider'; @@ -181,7 +181,10 @@ function ChatScreen({ token }: { token: string }) { void hide(activeRoom, true)} onDraft={value => setDrafts(previous => ({ ...previous, [activeKey]: { ...value, clientId: randomUUID() } }))} - onSent={clientId => setDrafts(previous => { if (previous[activeKey]?.clientId !== clientId) return previous; const next = { ...previous }; delete next[activeKey]; return next; })} /> + onSent={clientId => { + setDirectory(previous => { const next = previous && { ...previous, rooms: touchChatRoom(previous.rooms, activeRoom.key) }; directoryRef.current = next; return next; }); + setDrafts(previous => { if (previous[activeKey]?.clientId !== clientId) return previous; const next = { ...previous }; delete next[activeKey]; return next; }); + }} /> : Wähle einen Chat aus.} setShowHidden(false)}> Ausgeblendete ChatsDiese Einstellung gilt auch im Web. diff --git a/mobile/components/chat-conversation.tsx b/mobile/components/chat-conversation.tsx index 93287bbe..9162de4b 100644 --- a/mobile/components/chat-conversation.tsx +++ b/mobile/components/chat-conversation.tsx @@ -1,7 +1,7 @@ import { Ionicons } from '@expo/vector-icons'; import { Image } from 'expo-image'; import * as ImagePicker from 'expo-image-picker'; -import { useCallback, useEffect, useRef, useState } from 'react'; +import { useEffect, useMemo, useRef, useState } from 'react'; import { ActivityIndicator, FlatList, Modal, Pressable, ScrollView, StyleSheet, Text, TextInput, View } from 'react-native'; import { useChatConversation } from '@/hooks/use-chat-conversation'; @@ -58,7 +58,6 @@ export function ChatConversation({ token, room, enabled, draft, onDraft, onSent, const [imageError, setImageError] = useState(''); const list = useRef>(null); const alive = useRef(true); - const nearBottom = useRef(true); const pickerGeneration = useRef(0); const allowed = enabled && !!chat.detail?.canSend && !chat.loading; useEffect(() => { alive.current = true; return () => { alive.current = false; }; }, []); @@ -82,13 +81,13 @@ export function ChatConversation({ token, room, enabled, draft, onDraft, onSent, const send = async () => { if (!allowed || preparing || (!draft.text.trim() && !draft.image)) return; const sent = await chat.send(draft.text.trim(), draft.image, draft.clientId); - if (sent) { onSent(draft.clientId); nearBottom.current = true; list.current?.scrollToEnd({ animated: true }); } + if (sent) { onSent(draft.clientId); list.current?.scrollToOffset({ offset: 0, animated: true }); } }; const time = (message: MobileChatMessage) => { const value = message.createdAt || message.timestamp; return value ? new Date(value).toLocaleString('de-DE', { day: '2-digit', month: '2-digit', hour: '2-digit', minute: '2-digit' }) : ''; }; - const older = () => { nearBottom.current = false; void chat.older(); }; + const older = () => void chat.older(); const renderMessage = ({ item }: { item: MobileChatMessage }) => { const canReact = allowed && !chat.detail?.offline && !item.pending && !item.failed; return @@ -110,7 +109,8 @@ export function ChatConversation({ token, room, enabled, draft, onDraft, onSent, ; }; - const scrollToEnd = useCallback(() => { if (nearBottom.current) list.current?.scrollToEnd({ animated: false }); }, []); + // The inverted list renders the newest message at the bottom and always opens there. + const newestFirst = useMemo(() => [...chat.messages].reverse(), [chat.messages]); return {chat.detail?.name || room.name} @@ -126,13 +126,11 @@ export function ChatConversation({ token, room, enabled, draft, onDraft, onSent, {Object.entries(chat.detail.overview).filter(([, value]) => !!value).map(([key, value]) => {key === 'status' ? 'Status: ' : key === 'date' ? 'Termin: ' : ''}{value})} } {chat.loading ? Nachrichten werden geladen … : - String(item.id)} renderItem={renderMessage} + !chat.messages.length ? Noch keine Nachrichten in diesem Chat. : + String(item.id)} renderItem={renderMessage} style={styles.grow} contentContainerStyle={styles.messages} keyboardShouldPersistTaps="handled" - maintainVisibleContentPosition={{ minIndexForVisible: 0 }} - onScroll={event => { const { contentOffset, contentSize, layoutMeasurement } = event.nativeEvent; nearBottom.current = contentSize.height - contentOffset.y - layoutMeasurement.height < 100; }} - scrollEventThrottle={100} onContentSizeChange={scrollToEnd} - ListHeaderComponent={chat.hasMore ? {chat.olderLoading ? 'Wird geladen …' : 'Ältere Nachrichten laden'} : null} - ListEmptyComponent={Noch keine Nachrichten in diesem Chat.} />} + maintainVisibleContentPosition={{ minIndexForVisible: 1, autoscrollToTopThreshold: 100 }} + ListFooterComponent={chat.hasMore ? {chat.olderLoading ? 'Wird geladen …' : 'Ältere Nachrichten laden'} : null} />} {chat.detail && {!chat.detail.canSend && Dieser Chat ist nur lesbar.} {!!imageError && {imageError}} diff --git a/mobile/src/lib/chat.ts b/mobile/src/lib/chat.ts index f5c15d8e..96984e58 100644 --- a/mobile/src/lib/chat.ts +++ b/mobile/src/lib/chat.ts @@ -6,7 +6,7 @@ export type MobileChatRoom = { key: string; id?: string; name: string; topic?: string | null; source: 'internal' | 'collaboration'; group: string; exists: boolean; type?: string; projectId?: number; projectNumber?: string | null; userId?: string; email?: string | null; - ownerName?: string; remote?: boolean; status?: string; unread?: number; + ownerName?: string; remote?: boolean; status?: string; unread?: number; lastActivityAt?: string | null; }; export type ChatInvitation = { id: string; name: string; ownerName?: string; role: string; @@ -45,6 +45,11 @@ export const chatEndpoint = (room: MobileChatRoom, suffix = '') => room.source = ? `/api/collaboration/rooms/${encodeURIComponent(room.id!)}${suffix}` : `/api/communication/chat/rooms/${encodeURIComponent(room.key)}${suffix}`; export const visibleChatRooms = (directory: ChatDirectory) => directory.rooms.filter(room => !directory.hiddenRoomKeys.includes(room.key)); +const activity = (room: MobileChatRoom) => (room.lastActivityAt && Date.parse(room.lastActivityAt)) || 0; +// Most recently used chats first; rooms without activity keep their server order at the end. +export const sortChatRooms = (rooms: MobileChatRoom[]) => [...rooms].sort((a, b) => activity(b) - activity(a)); +export const touchChatRoom = (rooms: MobileChatRoom[], key: string, at = new Date().toISOString()) => + sortChatRooms(rooms.map(room => room.key === key ? { ...room, lastActivityAt: at } : room)); export const chatCursor = (room: MobileChatRoom, messages: MobileChatMessage[]) => Math.max(0, ...messages.map(message => room.source === 'collaboration' ? message.seq || 0 : Number(message.id))); export function mergeChatMessages(current: MobileChatMessage[], incoming: MobileChatMessage[], collaboration: boolean) { @@ -98,9 +103,9 @@ export function createChatApi(request: Request) { ...room, source: 'internal', group, unread: unread.rooms?.[room.key]?.count || 0, })); return { - rooms: [...decorate(base.rooms, 'Räume'), ...decorate(projects.rooms, 'Projekte'), ...decorate(direct.rooms, 'Direkt'), + rooms: sortChatRooms([...decorate(base.rooms, 'Räume'), ...decorate(projects.rooms, 'Projekte'), ...decorate(direct.rooms, 'Direkt'), ...collaboration.rooms.map(room => ({ ...room, key: collaborationKey(room.id), - source: 'collaboration' as const, group: 'Zusammenarbeit', exists: true }))], + source: 'collaboration' as const, group: 'Zusammenarbeit', exists: true }))]), hiddenRoomKeys: hidden.hiddenRoomKeys, invitations: collaboration.invitations, canManage: collaboration.canManage === true, projects: collaboration.projects || [], }; diff --git a/mobile/tests/chat.test.mjs b/mobile/tests/chat.test.mjs index ab137ed9..bf21c3f0 100644 --- a/mobile/tests/chat.test.mjs +++ b/mobile/tests/chat.test.mjs @@ -2,7 +2,7 @@ import assert from 'node:assert/strict'; import { test } from 'node:test'; import { applyChatAccess, chatCursor, chatEndpoint, collaborationKey, createChatApi, matchesChatSearch, - mergeChatMessages, validChatImage, visibleChatRooms, + mergeChatMessages, sortChatRooms, touchChatRoom, validChatImage, visibleChatRooms, } from '../src/lib/chat.ts'; const id = '11111111-1111-4111-8111-111111111111'; @@ -131,3 +131,22 @@ test('Noch nicht angelegte Direkt- und Projektchats werden gezielt provisioniert assert.equal(calls[1].path, '/api/communication/chat/direct-rooms/user%2Fid/provision'); assert.equal(chatEndpoint(remote), `/api/collaboration/rooms/${id}`); }); + +test('Chats werden nach letzter Aktivität sortiert, neue Nachrichten rücken nach oben', () => { + const rooms = [ + { key: 'ohne', name: 'Ohne Aktivität', source: 'internal', exists: false }, + { key: 'alt', name: 'Alt', source: 'internal', exists: true, lastActivityAt: '2026-01-01T10:00:00.000Z' }, + { key: 'neu', name: 'Neu', source: 'collaboration', exists: true, lastActivityAt: '2026-03-01T10:00:00.000Z' }, + ]; + assert.deepEqual(sortChatRooms(rooms).map(room => room.key), ['neu', 'alt', 'ohne']); + assert.deepEqual(touchChatRoom(rooms, 'alt', '2026-04-01T10:00:00.000Z').map(room => room.key), ['alt', 'neu', 'ohne']); + assert.equal(rooms[1].lastActivityAt, '2026-01-01T10:00:00.000Z'); +}); +test('Verzeichnis sortiert Räume aller Gruppen gemeinsam nach Aktivität', async () => { + const { api } = fixture({ + '/api/communication/chat/rooms': { rooms: [{ ...internal, lastActivityAt: '2026-01-01T00:00:00.000Z' }] }, + '/api/collaboration': { rooms: [{ id, name: 'Partner', lastActivityAt: '2026-02-01T00:00:00.000Z' }], invitations: [] }, + }); + const result = await api.directory('token'); + assert.deepEqual(result.rooms.map(room => room.key), [collaborationKey(id), 'allgemein', 'project_42']); +}); From ea31a15eac82e9e7bbd9908ab22b49dd645f1e22 Mon Sep 17 00:00:00 2001 From: florianfederspiel Date: Sun, 25 Oct 2026 12:47:34 +0100 Subject: [PATCH 8/9] fix(accounting): Kostenstellen-Auswertung wertet Einnahmen positiv und Ausgaben negativ Co-Authored-By: Claude Opus 5.5 --- frontend/components/costcentreDisplay.vue | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/frontend/components/costcentreDisplay.vue b/frontend/components/costcentreDisplay.vue index 138dc130..4ef32c54 100644 --- a/frontend/components/costcentreDisplay.vue +++ b/frontend/components/costcentreDisplay.vue @@ -119,10 +119,13 @@ const reportRows = computed(() => { relevantCostCentreIds.value.has(account.costCentre) ) + // Expenses reduce the cost centre result; incoming documents marked as income increase it. + const sign = invoice.expense === false ? 1 : -1 + return matchingAccounts.map((account, index) => { - const amountNet = Number(account.amountNet || 0) - const amountTax = Number(account.amountTax || 0) - const amountGross = Number(account.amountGross || amountNet + amountTax || 0) + const amountNet = Number(account.amountNet || 0) * sign + const amountTax = Number(account.amountTax || 0) * sign + const amountGross = Number(account.amountGross || Number(account.amountNet || 0) + Number(account.amountTax || 0) || 0) * sign const accountCostCentre = costCentreMap.value.get(getCostCentreId(account.costCentre)) return { @@ -196,8 +199,7 @@ const reportRows = computed(() => { const centre = costCentreMap.value.get(getCostCentreId(allocation.costcentre)) const account = accounts.value.find(account => account.id === (allocation.account?.id || allocation.account)) || allocation.ownaccount const amounts = getBankAllocationAmounts(allocation) - // As for incoming documents, expenses are positive and refunds negative. - const sign = amounts.kind === "input" || (amounts.kind === "none" && amounts.amountGross < 0) ? -1 : 1 + // The bank amount is already signed: outflows (expenses) negative, inflows positive. return [{ id: `bank-${allocation.id}`, sourceLabel: "Bankbuchung", @@ -209,9 +211,9 @@ const reportRows = computed(() => { accountLabel: `${account?.number || ""} ${account?.label || account?.name || ""}`.trim() || "-", costCentreName: centre ? `${centre.number} - ${centre.name}` : "-", description: allocation.description || statement.text || "-", - amountNet: amounts.amountNet * sign, - amountTax: amounts.amountTax * sign, - amountGross: amounts.amountGross * sign, + amountNet: amounts.amountNet, + amountTax: amounts.amountTax, + amountGross: amounts.amountGross, }] }) return [...incomingRows, ...outgoingRows, ...bankRows] From 36dffc057a3df7eb8c40c951155168579086d579 Mon Sep 17 00:00:00 2001 From: florianfederspiel Date: Sun, 25 Oct 2026 13:55:48 +0100 Subject: [PATCH 9/9] fix(release): exclude merged pre-baseline mobile history from commit checks Merge 7a1986bb brought in the old mobile branch (89185b4b), whose commits predate the release baseline and do not follow Conventional Commits. Co-Authored-By: Claude Opus 5.5 --- docs/releases.md | 2 +- release.config.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/releases.md b/docs/releases.md index 6bba9404..3b0e49ac 100644 --- a/docs/releases.md +++ b/docs/releases.md @@ -41,7 +41,7 @@ Die Pipeline nutzt die fest gepinnten Pakete `@semantic-release/commit-analyzer` Es zählt der höchste erforderliche Sprung seit dem letzten stabilen Tag `vMAJOR.MINOR.PATCH`. Eine Änderung und ihr vollständiger Git-Revert innerhalb desselben Release-Bereichs heben sich auf. Selbst verfasste Nachrichten müssen dem Schema entsprechen; echte Merge-Commits und automatisch generierte Git-Reverts werden gesondert behandelt. -Der erste Release heißt `3.0.0`. `bootstrapCommit` in `release.config.json` begrenzt die Prüfung und die erste Änderungsliste auf die Einführung dieser Versionierung auf `dev`. `legacyHistoryHeads` hält zusätzlich den letzten alten `main`-Stand fest (`e7554fa2…` vor der ersten Übernahme von `dev`). Dessen Historie enthält ältere Änderungen an Issue-Vorlagen, die nicht vom `dev`-Ausgangscommit erreichbar sind. Beide festgelegten Historien werden aus Commit-Prüfung, Versionsberechnung und Release Notes ausgeschlossen. Neue Commits zählen auch dann, wenn ihr Zweig von einem dieser alten Stände abzweigt. +Der erste Release heißt `3.0.0`. `bootstrapCommit` in `release.config.json` begrenzt die Prüfung und die erste Änderungsliste auf die Einführung dieser Versionierung auf `dev`. `legacyHistoryHeads` hält zusätzlich den letzten alten `main`-Stand fest (`e7554fa2…` vor der ersten Übernahme von `dev`). Dessen Historie enthält ältere Änderungen an Issue-Vorlagen, die nicht vom `dev`-Ausgangscommit erreichbar sind. Außerdem ist der alte Mobile-Zweig (`89185b4b…`, Stand vor dem Ausgangscommit) eingetragen, der mit `7a1986bb` nach `dev` übernommen wurde. Alle festgelegten Historien werden aus Commit-Prüfung, Versionsberechnung und Release Notes ausgeschlossen. Neue Commits zählen auch dann, wenn ihr Zweig von einem dieser alten Stände abzweigt. Ältere Commits werden weder umgeschrieben noch nachträglich klassifiziert. Diese festen Ausgangscommits nach Einführung nicht verschieben und keine späteren Änderungen hinzufügen, um die Commit-Prüfung zu umgehen. Der vollständige Checkout muss auch die alten Branch-Historien enthalten; ein fehlender Ausgangscommit führt zum Abbruch. diff --git a/release.config.json b/release.config.json index 8b4a92cc..28df33cc 100644 --- a/release.config.json +++ b/release.config.json @@ -1,7 +1,7 @@ { "initialVersion": "3.0.0", "bootstrapCommit": "cfef07b96d97f8dffdfc32c74f8f32b62fe43b18", - "legacyHistoryHeads": ["e7554fa2cc649f69dce4880abe76c6fab0f82700"], + "legacyHistoryHeads": ["e7554fa2cc649f69dce4880abe76c6fab0f82700", "89185b4b086d782b59146c9c7cf45392b0078cc7"], "branch": "main", "repositoryUrl": "https://git.federspiel.tech/flfeders/FEDEO", "registry": "git.federspiel.tech/flfeders/fedeo",