feat: Ein Fahrer hat einen Namen, nicht zwei

Vorname und Nachname getrennt zu fuehren versprach eine Ordnung, die es
hier nie gab: sortiert wird ueber den ganzen Namen, angezeigt wird der
ganze Name, gesucht wird ueber beides zugleich. Dafuer musste jede Maske
zwei Felder anbieten und jede Anzeige sie wieder zusammensetzen — und
wer schlicht "Schorsch" heisst, stand vor der Frage, welches der beiden
Felder das ist.

Das Feld name gibt es an riders laengst; es lag nur brach, weil die
Anwendung firstname und lastname benutzte. Es uebernimmt deren Inhalt —
kein neues Feld, keine zweite Spalte, nichts, was auf bestehenden
Instanzen erst entstehen muesste.

Zurueck geht es nur ungenau: Die Umkehrung trennt am ersten Leerzeichen
und raet damit bei jedem Doppelvornamen falsch. Das ist der Preis dafuer,
dass die Trennung ueberhaupt verschwindet.

fullName heisst jetzt riderName und ist nur noch ein Feldzugriff — die
Funktion bleibt als die eine Stelle, an der ein namenloser Fahrer zu
einem leeren String wird statt als undefined durch die Oberflaeche zu
geistern.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01P32KoesVtABd6xWsqMKzhr
This commit is contained in:
Daniel Michelberger 2026-09-07 22:10:30 +02:00
parent eac2455ee3
commit 8c11095b17
9 changed files with 177 additions and 65 deletions

View file

@ -709,7 +709,7 @@ migrate((app) => {
"min": 0,
"name": "name",
"pattern": "",
"presentable": false,
"presentable": true,
"primaryKey": false,
"required": false,
"system": false,
@ -764,36 +764,6 @@ migrate((app) => {
"system": false,
"type": "autodate"
},
{
"autogeneratePattern": "",
"help": "",
"hidden": false,
"id": "text2208304744",
"max": 0,
"min": 0,
"name": "firstname",
"pattern": "",
"presentable": true,
"primaryKey": false,
"required": false,
"system": false,
"type": "text"
},
{
"autogeneratePattern": "",
"help": "",
"hidden": false,
"id": "text824489398",
"max": 0,
"min": 0,
"name": "lastname",
"pattern": "",
"presentable": true,
"primaryKey": false,
"required": false,
"system": false,
"type": "text"
},
{
"cascadeDelete": false,
"collectionId": "pbc_1568971955",

View file

@ -0,0 +1,87 @@
/// <reference path="../pb_data/types.d.ts" />
// Ein Fahrer hat einen Namen, nicht zwei.
//
// Vorname und Nachname getrennt zu führen versprach eine Ordnung, die es hier
// nie gab: sortiert wird über den ganzen Namen, angezeigt wird der ganze Name,
// gesucht wird über beides zugleich. Dafür musste jede Maske zwei Felder
// anbieten und jede Anzeige sie wieder zusammensetzen — und wer schlicht
// „Schorsch" heißt, stand vor der Frage, welches der beiden Felder das ist.
//
// Das Feld `name` gibt es an riders längst; es lag nur brach, weil die
// Anwendung firstname und lastname benutzte. Es bleibt deshalb, wie es ist,
// und bekommt nur den Inhalt der beiden anderen — kein neues Feld, keine
// zweite Spalte, nichts, was auf bestehenden Instanzen erst entstehen müsste.
//
// Beim Zusammenlegen geht nichts verloren: firstname und lastname werden mit
// einem Leerzeichen verbunden. Zurück geht es nur ungenau — die Umkehrung
// trennt am ersten Leerzeichen und rät damit bei jedem Doppelvornamen falsch.
// Das ist der Preis dafür, dass die Trennung überhaupt verschwindet.
migrate((app) => {
const riders = app.findCollectionByNameOrId('riders')
// Erst zusammenführen, dann die alten Felder abräumen — andersherum wären
// die Daten fort, bevor sie jemand liest. Auf einer frischen Datenbank hat
// der Snapshot die beiden Felder gar nicht erst.
if (riders.fields.getByName('firstname') || riders.fields.getByName('lastname')) {
for (const rider of app.findRecordsByFilter('riders', 'id != ""', '', 0, 0)) {
const merged = [rider.get('firstname'), rider.get('lastname')]
.map((part) => (part || '').trim())
.filter(Boolean)
.join(' ')
// Ein bereits gefülltes `name` gewinnt: Es wäre von Hand gesetzt
// worden und wüsste mehr als die beiden Bruchstücke.
if (merged && !(rider.get('name') || '').trim()) {
rider.set('name', merged)
app.save(rider)
}
}
}
riders.fields.removeByName('firstname')
riders.fields.removeByName('lastname')
// Im Admin-UI steht ein Fahrer jetzt mit Namen statt mit seiner ID da.
const name = riders.fields.getByName('name')
if (name) name.presentable = true
app.save(riders)
}, (app) => {
const riders = app.findCollectionByNameOrId('riders')
if (!riders.fields.getByName('firstname')) {
riders.fields.add(new Field({
id: 'text2208304744',
name: 'firstname',
type: 'text',
presentable: true,
}))
}
if (!riders.fields.getByName('lastname')) {
riders.fields.add(new Field({
id: 'text824489398',
name: 'lastname',
type: 'text',
presentable: true,
}))
}
const name = riders.fields.getByName('name')
if (name) name.presentable = false
app.save(riders)
// Am ersten Leerzeichen trennen: davor der Vorname, danach der Rest. Aus
// „Anna Maria Huber" wird so „Anna" und „Maria Huber".
for (const rider of app.findRecordsByFilter('riders', 'id != ""', '', 0, 0)) {
const full = (rider.get('name') || '').trim()
if (!full) continue
const cut = full.indexOf(' ')
rider.set('firstname', cut === -1 ? full : full.slice(0, cut))
rider.set('lastname', cut === -1 ? '' : full.slice(cut + 1).trim())
app.save(rider)
}
})

View file

@ -12,7 +12,7 @@
* Zeit-Dialog mit Fahrer und Run vorbelegt.
*/
import { getTimeContext } from '$lib/stores/times.svelte'
import { fullName, getRiderContext } from '$lib/stores/riders.svelte'
import { riderName, getRiderContext } from '$lib/stores/riders.svelte'
import { getRunContext } from '$lib/stores/runs.svelte'
import { getEventParticipantContext } from '$lib/stores/eventParticipants.svelte'
import { ticker } from '$lib/stores/ticker.svelte'
@ -56,7 +56,7 @@
const eventId = runId ? runs.getById(runId)?.event : undefined
if (!eventId) {
const r = riderId ? riders.getById(riderId) : undefined
return r ? fullName(r) : 'Unbekannter Fahrer'
return r ? riderName(r) : 'Unbekannter Fahrer'
}
return participants.riderLabel(eventId, riderId)
}

View file

@ -7,7 +7,7 @@
* Tabellen noch die Toast-Leiste lösen sie direkt aus.
*/
import { getTimeContext, type TimeStatus } from '$lib/stores/times.svelte'
import { fullName, getRiderContext } from '$lib/stores/riders.svelte'
import { riderName, getRiderContext } from '$lib/stores/riders.svelte'
import { getRunContext } from '$lib/stores/runs.svelte'
import { getEventParticipantContext } from '$lib/stores/eventParticipants.svelte'
import { ticker } from '$lib/stores/ticker.svelte'

View file

@ -1,7 +1,7 @@
import { getContext, setContext } from 'svelte'
import { api, auth } from './pocketbase.svelte'
import { getEventContext } from './events.svelte'
import { getRiderContext } from './riders.svelte'
import { getRiderContext, riderName } from './riders.svelte'
import type { EventParticipantsResponse, RidersResponse } from '$lib/types'
const KEY = Symbol('eventParticipant')
@ -45,7 +45,7 @@ export class EventParticipantStore {
if (Number.isFinite(na) && Number.isFinite(nb)) return na - nb
if (Number.isFinite(na)) return -1
if (Number.isFinite(nb)) return 1
return this.riderName(a.rider).localeCompare(this.riderName(b.rider))
return this.nameOf(a.rider).localeCompare(this.nameOf(b.rider))
})
}
@ -94,16 +94,15 @@ export class EventParticipantStore {
riderLabel(eventId: string, riderId: string | undefined): string {
if (!riderId) return 'Unbekannter Fahrer'
const name = this.riderName(riderId) || 'Unbekannter Fahrer'
const name = this.nameOf(riderId) || 'Unbekannter Fahrer'
const number = this.numberOf(eventId, riderId)
return number ? `#${number} ${name}` : name
}
private riderName(riderId: string): string {
private nameOf(riderId: string): string {
const r = this.riders.getById(riderId)
if (!r) return ''
return [r.firstname, r.lastname].filter(Boolean).join(' ') || r.name || ''
return r ? riderName(r) : ''
}
async load() {

View file

@ -11,12 +11,20 @@ const KEY = Symbol('rider')
* Die gelten seit 1754501300 je Event und stehen am Teilnehmer.
*/
function sortByName(records: RidersResponse[]) {
return [...records].sort((a, b) => fullName(a).localeCompare(fullName(b)))
return [...records].sort((a, b) => riderName(a).localeCompare(riderName(b)))
}
export function fullName(r: RidersResponse) {
const parts = [r.firstname, r.lastname].filter(Boolean)
return parts.join(' ').trim() || r.name || ''
/**
* Der Name eines Fahrers ein Feld, ein Wert.
*
* Bis 1754501800_riders_single_name.js standen hier Vorname und Nachname
* getrennt und mussten überall wieder zusammengesetzt werden. Die Funktion
* bleibt trotzdem: Sie ist die eine Stelle, an der ein namenloser Fahrer zu
* einem leeren String wird, statt als `undefined` durch die Oberfläche zu
* geistern.
*/
export function riderName(r: RidersResponse) {
return (r.name ?? '').trim()
}
export class RiderStore {
@ -84,13 +92,47 @@ export class RiderStore {
return await api.collection('riders').update(id, data)
}
/**
* Fahrer löschen und mit ihm die Mitgliedschaft seines Kontos.
*
* Ohne den zweiten Schritt riss jedes Löschen ein Loch: Der Fahrer war
* fort, sein Konto blieb in `teams.users` und stand danach als Zeile ohne
* Fahrer im Kader. Aus so einer Zeile führte kein Weg zurück ein Konto
* nachträglich an einen Fahrer zu hängen gibt es bewusst nicht, weil
* dieselbe Person sonst zweimal in der Liste stehen könnte. Der Eintrag
* war damit unreparierbar.
*
* Das Konto selbst bleibt bestehen: Löschen darf es allein sein Inhaber
* (`users.deleteRule = id = @request.auth.id`), und über einen neuen
* Einladungslink kommt es zurück ins Team.
*
* Der Owner ist ausgenommen. Ihn aus `teams.users` zu nehmen, nähme ihm
* die Sicht auf sein eigenes Team die listRule der meisten Collections
* hängt an `team.users`, nicht an `team.owner`.
*/
remove(record: RidersResponse) {
const team = this.teams.records.find((t) => t.id === record.team)
const userId = record.user || null
const dropsMembership = !!userId && !!team && userId !== team.owner
const text = dropsMembership
? `Möchten Sie „${riderName(record)}" wirklich löschen? Alle Teilnahmen und Zeiten dieses Fahrers werden ebenfalls gelöscht. ` +
`Das zugehörige Konto verliert seine Mitgliedschaft im Team — gelöscht wird es nicht, es kann über einen Einladungslink zurückkommen.`
: `Möchten Sie „${riderName(record)}" wirklich löschen? Alle Teilnahmen und Zeiten dieses Fahrers werden ebenfalls gelöscht.`
return new Promise<void>((resolve, reject) => {
app.confirm.request({
title: 'Fahrer löschen?',
text: `Möchten Sie „${fullName(record)}" wirklich löschen? Alle Teilnahmen und Zeiten dieses Fahrers werden ebenfalls gelöscht.`,
text,
yes: async () => {
try {
// Erst die Mitgliedschaft, dann der Fahrer. Andersherum
// stünde bei einem Fehler im zweiten Schritt genau das
// verwaiste Konto da, das hier verhindert werden soll.
if (dropsMembership) {
await this.teams.removeMember(team!.id, userId!)
}
await api.collection('riders').delete(record.id)
app.confirm.close()
resolve()
@ -116,9 +158,14 @@ export class RiderStore {
return this.scoped.filter((r) => r.user === userId)
}
/** Bestehendes Konto zuordnen oder die Zuordnung lösen. */
async linkUser(riderId: string, userId: string | null) {
return await this.edit(riderId, { user: userId ?? '' } as Partial<RidersResponse>)
/**
* Konto an den Fahrer hängen. Nur intern und nur in diese Richtung: Ein
* Konto entsteht am Fahrer, ein Lösen der Verbindung gibt es nicht sonst
* stünde dieselbe Person zweimal in der Liste, einmal mit und einmal ohne
* Login.
*/
private async linkUser(riderId: string, userId: string) {
return await this.edit(riderId, { user: userId } as Partial<RidersResponse>)
}
/**
@ -131,7 +178,7 @@ export class RiderStore {
): Promise<UsersResponse> {
const user = await this.teams.createMember({
...data,
name: data.name?.trim() || fullName(rider),
name: data.name?.trim() || riderName(rider),
})
await this.linkUser(rider.id, user.id)
@ -142,13 +189,7 @@ export class RiderStore {
get filtered() {
const q = this.searchQuery.toLowerCase().trim()
if (!q) return this.scoped
return this.scoped.filter((r) => {
return (
(r.firstname ?? '').toLowerCase().includes(q) ||
(r.lastname ?? '').toLowerCase().includes(q) ||
(r.name ?? '').toLowerCase().includes(q)
)
})
return this.scoped.filter((r) => riderName(r).toLowerCase().includes(q))
}
destroy() {

View file

@ -11,6 +11,7 @@ export enum Collections {
Events = "events",
Riders = "riders",
Runs = "runs",
TeamInvites = "team_invites",
Teams = "teams",
Times = "times",
TrailComments = "trail_comments",
@ -116,9 +117,7 @@ export type EventsRecord = {
export type RidersRecord = {
avatar?: string
created?: IsoDateString
firstname?: string
id: string
lastname?: string
name?: string
team?: RecordIdString
updated?: IsoDateString
@ -134,6 +133,18 @@ export type RunsRecord = {
updated?: IsoDateString
}
export type TeamInvitesRecord = {
created?: IsoDateString
created_by?: RecordIdString
expires?: IsoDateString
id: string
max_uses?: number
team: RecordIdString
token: string
updated?: IsoDateString
uses?: number
}
export type TeamsRecord = {
admins?: RecordIdString[]
created?: IsoDateString
@ -268,6 +279,7 @@ export type EventSeriesResponse<Texpand = unknown> = Required<EventSeriesRecord>
export type EventsResponse<Texpand = unknown> = Required<EventsRecord> & BaseSystemFields<Texpand>
export type RidersResponse<Texpand = unknown> = Required<RidersRecord> & BaseSystemFields<Texpand>
export type RunsResponse<Texpand = unknown> = Required<RunsRecord> & BaseSystemFields<Texpand>
export type TeamInvitesResponse<Texpand = unknown> = Required<TeamInvitesRecord> & BaseSystemFields<Texpand>
export type TeamsResponse<Texpand = unknown> = Required<TeamsRecord> & BaseSystemFields<Texpand>
export type TimesResponse<Texpand = unknown> = Required<TimesRecord> & BaseSystemFields<Texpand>
export type TrailCommentsResponse<Texpand = unknown> = Required<TrailCommentsRecord> & BaseSystemFields<Texpand>
@ -285,6 +297,7 @@ export type CollectionRecords = {
events: EventsRecord
riders: RidersRecord
runs: RunsRecord
team_invites: TeamInvitesRecord
teams: TeamsRecord
times: TimesRecord
trail_comments: TrailCommentsRecord
@ -301,6 +314,7 @@ export type CollectionResponses = {
events: EventsResponse
riders: RidersResponse
runs: RunsResponse
team_invites: TeamInvitesResponse
teams: TeamsResponse
times: TimesResponse
trail_comments: TrailCommentsResponse
@ -320,6 +334,7 @@ export type TypedPocketBase = PocketBase & {
collection(idOrName: 'events'): RecordService<EventsResponse>
collection(idOrName: 'riders'): RecordService<RidersResponse>
collection(idOrName: 'runs'): RecordService<RunsResponse>
collection(idOrName: 'team_invites'): RecordService<TeamInvitesResponse>
collection(idOrName: 'teams'): RecordService<TeamsResponse>
collection(idOrName: 'times'): RecordService<TimesResponse>
collection(idOrName: 'trail_comments'): RecordService<TrailCommentsResponse>

View file

@ -8,7 +8,7 @@
import { goto } from '$app/navigation'
import { getEventContext } from '$lib/stores/events.svelte'
import { getRunContext } from '$lib/stores/runs.svelte'
import { fullName, getRiderContext } from '$lib/stores/riders.svelte'
import { riderName, getRiderContext } from '$lib/stores/riders.svelte'
import { getTimeContext } from '$lib/stores/times.svelte'
import { getEventParticipantContext } from '$lib/stores/eventParticipants.svelte'
import { ticker } from '$lib/stores/ticker.svelte'
@ -85,7 +85,7 @@
})
</script>
<svelte:head><title>{rider ? fullName(rider) : 'Fahrer'} {event?.name ?? 'Event'}</title></svelte:head>
<svelte:head><title>{rider ? riderName(rider) : 'Fahrer'} {event?.name ?? 'Event'}</title></svelte:head>
{#if !valid}
<Card>
@ -117,7 +117,7 @@
<Badge variant="outline" class="text-lg px-3 py-1">{entry.number}</Badge>
{/if}
<h1 class="text-3xl font-bold tracking-tight">
{fullName(rider) || 'Ohne Name'}
{riderName(rider) || 'Ohne Name'}
</h1>
</div>
</div>

View file

@ -9,7 +9,7 @@
import { page } from '$app/state'
import { getEventContext } from '$lib/stores/events.svelte'
import { getRunContext } from '$lib/stores/runs.svelte'
import { fullName, getRiderContext } from '$lib/stores/riders.svelte'
import { riderName, getRiderContext } from '$lib/stores/riders.svelte'
import { getTimeContext } from '$lib/stores/times.svelte'
import { getEventParticipantContext } from '$lib/stores/eventParticipants.svelte'
import { ticker } from '$lib/stores/ticker.svelte'
@ -75,7 +75,7 @@
rows.push([
String(i + 1),
event ? participants.numberOf(event.id, t.rider) : '',
r ? fullName(r) : '',
r ? riderName(r) : '',
t.formattedTime ?? '',
String(t.correction ?? 0),
])
@ -195,7 +195,7 @@
</Table.Cell>
<Table.Cell class="font-medium">
{#if r}
<a href={riderPath(r.id)} class="hover:underline">{fullName(r) || '—'}</a>
<a href={riderPath(r.id)} class="hover:underline">{riderName(r) || '—'}</a>
{:else}
{/if}
@ -233,7 +233,7 @@
</Badge>
<span class="truncate">
{#if r}
<a href={riderPath(r.id)} class="hover:underline">{fullName(r) || '—'}</a>
<a href={riderPath(r.id)} class="hover:underline">{riderName(r) || '—'}</a>
{:else}
{/if}