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}