final: added personHistory

This commit is contained in:
Cron1cle
2025-10-23 03:17:46 +02:00
parent 73d883ae29
commit d40b90de41
7 changed files with 603 additions and 97 deletions

169
apis/personHistoryApi.ts Normal file
View File

@@ -0,0 +1,169 @@
export type PersonMini = {
personId: number;
name: string;
birthDate: string | null;
imageUrl?: string | null;
};
export type PersonHistoryRecord = {
seasonId: number;
showId: number;
startDate: string | null;
endDate: string | null;
seasonNumber: number;
partner: PersonMini | null;
seasonParticipants: (PersonMini & { partner?: PersonMini | null })[];
};
type RawSeasonNew = {
seasonId: number;
showId?: number;
show?: number;
seasonNumber: number;
startDate: string | null;
endDate: string | null;
partner?: {
personId: number;
name: string;
birthDate?: string | null;
imageUrl?: string | null;
} | null;
seasonParticipants?: {
personId: number;
name: string;
birthDate?: string | null;
imageUrl?: string | null;
}[];
};
type RawPersonOld = {
personId: number;
name: string;
birthDate?: string | null;
imageUrl?: string | null;
};
type RawSeasonOld = {
seasonId: number;
show?: number;
showId?: number;
seasonNumber: number;
startDate: string | null;
endDate: string | null;
seasonParticipants?:
| {
id?: { seasonId?: number; personId?: number };
person?: RawPersonOld | null;
partner?: RawPersonOld | null;
}[]
| null;
};
const PERSONS_BASE_URL = "http://45.157.177.99:8080/persons";
function toMini(p: any | undefined | null): PersonMini | null {
if (!p || !p.personId || !p.name) return null;
return {
personId: Number(p.personId),
name: String(p.name),
birthDate: p.birthDate ?? null,
imageUrl: p.imageUrl ?? null,
};
}
function isFlatSeason(s: any): s is RawSeasonNew {
const sp = s?.seasonParticipants;
return Array.isArray(sp) && (sp.length === 0 || "personId" in (sp[0] ?? {}));
}
function mapSeason(
s: RawSeasonNew | RawSeasonOld,
requestedPersonId: number
): PersonHistoryRecord {
const showId = Number((s as any).showId ?? (s as any).show ?? 0) || 0;
const base = {
seasonId: s.seasonId,
showId,
startDate: s.startDate ?? null,
endDate: s.endDate ?? null,
seasonNumber: s.seasonNumber,
};
if (isFlatSeason(s)) {
const seasonParticipants = Array.isArray(s.seasonParticipants)
? s.seasonParticipants
.map((p) => toMini(p))
.filter((x): x is PersonMini => !!x)
: [];
const partner = toMini(s.partner ?? null);
return {
...base,
partner,
seasonParticipants,
};
}
const spOld = (s as RawSeasonOld).seasonParticipants;
const seasonParticipantsOld = Array.isArray(spOld)
? spOld
.map((p) => {
const pid = p.person?.personId ?? p.id?.personId;
const name = p.person?.name ?? null;
if (!pid || !name) return null;
const me: PersonMini = {
personId: Number(pid),
name: String(name),
birthDate: p.person?.birthDate ?? null,
imageUrl: p.person?.imageUrl ?? null,
};
const partnerMini = toMini(p.partner ?? null);
return {
...me,
partner: partnerMini,
};
})
.filter((x): x is NonNullable<typeof x> => !!x)
: [];
const me =
seasonParticipantsOld.find((pp) => pp.personId === requestedPersonId) ||
null;
const partner = (me?.partner ?? null) as PersonMini | null;
return {
...base,
partner,
seasonParticipants: seasonParticipantsOld,
};
}
export async function getPersonHistory(
personId: number,
signal?: AbortSignal
): Promise<PersonHistoryRecord[]> {
const apiKey = process.env.EXPO_PUBLIC_API_KEY;
const url = `${PERSONS_BASE_URL}/${personId}/history`;
const res = await fetch(url, {
signal,
headers: {
"Content-Type": "application/json",
"X-API-Key": apiKey ?? "",
},
});
if (!res.ok) throw new Error("GetPersonHistory failed " + res.status);
const data: unknown = await res.json();
if (!Array.isArray(data)) return [];
return (data as (RawSeasonNew | RawSeasonOld)[]).map((s) =>
mapSeason(s, personId)
);
}

View File

@@ -24,7 +24,7 @@ export type Show = {
concept: string; concept: string;
startDate?: string; startDate?: string;
endDate?: string | null; endDate?: string | null;
logoUri: string; logoUrl: string;
running: boolean; running: boolean;
}; };
@@ -58,10 +58,36 @@ export async function getShows(): Promise<Show[]> {
streamingService: s.streamingServices, streamingService: s.streamingServices,
concept: s.concept, concept: s.concept,
running: s.running, running: s.running,
logoUri: s.logoUrl ?? "", logoUrl: s.logoUrl ?? "",
})); }));
} catch (error) { } catch (error) {
console.error("Fetch error:", error); console.error("Fetch error:", error);
throw error; throw error;
} }
} }
export async function getShowById(showId: number): Promise<Show | null> {
const apiKey = process.env.EXPO_PUBLIC_API_KEY;
const url = `${SHOW_API_URL}/${showId}`;
const res = await fetch(url, {
headers: {
"Content-Type": "application/json",
"X-API-Key": apiKey ?? "",
},
});
if (res.status === 404) return null;
if (!res.ok) throw new Error("getShowById failed " + res.status);
const s = (await res.json()) as RawShow;
return {
id: s.showId,
title: s.title,
description: s.description,
genres: s.genre ? s.genre.split(",").map((g) => g.trim()) : [],
thumbnailUri: s.thumbnailUrl,
bannerUri: s.bannerUrl ?? "",
streamingService: s.streamingServices,
concept: s.concept,
running: s.running,
logoUrl: s.logoUrl ?? "",
};
}

View File

@@ -176,7 +176,7 @@ export default function HomeScreen() {
concept: show.concept, concept: show.concept,
genres: show.genres, genres: show.genres,
streamingService: show.streamingService, streamingService: show.streamingService,
logoUri: show.logoUri, logoUri: show.logoUrl,
running: String(show.running), running: String(show.running),
}, },
}) })

View File

@@ -22,7 +22,6 @@ export default function RootLayout() {
<Stack.Screen <Stack.Screen
name="participant" name="participant"
options={{ options={{
presentation: "fullScreenModal",
headerShown: false, headerShown: false,
}} }}
/> />

View File

@@ -2,56 +2,188 @@ import styles from "@/app/stackStyles/participantStyles";
import { useShowContext } from "@/contexts/ShowContext"; import { useShowContext } from "@/contexts/ShowContext";
import Ionicons from "@expo/vector-icons/Ionicons"; import Ionicons from "@expo/vector-icons/Ionicons";
import { router, useLocalSearchParams } from "expo-router"; import { router, useLocalSearchParams } from "expo-router";
import React, { useMemo, useState } from "react"; import React from "react";
import { Text, TouchableOpacity, View } from "react-native"; import { Text, TouchableOpacity, View, Image, Dimensions } from "react-native";
import {
getPersonHistory,
type PersonMini,
type PersonHistoryRecord,
} from "@/apis/personHistoryApi";
import { getShowById } from "@/apis/showApi";
import { import {
GestureHandlerRootView, GestureHandlerRootView,
ScrollView, ScrollView,
} from "react-native-gesture-handler"; } from "react-native-gesture-handler";
export default function ParticipantScreen() { type SeasonEntry = {
const [appearances] = useState<
{
showId: number;
seasons: number[];
}[]
>([]);
const { shows } = useShowContext();
const { name } = useLocalSearchParams();
const resolved = useMemo(
() =>
(appearances as any[])
.map((a) => {
const show = shows.find((s) => s.id === a.showId);
if (!show) return null;
return {
show,
seasons: a.seasons as number[],
partners: a.partners as {
seasonNumber: number; seasonNumber: number;
partner?: { id: number; name: string; imageUrl?: string | null }; partner: PersonMini | null;
}[], participants: PersonMini[];
startDate: string | null;
}; };
type AppearanceGroup = {
show: {
id: number;
title: string;
bannerUri: string;
thumbnailUri: string;
};
seasons: SeasonEntry[];
};
export default function ParticipantScreen() {
const { shows } = useShowContext();
const { name, participantId } = useLocalSearchParams();
const pid = Array.isArray(participantId)
? Number(participantId[0])
: Number(participantId);
const [loading, setLoading] = React.useState(false);
const [error, setError] = React.useState<string | null>(null);
const [appearances, setAppearances] = React.useState<AppearanceGroup[]>([]);
const formatYear = (iso?: string | null) => {
if (!iso) return null;
const [y] = iso.split("-");
return y || null;
};
React.useEffect(() => {
if (!pid || Number.isNaN(pid)) return;
const controller = new AbortController();
setLoading(true);
setError(null);
(async () => {
try {
const hist = await getPersonHistory(pid, controller.signal);
const grouped = new Map<number, Map<number, SeasonEntry>>();
for (const h of hist) {
if (!Number.isFinite(h.showId) || h.showId <= 0) continue;
const seasonsForShow =
grouped.get(h.showId) ?? new Map<number, SeasonEntry>();
const existing = seasonsForShow.get(h.seasonNumber);
if (existing) {
seasonsForShow.set(h.seasonNumber, {
seasonNumber: h.seasonNumber,
partner: existing.partner ?? h.partner ?? null,
participants: existing.participants.length
? existing.participants
: (h.seasonParticipants ?? []),
startDate: existing.startDate ?? h.startDate ?? null,
});
} else {
seasonsForShow.set(h.seasonNumber, {
seasonNumber: h.seasonNumber,
partner: h.partner ?? null,
participants: h.seasonParticipants ?? [],
startDate: h.startDate ?? null,
});
}
grouped.set(h.showId, seasonsForShow);
}
const showIds = Array.from(grouped.keys());
const fromContext = showIds
.map((id) => shows.find((s) => s.id === id))
.filter((s): s is (typeof shows)[number] => !!s);
const missingIds = showIds.filter(
(id) => !fromContext.some((s) => s.id === id)
);
const fetched = await Promise.all(
missingIds.map(async (id) => {
try {
const s = await getShowById(id);
return s;
} catch {
return null;
}
}) })
.filter( );
(
v const allShows = [
): v is { ...fromContext,
show: (typeof shows)[number]; ...fetched.filter(Boolean),
seasons: number[]; ] as typeof shows;
partners: {
seasonNumber: number; const result: AppearanceGroup[] = allShows.map((s) => {
partner?: { id: number; name: string; imageUrl?: string | null }; const seasonsMap = grouped.get(s.id)!;
}[]; const seasonsSorted = Array.from(seasonsMap.values()).sort(
} => !!v (a, b) => a.seasonNumber - b.seasonNumber
), );
[appearances, shows] return {
show: {
id: s.id,
title: s.title,
bannerUri: s.bannerUri,
thumbnailUri: s.thumbnailUri,
},
seasons: seasonsSorted,
};
});
result.sort((a, b) =>
a.show.title.localeCompare(b.show.title, "de", {
sensitivity: "base",
})
);
setAppearances(result);
} catch (e: any) {
if (!controller.signal.aborted)
setError(e?.message || "Fehler beim Laden");
} finally {
if (!controller.signal.aborted) setLoading(false);
}
})();
return () => controller.abort();
}, [pid, shows]);
const [expandedShows, setExpandedShows] = React.useState<Set<number>>(
new Set()
);
const toggleExpand = React.useCallback((showId: number) => {
setExpandedShows((prev) => {
const next = new Set(prev);
if (next.has(showId)) next.delete(showId);
else next.add(showId);
return next;
});
}, []);
const goToShow = React.useCallback((id: number) => {
router.push({ pathname: "/showDetails", params: { id: String(id) } });
}, []);
const goToPerson = React.useCallback(
(p: PersonMini) => {
if (!p?.personId) return;
if (p.personId === pid) return;
router.push({
pathname: "/participant",
params: { participantId: String(p.personId), name: p.name },
});
},
[pid]
); );
return ( return (
<GestureHandlerRootView style={styles.mainContainer}> <GestureHandlerRootView style={styles.mainContainer}>
<ScrollView showsVerticalScrollIndicator={false}> <ScrollView
showsVerticalScrollIndicator={false}
contentContainerStyle={{ paddingBottom: 20, paddingTop: 10 }}
>
<Text style={styles.participantName}>{name}</Text> <Text style={styles.participantName}>{name}</Text>
<TouchableOpacity <TouchableOpacity
style={styles.closeIcon} style={styles.closeIcon}
@@ -63,42 +195,152 @@ export default function ParticipantScreen() {
<View style={styles.performedShowsSection}> <View style={styles.performedShowsSection}>
<Text style={styles.performedShowsTitle}>Auftritte:</Text> <Text style={styles.performedShowsTitle}>Auftritte:</Text>
<View style={styles.showContainer}></View> <ScrollView
{/* <ScrollView
horizontal horizontal
showsHorizontalScrollIndicator={false} showsHorizontalScrollIndicator={false}
style={{ style={{ flex: 1 }}
width: "100%", contentContainerStyle={{
marginTop: 15, gap: 20,
paddingHorizontal: 15,
paddingLeft: 30,
}} }}
> >
{resolved.map(({ show, seasons, partners }) => { {appearances.map(({ show, seasons }) => {
const seasonPartnerLines = partners.map((p) => { const partners = Array.from(
const label = `Staffel ${p.seasonNumber}`; new Map(
if (!p.partner) return label; seasons
return `${label} • Partner: ${p.partner.name}`; .map((s) => s.partner)
}); .filter((p): p is NonNullable<typeof p> => !!p)
.map((p) => [p.personId, p])
).values()
);
const allParticipants = Array.from(
new Map(
seasons
.flatMap((s) => s.participants)
.filter((p) => p.personId !== pid)
.map((p) => [p.personId, p])
).values()
);
const isExpanded = expandedShows.has(show.id);
const visible = isExpanded
? allParticipants
: allParticipants.slice(0, 12);
const restCount = Math.max(
allParticipants.length - visible.length,
0
);
return ( return (
<TouchableOpacity key={show.id} style={styles.showContainer}> <View key={show.id} style={styles.card}>
<TouchableOpacity
style={styles.showContainer}
onPress={() => goToShow(show.id)}
>
<Image <Image
source={{ uri: show.thumbnailUri }} source={{ uri: show.bannerUri || show.thumbnailUri }}
style={styles.showImage} style={styles.showImage}
resizeMode="cover"
/> />
<Text style={styles.showTitle} numberOfLines={2}> </TouchableOpacity>
<Text style={styles.showTitle} numberOfLines={1}>
{show.title} {show.title}
</Text> </Text>
<Text style={styles.showSeason}>
({formatYear(seasons[0]?.startDate)})
</Text>
<Text style={styles.showSeason}>
Staffel {seasons.map((s) => s.seasonNumber).join(" und ")}
</Text>
<Text <View style={styles.horizontalLine} />
style={styles.showSeason}
numberOfLines={seasonPartnerLines.length} <Text style={[styles.participantLabel, { marginTop: 10 }]}>
Weitere Teilnehmer
</Text>
<View style={styles.participantContainer}>
<View style={styles.participantRow}>
{visible.map((p) => (
<TouchableOpacity
key={p.personId}
style={styles.participantChip}
onPress={() => goToPerson(p)}
> >
{seasonPartnerLines.join("\n")} <Text
style={styles.participantChipText}
numberOfLines={1}
>
{p.name}
</Text> </Text>
</TouchableOpacity> </TouchableOpacity>
))}
{!isExpanded && restCount > 0 && (
<TouchableOpacity
onPress={() => toggleExpand(show.id)}
style={styles.moreChip}
>
<Text style={styles.moreChipText}>
+{restCount} mehr
</Text>
</TouchableOpacity>
)}
{isExpanded && allParticipants.length > 12 && (
<TouchableOpacity
onPress={() => toggleExpand(show.id)}
style={styles.moreChip}
>
<Text style={styles.moreChipText}>Weniger</Text>
</TouchableOpacity>
)}
</View>
</View>
{partners.length > 0 && (
<>
<View style={styles.horizontalLine} />
<Text
style={[styles.participantLabel, { marginTop: 10 }]}
>
Partner
</Text>
<View
style={[
styles.showContainer,
{
backgroundColor: "hsl(221, 39%, 12%)",
width: 150,
marginTop: 20,
},
]}
>
<Image
style={styles.showImage}
blurRadius={20}
source={{
uri: `https://i.pravatar.cc/300?img=${Math.floor(Math.random() * 70)}`,
}}
/>
</View>
{partners.map((p) => (
<Text
key={p.personId}
style={styles.partnerLabel}
numberOfLines={1}
>
{p.name}
</Text>
))}
</>
)}
</View>
); );
})} })}
</ScrollView> */} </ScrollView>
</View> </View>
</ScrollView> </ScrollView>
</GestureHandlerRootView> </GestureHandlerRootView>

View File

@@ -4,7 +4,6 @@ import StackHeader from "@/components/ui/StackHeader";
import { useSeasonContext } from "@/contexts/SeasonContext"; import { useSeasonContext } from "@/contexts/SeasonContext";
import { router, useLocalSearchParams } from "expo-router"; import { router, useLocalSearchParams } from "expo-router";
import React from "react"; import React from "react";
import { import {
Dimensions, Dimensions,
Image, Image,
@@ -99,6 +98,21 @@ export default function ShowDetails() {
}); });
}, [startDate]); }, [startDate]);
const handleOpenParticipant = React.useCallback(
(p: { id: number; name: string }) => {
router.push({
pathname: "/participant",
params: {
participantId: p.id,
name: p.name,
originShowId: String(showId),
originSeason: String(selectedSeason),
},
});
},
[showId, selectedSeason]
);
return ( return (
<View style={styles.mainContainer}> <View style={styles.mainContainer}>
<StackHeader /> <StackHeader />
@@ -210,23 +224,11 @@ export default function ShowDetails() {
styles.participantContainer, styles.participantContainer,
{ backgroundColor: "hsl(336, 79%, 63%)" }, { backgroundColor: "hsl(336, 79%, 63%)" },
]} ]}
onPress={() => onPress={() => handleOpenParticipant(p)}
router.push({
pathname: "/participant",
params: {
participantId: p.id,
name: p.name,
},
})
}
> >
<Image <Image
source={{ uri: p.imageUri }} source={{ uri: p.imageUri }}
style={{ style={{ width: "100%", height: "100%", borderRadius: 10 }}
width: "100%",
height: "100%",
borderRadius: 10,
}}
resizeMode="cover" resizeMode="cover"
blurRadius={p.imageUri.includes("pravatar") ? 16 : 0} blurRadius={p.imageUri.includes("pravatar") ? 16 : 0}
/> />

View File

@@ -7,15 +7,15 @@ const styles = StyleSheet.create({
}, },
closeIcon: { closeIcon: {
position: "absolute", position: "absolute",
top: Dimensions.get("window").height * 0.07, top: Dimensions.get("window").height * 0.065,
right: 15, right: 15,
}, },
participantName: { participantName: {
color: "white", color: "white",
fontSize: 24, fontSize: 20,
fontWeight: "600", fontWeight: "600",
textAlign: "center", textAlign: "center",
marginTop: Dimensions.get("window").height * 0.075, marginTop: Dimensions.get("window").height * 0.06,
}, },
participantImage: { participantImage: {
width: "100%", width: "100%",
@@ -49,7 +49,7 @@ const styles = StyleSheet.create({
}, },
performedShowsSection: { performedShowsSection: {
width: "100%", width: "100%",
height: Dimensions.get("window").height, height: "100%",
backgroundColor: "hsl(221, 39%, 0%)", backgroundColor: "hsl(221, 39%, 0%)",
marginTop: 20, marginTop: 20,
}, },
@@ -60,14 +60,7 @@ const styles = StyleSheet.create({
marginTop: 15, marginTop: 15,
marginLeft: 15, marginLeft: 15,
}, },
showContainer: {
width: "85%",
height: 180,
backgroundColor: "hsl(336, 79%, 63%)",
borderRadius: 10,
alignSelf: "center",
marginTop: 15,
},
showImage: { showImage: {
width: "100%", width: "100%",
height: "100%", height: "100%",
@@ -94,14 +87,89 @@ const styles = StyleSheet.create({
fontSize: 12, fontSize: 12,
fontWeight: "600", fontWeight: "600",
textAlign: "center", textAlign: "center",
marginTop: 10, marginTop: 15,
}, },
showSeason: { showSeason: {
color: "hsl(0, 0%, 80%)", color: "hsl(0, 0%, 80%)",
fontSize: 12, fontSize: 12,
fontWeight: "400", fontWeight: "400",
textAlign: "center", textAlign: "center",
marginTop: 3, marginTop: 5,
},
showContainer: {
width: Dimensions.get("window").width - 75,
height: 200,
borderRadius: 15,
marginTop: 20,
alignItems: "center",
backgroundColor: "hsl(336, 79%, 63%)",
},
card: {
width: Dimensions.get("window").width - 75,
alignItems: "center",
},
horizontalLine: {
height: 50,
width: 2,
backgroundColor: "hsl(0, 0%, 70%)",
marginTop: 10,
alignSelf: "center",
},
partnerLabel: {
color: "hsl(0, 0%, 80%)",
fontSize: 12,
fontWeight: "400",
textAlign: "center",
marginTop: 10,
},
participantContainer: {
width: "auto",
height: "auto",
borderRadius: 15,
marginTop: 15,
alignItems: "center",
justifyContent: "center",
backgroundColor: "hsl(221, 39%, 12%)",
padding: 10,
},
participantLabel: {
color: "white",
fontSize: 12,
},
participantRow: {
flexDirection: "row",
flexWrap: "wrap",
gap: 6,
alignItems: "center",
justifyContent: "flex-start",
},
participantChip: {
paddingVertical: 4,
paddingHorizontal: 8,
borderRadius: 12,
backgroundColor: "hsl(221, 39%, 18%)",
maxWidth: 160,
},
participantChipText: {
color: "hsl(0, 0%, 85%)",
fontSize: 11,
},
moreChip: {
paddingVertical: 4,
paddingHorizontal: 10,
borderRadius: 12,
backgroundColor: "hsl(221, 39%, 28%)",
},
moreChipText: {
color: "white",
fontSize: 11,
fontWeight: "600",
}, },
}); });