feat(timetable): pure logic ports — snap, lane, conflict, b2b, capacity, time-grid (Session 4 step 2)
Ports the prototype's helpers.js + cascade-bump algorithm into typed
TypeScript modules in apps/app/src/lib/timetable/:
- snap.ts — 5-minute snap (RFC D7) + 15-min minimum duration
- time-grid.ts — pixel ↔ minute ↔ ISO-8601 coordinate conversions
- conflict.ts — same-stage same-lane overlap detection (RFC D5)
- b2b.ts — back-to-back marker links, 3-min threshold (RFC D26)
- capacity.ts — 110% over-capacity warn level (RFC D25)
- lane.ts — two-pass resolver + drag-preview cascade (D13/D18/D19,
client-side preview only; server is authoritative)
All functions are pure (no Vue, no DOM). Tested in Phase C.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
75
apps/app/src/lib/timetable/b2b.ts
Normal file
75
apps/app/src/lib/timetable/b2b.ts
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
import type { Performance } from '@/types/timetable'
|
||||||
|
import { ArtistEngagementStatus } from '@/types/timetable'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* RFC v0.2 D26 — back-to-back marker rule. Two consecutive non-cancelled
|
||||||
|
* performances on the same stage with `p2.start_at - p1.end_at ∈ [0, 3]`
|
||||||
|
* minutes get a B2B marker.
|
||||||
|
*
|
||||||
|
* (RFC text says "≤5 min" in D6/D25 but the sprint-4 prompt's component
|
||||||
|
* spec calls for the prototype's stricter 3-minute threshold. Exposed as
|
||||||
|
* a constant so the threshold is one place.)
|
||||||
|
*/
|
||||||
|
export const B2B_THRESHOLD_MIN = 3
|
||||||
|
|
||||||
|
export interface B2BLink {
|
||||||
|
leftId: string
|
||||||
|
rightId: string
|
||||||
|
gapMin: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export function findB2BLinks(performances: Performance[], thresholdMin = B2B_THRESHOLD_MIN): B2BLink[] {
|
||||||
|
const links: B2BLink[] = []
|
||||||
|
const groups = new Map<string, Performance[]>()
|
||||||
|
|
||||||
|
for (const p of performances) {
|
||||||
|
if (isCancelled(p))
|
||||||
|
continue
|
||||||
|
if (p.stage_id === null || p.start_at === null || p.end_at === null)
|
||||||
|
continue
|
||||||
|
|
||||||
|
// B2B is per-lane (changeover relevant within a lane).
|
||||||
|
const key = `${p.stage_id}::${p.event_id}::${p.lane_resolved}`
|
||||||
|
const list = groups.get(key) ?? []
|
||||||
|
|
||||||
|
list.push(p)
|
||||||
|
groups.set(key, list)
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const list of groups.values()) {
|
||||||
|
if (list.length < 2)
|
||||||
|
continue
|
||||||
|
const sorted = list.slice().sort((a, b) => Date.parse(a.start_at!) - Date.parse(b.start_at!))
|
||||||
|
for (let i = 0; i < sorted.length - 1; i++) {
|
||||||
|
const left = sorted[i]
|
||||||
|
const right = sorted[i + 1]
|
||||||
|
const gapMs = Date.parse(right.start_at!) - Date.parse(left.end_at!)
|
||||||
|
const gapMin = gapMs / 60_000
|
||||||
|
if (gapMin >= 0 && gapMin <= thresholdMin)
|
||||||
|
links.push({ leftId: left.id, rightId: right.id, gapMin })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return links
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convenience: returns two sets — performance IDs that have a B2B link
|
||||||
|
* to the LEFT (earlier neighbour ends close), and to the RIGHT
|
||||||
|
* (later neighbour starts close). Drives the dot rendering on the block.
|
||||||
|
*/
|
||||||
|
export function findB2BSides(performances: Performance[], thresholdMin = B2B_THRESHOLD_MIN): { leftSet: Set<string>; rightSet: Set<string> } {
|
||||||
|
const links = findB2BLinks(performances, thresholdMin)
|
||||||
|
const leftSet = new Set<string>()
|
||||||
|
const rightSet = new Set<string>()
|
||||||
|
for (const link of links) {
|
||||||
|
rightSet.add(link.leftId)
|
||||||
|
leftSet.add(link.rightId)
|
||||||
|
}
|
||||||
|
|
||||||
|
return { leftSet, rightSet }
|
||||||
|
}
|
||||||
|
|
||||||
|
function isCancelled(p: Performance): boolean {
|
||||||
|
return p.engagement?.booking_status?.value === ArtistEngagementStatus.CANCELLED
|
||||||
|
}
|
||||||
57
apps/app/src/lib/timetable/capacity.ts
Normal file
57
apps/app/src/lib/timetable/capacity.ts
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
import type { ArtistEngagement, Performance, Stage } from '@/types/timetable'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* RFC v0.2 D25 — capacity warning when expected attendance exceeds
|
||||||
|
* the stage capacity by more than the tolerance (defaults to 10%).
|
||||||
|
*
|
||||||
|
* Inputs:
|
||||||
|
* `stage.capacity` (nullable; null = no constraint, no warn)
|
||||||
|
* `engagement.crew_count + engagement.guests_count` if present
|
||||||
|
* else `artist.default_draw` if present
|
||||||
|
*
|
||||||
|
* Returns null if no warning applies (incl. missing data).
|
||||||
|
*/
|
||||||
|
export const CAPACITY_TOLERANCE = 1.1
|
||||||
|
|
||||||
|
export type CapacityLevel = 'warn' | 'critical'
|
||||||
|
|
||||||
|
export interface CapacityState {
|
||||||
|
level: CapacityLevel
|
||||||
|
expected: number
|
||||||
|
capacity: number
|
||||||
|
ratio: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export function evaluateCapacity(
|
||||||
|
performance: Performance,
|
||||||
|
stage: Stage | null | undefined,
|
||||||
|
engagement: ArtistEngagement | null | undefined,
|
||||||
|
): CapacityState | null {
|
||||||
|
if (!stage || stage.capacity === null || stage.capacity <= 0)
|
||||||
|
return null
|
||||||
|
|
||||||
|
const expected = expectedAttendance(performance, engagement)
|
||||||
|
if (expected === null)
|
||||||
|
return null
|
||||||
|
|
||||||
|
const ratio = expected / stage.capacity
|
||||||
|
if (ratio <= CAPACITY_TOLERANCE)
|
||||||
|
return null
|
||||||
|
|
||||||
|
return {
|
||||||
|
level: ratio > 1.5 ? 'critical' : 'warn',
|
||||||
|
expected,
|
||||||
|
capacity: stage.capacity,
|
||||||
|
ratio,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function expectedAttendance(_p: Performance, e: ArtistEngagement | null | undefined): number | null {
|
||||||
|
if (!e)
|
||||||
|
return null
|
||||||
|
const crewGuests = (e.crew_count ?? 0) + (e.guests_count ?? 0)
|
||||||
|
if (crewGuests > 0)
|
||||||
|
return crewGuests
|
||||||
|
|
||||||
|
return e.artist?.default_draw ?? null
|
||||||
|
}
|
||||||
84
apps/app/src/lib/timetable/conflict.ts
Normal file
84
apps/app/src/lib/timetable/conflict.ts
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
import type { Performance } from '@/types/timetable'
|
||||||
|
import { ArtistEngagementStatus } from '@/types/timetable'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Same-stage, same-event, same-lane time overlap = conflict (RFC D5).
|
||||||
|
*
|
||||||
|
* Two intervals overlap iff `a.start < b.end && b.start < a.end`. Touching
|
||||||
|
* at endpoints (a.end === b.start) is NOT overlap. Cancelled engagements
|
||||||
|
* never participate.
|
||||||
|
*
|
||||||
|
* Returns the set of performance IDs involved in at least one conflict.
|
||||||
|
*/
|
||||||
|
export function findConflicts(performances: Performance[]): Set<string> {
|
||||||
|
const conflicts = new Set<string>()
|
||||||
|
const groups = new Map<string, Performance[]>()
|
||||||
|
|
||||||
|
for (const p of performances) {
|
||||||
|
if (isCancelled(p))
|
||||||
|
continue
|
||||||
|
if (p.stage_id === null || p.start_at === null || p.end_at === null)
|
||||||
|
continue
|
||||||
|
const key = `${p.stage_id}::${p.event_id}::${p.lane_resolved}`
|
||||||
|
const list = groups.get(key) ?? []
|
||||||
|
|
||||||
|
list.push(p)
|
||||||
|
groups.set(key, list)
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const list of groups.values()) {
|
||||||
|
if (list.length < 2)
|
||||||
|
continue
|
||||||
|
const sorted = list.slice().sort((a, b) => Date.parse(a.start_at!) - Date.parse(b.start_at!))
|
||||||
|
for (let i = 0; i < sorted.length; i++) {
|
||||||
|
const ai = sorted[i]
|
||||||
|
const aEnd = Date.parse(ai.end_at!)
|
||||||
|
for (let j = i + 1; j < sorted.length; j++) {
|
||||||
|
const bj = sorted[j]
|
||||||
|
const bStart = Date.parse(bj.start_at!)
|
||||||
|
if (bStart >= aEnd)
|
||||||
|
break
|
||||||
|
|
||||||
|
// bStart < aEnd → overlap (b.start < a.end && a.start < b.end is
|
||||||
|
// already guaranteed since sorted by start ascending).
|
||||||
|
conflicts.add(ai.id)
|
||||||
|
conflicts.add(bj.id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return conflicts
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* True if two performances would conflict if placed at the given placement.
|
||||||
|
* Used in the drag-preview path to surface a conflict ring before commit.
|
||||||
|
*/
|
||||||
|
export function wouldConflict(
|
||||||
|
candidate: { id: string; stage_id: string | null; lane: number; start_at: string; end_at: string },
|
||||||
|
others: Performance[],
|
||||||
|
): boolean {
|
||||||
|
if (candidate.stage_id === null)
|
||||||
|
return false
|
||||||
|
const cs = Date.parse(candidate.start_at)
|
||||||
|
const ce = Date.parse(candidate.end_at)
|
||||||
|
|
||||||
|
for (const o of others) {
|
||||||
|
if (o.id === candidate.id || isCancelled(o))
|
||||||
|
continue
|
||||||
|
if (o.stage_id !== candidate.stage_id || o.lane_resolved !== candidate.lane)
|
||||||
|
continue
|
||||||
|
if (o.start_at === null || o.end_at === null)
|
||||||
|
continue
|
||||||
|
const os = Date.parse(o.start_at)
|
||||||
|
const oe = Date.parse(o.end_at)
|
||||||
|
if (cs < oe && os < ce)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
function isCancelled(p: Performance): boolean {
|
||||||
|
return p.engagement?.booking_status?.value === ArtistEngagementStatus.CANCELLED
|
||||||
|
}
|
||||||
6
apps/app/src/lib/timetable/index.ts
Normal file
6
apps/app/src/lib/timetable/index.ts
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
export * from './snap'
|
||||||
|
export * from './time-grid'
|
||||||
|
export * from './conflict'
|
||||||
|
export * from './b2b'
|
||||||
|
export * from './capacity'
|
||||||
|
export * from './lane'
|
||||||
147
apps/app/src/lib/timetable/lane.ts
Normal file
147
apps/app/src/lib/timetable/lane.ts
Normal file
@@ -0,0 +1,147 @@
|
|||||||
|
import type { Performance } from '@/types/timetable'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Client-side preview of the server's lane resolution (RFC v0.2 D13/D19).
|
||||||
|
* Server is authoritative; this implementation runs only during drag so
|
||||||
|
* the user sees the eventual lane assignment before the PATCH lands.
|
||||||
|
*
|
||||||
|
* Two-pass placement (mirrors the LaneResolver service in the backend):
|
||||||
|
*
|
||||||
|
* Pass 1 — items with explicit `lane` go to that lane (bumped down on
|
||||||
|
* time-overlap). Sorted by lane asc then start asc.
|
||||||
|
* Pass 2 — items without explicit lane go to the lowest free lane.
|
||||||
|
* Sorted by start asc, ties broken by id.
|
||||||
|
*
|
||||||
|
* The cohort is "every performance on the same stage in the same event".
|
||||||
|
* Cancelled engagements are excluded from collision checks.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface LaneSubject {
|
||||||
|
id: string
|
||||||
|
|
||||||
|
/** Raw persisted lane (or `null` for auto-pack candidates). */
|
||||||
|
lane: number | null
|
||||||
|
start_at: string
|
||||||
|
end_at: string
|
||||||
|
|
||||||
|
/** True if this performance should be excluded from conflict checks. */
|
||||||
|
cancelled?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LaneAssignment {
|
||||||
|
|
||||||
|
/** Map of perf id → resolved lane (0-indexed). */
|
||||||
|
laneOf: Record<string, number>
|
||||||
|
|
||||||
|
/** Width of the stage row in lanes (max lane + 1, min 1). */
|
||||||
|
laneCount: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveLanes(items: LaneSubject[]): LaneAssignment {
|
||||||
|
const laneOf: Record<string, number> = {}
|
||||||
|
let maxLane = 0
|
||||||
|
|
||||||
|
// Sort once for deterministic tie-breaking.
|
||||||
|
const sorted = items.slice().sort((a, b) => {
|
||||||
|
const sa = Date.parse(a.start_at)
|
||||||
|
const sb = Date.parse(b.start_at)
|
||||||
|
if (sa !== sb)
|
||||||
|
return sa - sb
|
||||||
|
|
||||||
|
return a.id.localeCompare(b.id)
|
||||||
|
})
|
||||||
|
|
||||||
|
const overlapsAt = (it: LaneSubject, lane: number): boolean => {
|
||||||
|
if (it.cancelled)
|
||||||
|
return false
|
||||||
|
const itStart = Date.parse(it.start_at)
|
||||||
|
const itEnd = Date.parse(it.end_at)
|
||||||
|
for (const o of sorted) {
|
||||||
|
if (o.id === it.id)
|
||||||
|
continue
|
||||||
|
if (o.cancelled)
|
||||||
|
continue
|
||||||
|
if (laneOf[o.id] !== lane)
|
||||||
|
continue
|
||||||
|
const oStart = Date.parse(o.start_at)
|
||||||
|
const oEnd = Date.parse(o.end_at)
|
||||||
|
if (oStart < itEnd && itStart < oEnd)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pass 1 — explicit lanes, sorted by requested lane asc then start asc.
|
||||||
|
const explicit = sorted
|
||||||
|
.filter(i => Number.isInteger(i.lane))
|
||||||
|
.sort((a, b) => {
|
||||||
|
if (a.lane !== b.lane)
|
||||||
|
return (a.lane as number) - (b.lane as number)
|
||||||
|
|
||||||
|
return Date.parse(a.start_at) - Date.parse(b.start_at)
|
||||||
|
})
|
||||||
|
|
||||||
|
for (const it of explicit) {
|
||||||
|
let lane = Math.max(0, it.lane as number)
|
||||||
|
while (overlapsAt(it, lane)) lane++
|
||||||
|
laneOf[it.id] = lane
|
||||||
|
if (lane > maxLane)
|
||||||
|
maxLane = lane
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pass 2 — implicit lanes, lowest free.
|
||||||
|
for (const it of sorted) {
|
||||||
|
if (it.id in laneOf)
|
||||||
|
continue
|
||||||
|
let lane = 0
|
||||||
|
while (overlapsAt(it, lane)) lane++
|
||||||
|
laneOf[it.id] = lane
|
||||||
|
if (lane > maxLane)
|
||||||
|
maxLane = lane
|
||||||
|
}
|
||||||
|
|
||||||
|
return { laneOf, laneCount: maxLane + 1 }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Drag-preview cascade: simulate dropping `dragged` at the candidate
|
||||||
|
* (stage, lane, start, end) and return the resulting lane mapping
|
||||||
|
* for the cohort. Server runs the same logic transactionally per RFC D18.
|
||||||
|
*
|
||||||
|
* Pure: never mutates inputs. The dragged item carries its desired lane
|
||||||
|
* via `lane`; cohort items keep their persisted lane.
|
||||||
|
*/
|
||||||
|
export function previewCascade(
|
||||||
|
dragged: LaneSubject,
|
||||||
|
cohort: Performance[],
|
||||||
|
): LaneAssignment {
|
||||||
|
const subjects: LaneSubject[] = cohort
|
||||||
|
.filter(p => p.id !== dragged.id && p.start_at !== null && p.end_at !== null)
|
||||||
|
.map(p => ({
|
||||||
|
id: p.id,
|
||||||
|
lane: p.lane,
|
||||||
|
start_at: p.start_at as string,
|
||||||
|
end_at: p.end_at as string,
|
||||||
|
cancelled: p.engagement?.booking_status?.value === 'cancelled',
|
||||||
|
}))
|
||||||
|
|
||||||
|
// Find an existing item that already occupies the dragged item's
|
||||||
|
// requested lane × time slot — bump dragged to oLane + 1 BEFORE running
|
||||||
|
// the resolver so cascade-bump triggers naturally.
|
||||||
|
const wanted = dragged.lane ?? 0
|
||||||
|
|
||||||
|
const occupant = subjects.find(s =>
|
||||||
|
!s.cancelled
|
||||||
|
&& s.lane === wanted
|
||||||
|
&& Date.parse(s.start_at) < Date.parse(dragged.end_at)
|
||||||
|
&& Date.parse(s.end_at) > Date.parse(dragged.start_at),
|
||||||
|
)
|
||||||
|
|
||||||
|
const finalLane = occupant ? wanted + 1 : wanted
|
||||||
|
|
||||||
|
return resolveLanes([
|
||||||
|
...subjects,
|
||||||
|
{ ...dragged, lane: finalLane },
|
||||||
|
])
|
||||||
|
}
|
||||||
27
apps/app/src/lib/timetable/snap.ts
Normal file
27
apps/app/src/lib/timetable/snap.ts
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
/**
|
||||||
|
* Round a value to the nearest multiple of `step`.
|
||||||
|
*
|
||||||
|
* RFC-TIMETABLE v0.2 D7 — drag/resize snap is 5 minutes for v1
|
||||||
|
* (the prototype audit §2.11 used 15; v1 RFC §D20 chose 5 to give the
|
||||||
|
* keyboard nudges finer control). Same primitive is reused for
|
||||||
|
* pixel-precision snaps via the pxPerMin coefficient at the call site.
|
||||||
|
*/
|
||||||
|
export function snap(value: number, step: number): number {
|
||||||
|
if (step <= 0)
|
||||||
|
return value
|
||||||
|
|
||||||
|
return Math.round(value / step) * step
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The grid snap interval in minutes. RFC D7 + D20. */
|
||||||
|
export const SNAP_MIN = 5
|
||||||
|
|
||||||
|
/** Minimum performance duration in minutes. Matches prototype audit §2.17. */
|
||||||
|
export const MIN_DURATION_MIN = 15
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Snap a minute count, then clamp into [min, max].
|
||||||
|
*/
|
||||||
|
export function snapClamp(value: number, step: number, min: number, max: number): number {
|
||||||
|
return Math.max(min, Math.min(max, snap(value, step)))
|
||||||
|
}
|
||||||
70
apps/app/src/lib/timetable/time-grid.ts
Normal file
70
apps/app/src/lib/timetable/time-grid.ts
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
/**
|
||||||
|
* Pixel ↔ time conversions for the timetable canvas.
|
||||||
|
*
|
||||||
|
* The grid is anchored on a CarbonImmutable `gridStart` (typically
|
||||||
|
* the sub-event's start_at, possibly extended to the previous hour
|
||||||
|
* boundary). All other coordinates are derived from minutes-since-
|
||||||
|
* `gridStart` × `pxPerMin`.
|
||||||
|
*
|
||||||
|
* Pure functions — no DOM access. Input is ISO-8601 strings or epoch ms;
|
||||||
|
* the grid origin is captured once per render in the page entry.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface TimeGridConfig {
|
||||||
|
|
||||||
|
/** Minute 0 of the canvas, as ISO-8601 (the sub-event start anchor). */
|
||||||
|
gridStartIso: string
|
||||||
|
|
||||||
|
/** Total minutes spanned by the canvas (e.g. 13h × 60 = 780). */
|
||||||
|
totalMinutes: number
|
||||||
|
|
||||||
|
/** Horizontal pixels per minute (drives stretch/zoom). */
|
||||||
|
pxPerMin: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isoToMinutes(iso: string, gridStartIso: string): number {
|
||||||
|
const start = Date.parse(gridStartIso)
|
||||||
|
const at = Date.parse(iso)
|
||||||
|
|
||||||
|
return Math.round((at - start) / 60_000)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function minutesToIso(minutes: number, gridStartIso: string): string {
|
||||||
|
const start = Date.parse(gridStartIso)
|
||||||
|
|
||||||
|
return new Date(start + minutes * 60_000).toISOString()
|
||||||
|
}
|
||||||
|
|
||||||
|
export function minutesToPx(minutes: number, pxPerMin: number): number {
|
||||||
|
return minutes * pxPerMin
|
||||||
|
}
|
||||||
|
|
||||||
|
export function pxToMinutes(px: number, pxPerMin: number): number {
|
||||||
|
if (pxPerMin <= 0)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
return px / pxPerMin
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The wall-clock label for a tick at `minute`. RFC v0.2 §D21 defaults
|
||||||
|
* to a 24h HH:MM format in nl-NL locale; days-spanning canvases
|
||||||
|
* preserve the wrap (00:00 + minutes since previous midnight).
|
||||||
|
*/
|
||||||
|
export function formatTickLabel(minute: number, gridStartIso: string): string {
|
||||||
|
const iso = minutesToIso(minute, gridStartIso)
|
||||||
|
const date = new Date(iso)
|
||||||
|
|
||||||
|
return date.toLocaleTimeString('nl-NL', { hour: '2-digit', minute: '2-digit', hour12: false })
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate evenly-spaced tick positions in [0, totalMinutes] at `intervalMin`.
|
||||||
|
*/
|
||||||
|
export function generateTicks(totalMinutes: number, intervalMin: number): number[] {
|
||||||
|
const ticks: number[] = []
|
||||||
|
for (let m = 0; m <= totalMinutes; m += intervalMin)
|
||||||
|
ticks.push(m)
|
||||||
|
|
||||||
|
return ticks
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user