feat(woche): Rasterlogik für die Wochenansicht
Aus Zeitpunkt, Stundenfenster und Blöcken entsteht alles, was die Ansicht braucht: Spalten ab Montag, Zeilen im Viertelstundenraster und die Lage jedes Balkens darin. Reine Logik, 21 Tests. Drei Feinheiten, die ohne Test leicht falsch geraten: - Das Stundenfenster ist die Hülle über alle Arbeitsfenster, mit einer Stunde Luft. Ohne die Luft läge ein Block, der genau am Rand beginnt, an der Kante und ließe sich kaum greifen. - Balken werden am Fensterrand abgeschnitten. Ein Termin von 6 bis 23 Uhr würde sonst das Raster sprengen. - Sich überschneidende Termine bekommen Spuren nebeneinander, sonst läge einer unsichtbar hinter dem anderen. Eine Spur wird wieder frei, sobald sie zu Ende ist. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
9c1c916b58
commit
bcb4b8eb02
Generated
+41
@@ -10,6 +10,8 @@
|
||||
"dependencies": {
|
||||
"@auth/prisma-adapter": "2.11.3",
|
||||
"@date-fns/tz": "1.5.0",
|
||||
"@dnd-kit/core": "6.3.1",
|
||||
"@dnd-kit/utilities": "3.2.2",
|
||||
"@prisma/adapter-pg": "7.10.0",
|
||||
"@prisma/client": "7.10.0",
|
||||
"@radix-ui/react-avatar": "1.2.6",
|
||||
@@ -593,6 +595,45 @@
|
||||
"integrity": "sha512-lwYN/vDPeNRULcepoE/LO2Pgx+7/RV+S9ARfbc9lr2DtGkOD7pAiruHvbR1RX3Qyf6ja47EWJDMsNK5vK08DJg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@dnd-kit/accessibility": {
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@dnd-kit/accessibility/-/accessibility-3.1.1.tgz",
|
||||
"integrity": "sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"tslib": "^2.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=16.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@dnd-kit/core": {
|
||||
"version": "6.3.1",
|
||||
"resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.3.1.tgz",
|
||||
"integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@dnd-kit/accessibility": "^3.1.1",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"tslib": "^2.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=16.8.0",
|
||||
"react-dom": ">=16.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@dnd-kit/utilities": {
|
||||
"version": "3.2.2",
|
||||
"resolved": "https://registry.npmjs.org/@dnd-kit/utilities/-/utilities-3.2.2.tgz",
|
||||
"integrity": "sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"tslib": "^2.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=16.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@electric-sql/pglite": {
|
||||
"version": "0.4.3",
|
||||
"resolved": "https://registry.npmjs.org/@electric-sql/pglite/-/pglite-0.4.3.tgz",
|
||||
|
||||
@@ -40,6 +40,8 @@
|
||||
"dependencies": {
|
||||
"@auth/prisma-adapter": "2.11.3",
|
||||
"@date-fns/tz": "1.5.0",
|
||||
"@dnd-kit/core": "6.3.1",
|
||||
"@dnd-kit/utilities": "3.2.2",
|
||||
"@prisma/adapter-pg": "7.10.0",
|
||||
"@prisma/client": "7.10.0",
|
||||
"@radix-ui/react-avatar": "1.2.6",
|
||||
|
||||
@@ -0,0 +1,210 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
ZEILE_HOEHE,
|
||||
balkenlage,
|
||||
fensterHuelle,
|
||||
rasterBauen,
|
||||
schluesselFuer,
|
||||
spurenVerteilen,
|
||||
zeilenBeschriftung,
|
||||
zeitpunktAus,
|
||||
} from './woche'
|
||||
|
||||
// Dienstag, 17. März 2026. Berlin liegt dann noch auf UTC+1.
|
||||
const dienstag = new Date('2026-03-17T09:00:00Z')
|
||||
|
||||
describe('rasterBauen', () => {
|
||||
it('beginnt am Montag und hat sieben Spalten', () => {
|
||||
const raster = rasterBauen(dienstag, 8 * 60, 18 * 60)
|
||||
expect(raster.spalten).toHaveLength(7)
|
||||
expect(raster.spalten[0]?.wochentag).toBe(0)
|
||||
expect(raster.spalten[6]?.wochentag).toBe(6)
|
||||
})
|
||||
|
||||
it('legt den Montag auf den richtigen Tag', () => {
|
||||
const raster = rasterBauen(dienstag, 8 * 60, 18 * 60)
|
||||
// Montag, 16. März, 00:00 Berlin = 23:00 UTC am 15.
|
||||
expect(raster.spalten[0]?.tagesBeginn.toISOString()).toBe('2026-03-15T23:00:00.000Z')
|
||||
})
|
||||
|
||||
it('teilt das Fenster in Viertelstunden', () => {
|
||||
const raster = rasterBauen(dienstag, 8 * 60, 10 * 60)
|
||||
expect(raster.zeilen).toEqual([480, 495, 510, 525, 540, 555, 570, 585])
|
||||
})
|
||||
|
||||
it('lässt die letzte Zeile vor dem Ende enden', () => {
|
||||
const raster = rasterBauen(dienstag, 8 * 60, 9 * 60)
|
||||
expect(raster.zeilen.at(-1)).toBe(525)
|
||||
})
|
||||
})
|
||||
|
||||
describe('schluesselFuer und zeitpunktAus', () => {
|
||||
it('finden zurück zum Ausgangspunkt', () => {
|
||||
const schluessel = schluesselFuer(dienstag)
|
||||
// 09:00 Berlin am selben Tag.
|
||||
expect(zeitpunktAus(schluessel, 9 * 60).toISOString()).toBe(
|
||||
'2026-03-17T08:00:00.000Z',
|
||||
)
|
||||
})
|
||||
|
||||
it('geben für denselben Tag denselben Schlüssel', () => {
|
||||
expect(schluesselFuer(new Date('2026-03-17T06:00:00Z'))).toBe(
|
||||
schluesselFuer(new Date('2026-03-17T20:00:00Z')),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('fensterHuelle', () => {
|
||||
it('umspannt alle Arbeitsfenster mit etwas Luft', () => {
|
||||
const huelle = fensterHuelle([
|
||||
{ arbeitsfensterVon: 480, arbeitsfensterBis: 1020 },
|
||||
{ arbeitsfensterVon: 1020, arbeitsfensterBis: 1260 },
|
||||
])
|
||||
expect(huelle.von).toBe(420)
|
||||
expect(huelle.bis).toBe(1320)
|
||||
})
|
||||
|
||||
it('bleibt innerhalb eines Tages', () => {
|
||||
const huelle = fensterHuelle([{ arbeitsfensterVon: 30, arbeitsfensterBis: 1410 }])
|
||||
expect(huelle.von).toBe(0)
|
||||
expect(huelle.bis).toBe(1440)
|
||||
})
|
||||
|
||||
it('nimmt ohne Bereiche einen brauchbaren Standard', () => {
|
||||
expect(fensterHuelle([])).toEqual({ von: 480, bis: 1080 })
|
||||
})
|
||||
})
|
||||
|
||||
describe('balkenlage', () => {
|
||||
const raster = rasterBauen(dienstag, 8 * 60, 18 * 60)
|
||||
const spalte = raster.spalten[1]!
|
||||
|
||||
it('setzt einen Block an die richtige Stelle', () => {
|
||||
// 09:00 bis 10:00 Berlin.
|
||||
const lage = balkenlage(
|
||||
new Date('2026-03-17T08:00:00Z'),
|
||||
new Date('2026-03-17T09:00:00Z'),
|
||||
spalte,
|
||||
raster,
|
||||
)
|
||||
// Eine Stunde nach Fensterbeginn = vier Zeilen.
|
||||
expect(lage.oben).toBe(4 * ZEILE_HOEHE)
|
||||
expect(lage.hoehe).toBe(4 * ZEILE_HOEHE)
|
||||
expect(lage.ausserhalb).toBe(false)
|
||||
})
|
||||
|
||||
it('schneidet oben ab, was vor dem Fenster beginnt', () => {
|
||||
// 07:00 bis 09:00 Berlin, Fenster beginnt um 08:00.
|
||||
const lage = balkenlage(
|
||||
new Date('2026-03-17T06:00:00Z'),
|
||||
new Date('2026-03-17T08:00:00Z'),
|
||||
spalte,
|
||||
raster,
|
||||
)
|
||||
expect(lage.oben).toBe(0)
|
||||
expect(lage.hoehe).toBe(4 * ZEILE_HOEHE)
|
||||
})
|
||||
|
||||
it('schneidet unten ab, was über das Fenster hinausgeht', () => {
|
||||
// 17:00 bis 20:00 Berlin, Fenster endet um 18:00.
|
||||
const lage = balkenlage(
|
||||
new Date('2026-03-17T16:00:00Z'),
|
||||
new Date('2026-03-17T19:00:00Z'),
|
||||
spalte,
|
||||
raster,
|
||||
)
|
||||
expect(lage.hoehe).toBe(4 * ZEILE_HOEHE)
|
||||
})
|
||||
|
||||
it('erkennt einen Block ganz außerhalb des Fensters', () => {
|
||||
// 22:00 bis 23:00 Berlin.
|
||||
const lage = balkenlage(
|
||||
new Date('2026-03-17T21:00:00Z'),
|
||||
new Date('2026-03-17T22:00:00Z'),
|
||||
spalte,
|
||||
raster,
|
||||
)
|
||||
expect(lage.ausserhalb).toBe(true)
|
||||
})
|
||||
|
||||
it('gibt auch dem kürzesten Block eine sichtbare Höhe', () => {
|
||||
const lage = balkenlage(
|
||||
new Date('2026-03-17T08:00:00Z'),
|
||||
new Date('2026-03-17T08:15:00Z'),
|
||||
spalte,
|
||||
raster,
|
||||
)
|
||||
expect(lage.hoehe).toBeGreaterThanOrEqual(ZEILE_HOEHE)
|
||||
})
|
||||
})
|
||||
|
||||
describe('zeilenBeschriftung', () => {
|
||||
it('beschriftet nur volle Stunden', () => {
|
||||
expect(zeilenBeschriftung(480)).toBe('08:00')
|
||||
expect(zeilenBeschriftung(495)).toBeNull()
|
||||
expect(zeilenBeschriftung(540)).toBe('09:00')
|
||||
})
|
||||
})
|
||||
|
||||
describe('spurenVerteilen', () => {
|
||||
function eintrag(von: string, bis: string) {
|
||||
return { beginn: new Date(von), ende: new Date(bis) }
|
||||
}
|
||||
|
||||
it('gibt einem einzelnen Eintrag die volle Breite', () => {
|
||||
const verteilt = spurenVerteilen([
|
||||
eintrag('2026-03-17T08:00:00Z', '2026-03-17T09:00:00Z'),
|
||||
])
|
||||
expect(verteilt[0]?.spur).toBe(0)
|
||||
expect(verteilt[0]?.spuren).toBe(1)
|
||||
})
|
||||
|
||||
it('legt zwei sich überschneidende Einträge nebeneinander', () => {
|
||||
const verteilt = spurenVerteilen([
|
||||
eintrag('2026-03-17T08:00:00Z', '2026-03-17T10:00:00Z'),
|
||||
eintrag('2026-03-17T09:00:00Z', '2026-03-17T11:00:00Z'),
|
||||
])
|
||||
expect(verteilt.map((v) => v.spur)).toEqual([0, 1])
|
||||
expect(verteilt.every((v) => v.spuren === 2)).toBe(true)
|
||||
})
|
||||
|
||||
it('lässt getrennte Einträge beide die volle Breite behalten', () => {
|
||||
const verteilt = spurenVerteilen([
|
||||
eintrag('2026-03-17T08:00:00Z', '2026-03-17T09:00:00Z'),
|
||||
eintrag('2026-03-17T10:00:00Z', '2026-03-17T11:00:00Z'),
|
||||
])
|
||||
expect(verteilt.every((v) => v.spuren === 1)).toBe(true)
|
||||
})
|
||||
|
||||
it('gibt eine Spur wieder frei, sobald sie zu Ende ist', () => {
|
||||
const verteilt = spurenVerteilen([
|
||||
eintrag('2026-03-17T08:00:00Z', '2026-03-17T10:00:00Z'),
|
||||
eintrag('2026-03-17T09:00:00Z', '2026-03-17T11:00:00Z'),
|
||||
// Beginnt nach dem ersten, kann dessen Spur übernehmen.
|
||||
eintrag('2026-03-17T10:00:00Z', '2026-03-17T12:00:00Z'),
|
||||
])
|
||||
expect(verteilt.map((v) => v.spur)).toEqual([0, 1, 0])
|
||||
// Alle drei hängen über eine Kette zusammen und teilen sich zwei Spuren.
|
||||
expect(verteilt.every((v) => v.spuren === 2)).toBe(true)
|
||||
})
|
||||
|
||||
it('trennt Gruppen, die sich nicht berühren', () => {
|
||||
const verteilt = spurenVerteilen([
|
||||
eintrag('2026-03-17T08:00:00Z', '2026-03-17T09:00:00Z'),
|
||||
eintrag('2026-03-17T08:30:00Z', '2026-03-17T09:30:00Z'),
|
||||
eintrag('2026-03-17T14:00:00Z', '2026-03-17T15:00:00Z'),
|
||||
])
|
||||
expect(verteilt[0]?.spuren).toBe(2)
|
||||
expect(verteilt[1]?.spuren).toBe(2)
|
||||
expect(verteilt[2]?.spuren).toBe(1)
|
||||
})
|
||||
|
||||
it('behält jeden Eintrag genau einmal', () => {
|
||||
const eintraege = [
|
||||
eintrag('2026-03-17T08:00:00Z', '2026-03-17T10:00:00Z'),
|
||||
eintrag('2026-03-17T09:00:00Z', '2026-03-17T11:00:00Z'),
|
||||
eintrag('2026-03-17T14:00:00Z', '2026-03-17T15:00:00Z'),
|
||||
]
|
||||
expect(spurenVerteilen(eintraege)).toHaveLength(eintraege.length)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,194 @@
|
||||
import { alsUhrzeit, tagesBeginn, wochenTage, wochentagIndex } from '@/lib/zeit'
|
||||
import { RASTER_MINUTEN } from '@/lib/zeitblock'
|
||||
|
||||
/**
|
||||
* Aufbau des Wochenrasters.
|
||||
*
|
||||
* Reine Logik: aus einem Zeitpunkt, einem Stundenfenster und einer Liste von
|
||||
* Blöcken entsteht alles, was die Ansicht braucht — Spalten, Zeilen und die
|
||||
* Lage jedes Blocks darin. Keine Datenbank, keine Systemzeit.
|
||||
*/
|
||||
|
||||
/** Höhe einer Rasterzeile in Pixeln. 15 Minuten = 14 px. */
|
||||
export const ZEILE_HOEHE = 14
|
||||
|
||||
export type Tagesspalte = {
|
||||
/** 00:00 dieses Tages in Europe/Berlin. */
|
||||
tagesBeginn: Date
|
||||
/** 0 = Montag. */
|
||||
wochentag: number
|
||||
/** ISO-Datum, taugt als Schlüssel. */
|
||||
schluessel: string
|
||||
}
|
||||
|
||||
export type Raster = {
|
||||
spalten: Tagesspalte[]
|
||||
/** Minuten seit Mitternacht, an denen die Zeilen beginnen. */
|
||||
zeilen: number[]
|
||||
vonMinute: number
|
||||
bisMinute: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Baut das Raster für die Woche, in der der Zeitpunkt liegt.
|
||||
*
|
||||
* Das Stundenfenster kommt von außen — sinnvollerweise als Hülle über die
|
||||
* Arbeitsfenster aller Bereiche, damit nichts abgeschnitten wird, was
|
||||
* geplant werden darf.
|
||||
*/
|
||||
export function rasterBauen(
|
||||
zeitpunkt: Date,
|
||||
vonMinute: number,
|
||||
bisMinute: number,
|
||||
): Raster {
|
||||
const tage = wochenTage(zeitpunkt)
|
||||
|
||||
const zeilen: number[] = []
|
||||
for (let minute = vonMinute; minute < bisMinute; minute += RASTER_MINUTEN) {
|
||||
zeilen.push(minute)
|
||||
}
|
||||
|
||||
return {
|
||||
spalten: tage.map((tag) => ({
|
||||
tagesBeginn: tag,
|
||||
wochentag: wochentagIndex(tag),
|
||||
schluessel: schluesselFuer(tag),
|
||||
})),
|
||||
zeilen,
|
||||
vonMinute,
|
||||
bisMinute,
|
||||
}
|
||||
}
|
||||
|
||||
/** Schlüssel eines Tages im Raster, unabhängig von der Zeitzone lesbar. */
|
||||
export function schluesselFuer(tag: Date): string {
|
||||
const beginn = tagesBeginn(tag)
|
||||
return String(beginn.getTime())
|
||||
}
|
||||
|
||||
/** Zeitpunkt aus Tagesschlüssel und Minute seit Mitternacht. */
|
||||
export function zeitpunktAus(schluessel: string, minute: number): Date {
|
||||
return new Date(Number(schluessel) + minute * 60_000)
|
||||
}
|
||||
|
||||
/**
|
||||
* Hülle über alle Arbeitsfenster, mit etwas Luft nach oben und unten.
|
||||
*
|
||||
* Ohne die Luft läge ein Block, der genau am Rand beginnt, an der Kante des
|
||||
* Rasters und ließe sich kaum greifen.
|
||||
*/
|
||||
export function fensterHuelle(
|
||||
bereiche: readonly { arbeitsfensterVon: number; arbeitsfensterBis: number }[],
|
||||
luftMinuten = 60,
|
||||
): { von: number; bis: number } {
|
||||
if (bereiche.length === 0) return { von: 8 * 60, bis: 18 * 60 }
|
||||
|
||||
const von = Math.min(...bereiche.map((b) => b.arbeitsfensterVon))
|
||||
const bis = Math.max(...bereiche.map((b) => b.arbeitsfensterBis))
|
||||
|
||||
return {
|
||||
von: Math.max(0, aufRasterMinute(von - luftMinuten)),
|
||||
bis: Math.min(24 * 60, aufRasterMinute(bis + luftMinuten)),
|
||||
}
|
||||
}
|
||||
|
||||
function aufRasterMinute(minute: number): number {
|
||||
return Math.round(minute / RASTER_MINUTEN) * RASTER_MINUTEN
|
||||
}
|
||||
|
||||
export type Balkenlage = {
|
||||
/** Abstand von oben in Pixeln. */
|
||||
oben: number
|
||||
/** Höhe in Pixeln. */
|
||||
hoehe: number
|
||||
/** Liegt der Block ganz außerhalb des sichtbaren Fensters? */
|
||||
ausserhalb: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Wo ein Zeitraum im Raster eines Tages liegt.
|
||||
*
|
||||
* Ragt er über den sichtbaren Bereich hinaus, wird er abgeschnitten — sonst
|
||||
* würde ein Termin von 6 bis 23 Uhr das ganze Raster sprengen.
|
||||
*/
|
||||
export function balkenlage(
|
||||
beginn: Date,
|
||||
ende: Date,
|
||||
spalte: Tagesspalte,
|
||||
raster: Raster,
|
||||
): Balkenlage {
|
||||
const tagBeginn = spalte.tagesBeginn.getTime()
|
||||
const vonMinute = (beginn.getTime() - tagBeginn) / 60_000
|
||||
const bisMinute = (ende.getTime() - tagBeginn) / 60_000
|
||||
|
||||
if (bisMinute <= raster.vonMinute || vonMinute >= raster.bisMinute) {
|
||||
return { oben: 0, hoehe: 0, ausserhalb: true }
|
||||
}
|
||||
|
||||
const sichtbarVon = Math.max(vonMinute, raster.vonMinute)
|
||||
const sichtbarBis = Math.min(bisMinute, raster.bisMinute)
|
||||
|
||||
return {
|
||||
oben: ((sichtbarVon - raster.vonMinute) / RASTER_MINUTEN) * ZEILE_HOEHE,
|
||||
hoehe: Math.max(
|
||||
ZEILE_HOEHE,
|
||||
((sichtbarBis - sichtbarVon) / RASTER_MINUTEN) * ZEILE_HOEHE,
|
||||
),
|
||||
ausserhalb: false,
|
||||
}
|
||||
}
|
||||
|
||||
/** Beschriftung einer Rasterzeile; nur volle Stunden bekommen eine. */
|
||||
export function zeilenBeschriftung(minute: number): string | null {
|
||||
return minute % 60 === 0 ? alsUhrzeit(minute) : null
|
||||
}
|
||||
|
||||
/**
|
||||
* Verteilt sich überschneidende Einträge auf Spuren nebeneinander.
|
||||
*
|
||||
* Ohne das läge ein Termin genau über dem anderen und einer wäre unsichtbar.
|
||||
*/
|
||||
export function spurenVerteilen<T extends { beginn: Date; ende: Date }>(
|
||||
eintraege: readonly T[],
|
||||
): { eintrag: T; spur: number; spuren: number }[] {
|
||||
const sortiert = [...eintraege].sort((a, b) => a.beginn.getTime() - b.beginn.getTime())
|
||||
|
||||
// Gruppen bilden: alles, was sich über eine Kette von Überschneidungen
|
||||
// berührt, teilt sich dieselbe Anzahl Spuren.
|
||||
const gruppen: T[][] = []
|
||||
let aktuelle: T[] = []
|
||||
let gruppenEnde = 0
|
||||
|
||||
for (const eintrag of sortiert) {
|
||||
if (aktuelle.length > 0 && eintrag.beginn.getTime() >= gruppenEnde) {
|
||||
gruppen.push(aktuelle)
|
||||
aktuelle = []
|
||||
}
|
||||
aktuelle.push(eintrag)
|
||||
gruppenEnde = Math.max(gruppenEnde, eintrag.ende.getTime())
|
||||
}
|
||||
if (aktuelle.length > 0) gruppen.push(aktuelle)
|
||||
|
||||
const ergebnis: { eintrag: T; spur: number; spuren: number }[] = []
|
||||
|
||||
for (const gruppe of gruppen) {
|
||||
const spurEnden: number[] = []
|
||||
const zuordnung: { eintrag: T; spur: number }[] = []
|
||||
|
||||
for (const eintrag of gruppe) {
|
||||
let spur = spurEnden.findIndex((ende) => ende <= eintrag.beginn.getTime())
|
||||
if (spur === -1) {
|
||||
spur = spurEnden.length
|
||||
spurEnden.push(0)
|
||||
}
|
||||
spurEnden[spur] = eintrag.ende.getTime()
|
||||
zuordnung.push({ eintrag, spur })
|
||||
}
|
||||
|
||||
for (const { eintrag, spur } of zuordnung) {
|
||||
ergebnis.push({ eintrag, spur, spuren: spurEnden.length })
|
||||
}
|
||||
}
|
||||
|
||||
return ergebnis
|
||||
}
|
||||
Reference in New Issue
Block a user