From 8c11095b1712ff4a82afe575b4eebd2e439040fb Mon Sep 17 00:00:00 2001 From: Daniel Michelberger Date: Mon, 7 Sep 2026 22:10:30 +0200 Subject: [PATCH] feat: Ein Fahrer hat einen Namen, nicht zwei MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) Claude-Session: https://claude.ai/code/session_01P32KoesVtABd6xWsqMKzhr --- .../pb_migrations/1754400000_init_schema.js | 32 +------ .../1754501800_riders_single_name.js | 87 +++++++++++++++++++ .../lib/components/RunningTimesToast.svelte | 4 +- frontend/src/lib/components/TimeDialog.svelte | 2 +- .../lib/stores/eventParticipants.svelte.ts | 11 ++- frontend/src/lib/stores/riders.svelte.ts | 73 ++++++++++++---- frontend/src/lib/types.d.ts | 19 +++- .../events/[id]/riders/[riderId]/+page.svelte | 6 +- .../events/[id]/runs/[runId]/+page.svelte | 8 +- 9 files changed, 177 insertions(+), 65 deletions(-) create mode 100644 backend/pb_migrations/1754501800_riders_single_name.js diff --git a/backend/pb_migrations/1754400000_init_schema.js b/backend/pb_migrations/1754400000_init_schema.js index b0da36c..efe36c8 100644 --- a/backend/pb_migrations/1754400000_init_schema.js +++ b/backend/pb_migrations/1754400000_init_schema.js @@ -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", diff --git a/backend/pb_migrations/1754501800_riders_single_name.js b/backend/pb_migrations/1754501800_riders_single_name.js new file mode 100644 index 0000000..20a0082 --- /dev/null +++ b/backend/pb_migrations/1754501800_riders_single_name.js @@ -0,0 +1,87 @@ +/// + +// 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) + } +}) diff --git a/frontend/src/lib/components/RunningTimesToast.svelte b/frontend/src/lib/components/RunningTimesToast.svelte index 331aa55..1942c45 100644 --- a/frontend/src/lib/components/RunningTimesToast.svelte +++ b/frontend/src/lib/components/RunningTimesToast.svelte @@ -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) } diff --git a/frontend/src/lib/components/TimeDialog.svelte b/frontend/src/lib/components/TimeDialog.svelte index ef859eb..3f2389b 100644 --- a/frontend/src/lib/components/TimeDialog.svelte +++ b/frontend/src/lib/components/TimeDialog.svelte @@ -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' diff --git a/frontend/src/lib/stores/eventParticipants.svelte.ts b/frontend/src/lib/stores/eventParticipants.svelte.ts index 3f4a54b..86ff826 100644 --- a/frontend/src/lib/stores/eventParticipants.svelte.ts +++ b/frontend/src/lib/stores/eventParticipants.svelte.ts @@ -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() { diff --git a/frontend/src/lib/stores/riders.svelte.ts b/frontend/src/lib/stores/riders.svelte.ts index 654e6bc..714b22b 100644 --- a/frontend/src/lib/stores/riders.svelte.ts +++ b/frontend/src/lib/stores/riders.svelte.ts @@ -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((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) + /** + * 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) } /** @@ -131,7 +178,7 @@ export class RiderStore { ): Promise { 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() { diff --git a/frontend/src/lib/types.d.ts b/frontend/src/lib/types.d.ts index ee302d0..0fab11a 100644 --- a/frontend/src/lib/types.d.ts +++ b/frontend/src/lib/types.d.ts @@ -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 = Required export type EventsResponse = Required & BaseSystemFields export type RidersResponse = Required & BaseSystemFields export type RunsResponse = Required & BaseSystemFields +export type TeamInvitesResponse = Required & BaseSystemFields export type TeamsResponse = Required & BaseSystemFields export type TimesResponse = Required & BaseSystemFields export type TrailCommentsResponse = Required & BaseSystemFields @@ -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 collection(idOrName: 'riders'): RecordService collection(idOrName: 'runs'): RecordService + collection(idOrName: 'team_invites'): RecordService collection(idOrName: 'teams'): RecordService collection(idOrName: 'times'): RecordService collection(idOrName: 'trail_comments'): RecordService diff --git a/frontend/src/routes/dashboard/events/[id]/riders/[riderId]/+page.svelte b/frontend/src/routes/dashboard/events/[id]/riders/[riderId]/+page.svelte index a33905b..fa6f270 100644 --- a/frontend/src/routes/dashboard/events/[id]/riders/[riderId]/+page.svelte +++ b/frontend/src/routes/dashboard/events/[id]/riders/[riderId]/+page.svelte @@ -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 @@ }) -{rider ? fullName(rider) : 'Fahrer'} – {event?.name ?? 'Event'} +{rider ? riderName(rider) : 'Fahrer'} – {event?.name ?? 'Event'} {#if !valid} @@ -117,7 +117,7 @@ {entry.number} {/if}

- {fullName(rider) || 'Ohne Name'} + {riderName(rider) || 'Ohne Name'}

diff --git a/frontend/src/routes/dashboard/events/[id]/runs/[runId]/+page.svelte b/frontend/src/routes/dashboard/events/[id]/runs/[runId]/+page.svelte index 6d25405..fb63dbb 100644 --- a/frontend/src/routes/dashboard/events/[id]/runs/[runId]/+page.svelte +++ b/frontend/src/routes/dashboard/events/[id]/runs/[runId]/+page.svelte @@ -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 @@ {#if r} - {fullName(r) || '—'} + {riderName(r) || '—'} {:else} — {/if} @@ -233,7 +233,7 @@ {#if r} - {fullName(r) || '—'} + {riderName(r) || '—'} {:else} — {/if}