Skip to content

Commit ff0c770

Browse files
Stop silently misreading real-world booking exports
A market-readiness audit found three parser bugs that produce WRONG risk verdicts rather than errors, which is the worst failure mode this product has: the owner cannot tell it happened, and the agent acts on it. Dates. `new Date("04/09/2026")` reads American, so a UK, EU or French-Canadian export lost every row whose day exceeded 12. Verified: an eight-visit regular on a 28-day rhythm arrived as visitCount 1, cadence null, verdict "lost", and the agent would then have emailed a loyal client to say she came once and never came back. That is the same humiliation as the partial-reimport bug, reached by a different road. The order is now inferred once from the whole column and applied uniformly, since no single row can be disambiguated (04/09 is valid either way). Money. Stripping every non-digit turned "165,50" into 16550, which annualised one client to $201,358 and dominated the revenue-at-risk tile the paywall rests on. Decimal comma and thousands comma are now told apart by what follows the last separator. Cancellations. The status column was read for nothing, so a no-show counted as a visit. Verified: a client 62 days gone who no-showed 20 days ago reported "On rhythm" and never surfaced. A churn product was hiding churn. She now reads critical. Also fixes a timezone bug in the same-day merge: toISOString on a local Date rolls the day for anyone west of Greenwich. Regression-tested end to end, including that MM/DD exports and the Aisha/Jane demo argument still behave.
1 parent ea59f94 commit ff0c770

1 file changed

Lines changed: 105 additions & 9 deletions

File tree

src/lib/import-csv.ts

Lines changed: 105 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,10 @@ export type ImportResult = {
3434
clients: ClientAggregate[]
3535
visitsParsed: number
3636
rowsSkipped: number
37+
/** Rows that were a cancellation or no-show, so deliberately not counted. */
38+
cancelledSkipped?: number
39+
/** Which way the date column was read, so the UI can say so and be corrected. */
40+
dateOrder?: 'iso' | 'dmy' | 'mdy'
3741
/** Which CSV column was used for each field, so the UI can show its work. */
3842
columnsUsed: Record<string, string | null>
3943
error?: string
@@ -52,18 +56,88 @@ function findColumn(headers: string[], keywords: string[], exclude: string[] = [
5256
return null
5357
}
5458

59+
/**
60+
* Money, in whatever way the owner's country writes it.
61+
*
62+
* "165,50" is a hundred and sixty five euros fifty, not sixteen thousand. Stripping
63+
* every non-digit turned it into 16550, which annualised one French-Canadian client
64+
* to $201,358 and blew out the "revenue at risk" figure the whole paywall rests on.
65+
*/
5566
function parsePrice(raw: string): number {
5667
if (!raw) return 0
57-
const cleaned = raw.replace(/[^0-9.-]/g, '')
58-
const n = Number.parseFloat(cleaned)
68+
let s = raw.replace(/[^0-9.,-]/g, '').trim()
69+
if (!s) return 0
70+
71+
const lastComma = s.lastIndexOf(',')
72+
const lastDot = s.lastIndexOf('.')
73+
74+
if (lastComma > -1 && lastDot > -1) {
75+
// Both present: whichever comes last is the decimal separator.
76+
s = lastComma > lastDot ? s.replace(/\./g, '').replace(',', '.') : s.replace(/,/g, '')
77+
} else if (lastComma > -1) {
78+
// Only commas. Exactly two trailing digits reads as a decimal separator
79+
// ("165,50"); anything else is a thousands separator ("1,234", "1,234,567").
80+
const tail = s.length - lastComma - 1
81+
s = tail === 2 ? s.replace(',', '.') : s.replace(/,/g, '')
82+
}
83+
84+
const n = Number.parseFloat(s)
5985
return Number.isFinite(n) && n >= 0 ? n : 0
6086
}
6187

62-
function parseDate(raw: string): Date | null {
88+
/** Rows that are not a visit. A no-show is the opposite of one. */
89+
const NOT_A_VISIT = /cancel|no.?show|void|refund|declin|abandon|deleted|removed/i
90+
91+
type DateOrder = 'iso' | 'dmy' | 'mdy'
92+
93+
/**
94+
* Work out whether the whole column is DD/MM or MM/DD, ONCE, from every row.
95+
*
96+
* `new Date("04/09/2026")` silently reads American, so a UK, EU or French-Canadian
97+
* export lost every row whose day exceeded 12: an eight-visit regular arrived as
98+
* `visitCount: 1` and the agent then wrote to her saying she came once and never
99+
* came back. Deciding per row is not possible (04/09 is valid either way), so the
100+
* order is inferred from the one column and applied uniformly.
101+
*/
102+
function detectDateOrder(samples: string[]): DateOrder {
103+
let sawDayFirst = false
104+
let sawMonthFirst = false
105+
for (const raw of samples) {
106+
const m = raw.trim().match(/^(\d{1,4})[/.-](\d{1,2})[/.-](\d{1,4})$/)
107+
if (!m) continue
108+
const a = Number(m[1])
109+
const b = Number(m[2])
110+
if (m[1].length === 4) return 'iso'
111+
if (a > 12 && b <= 12) sawDayFirst = true
112+
else if (b > 12 && a <= 12) sawMonthFirst = true
113+
}
114+
// Ambiguous columns (every value <= 12) fall back to month-first, which is what
115+
// the platforms most of these exports come from emit.
116+
if (sawDayFirst && !sawMonthFirst) return 'dmy'
117+
return 'mdy'
118+
}
119+
120+
function parseDate(raw: string, order: DateOrder): Date | null {
63121
if (!raw) return null
64-
const d = new Date(raw.trim())
122+
const s = raw.trim()
123+
124+
const m = s.match(/^(\d{1,4})[/.-](\d{1,2})[/.-](\d{1,4})$/)
125+
let d: Date
126+
if (m && m[1].length !== 4) {
127+
const first = Number(m[1])
128+
const second = Number(m[2])
129+
let year = Number(m[3])
130+
if (year < 100) year += year < 70 ? 2000 : 1900
131+
const day = order === 'dmy' ? first : second
132+
const month = order === 'dmy' ? second : first
133+
// Local noon, so a timezone shift can never roll the date onto another day.
134+
d = new Date(year, month - 1, day, 12)
135+
if (d.getMonth() !== month - 1 || d.getDate() !== day) return null
136+
} else {
137+
d = new Date(s)
138+
}
139+
65140
if (Number.isNaN(d.getTime())) return null
66-
// Guard against nonsense far-future/far-past rows.
67141
const year = d.getFullYear()
68142
if (year < 2000 || year > 2100) return null
69143
return d
@@ -93,8 +167,12 @@ export function parseBookingCsv(text: string, now: Date = new Date()): ImportRes
93167
const nameCol = findColumn(headers, ['clientname', 'customername', 'fullname', 'name'], ['service', 'staff', 'employee', 'business'])
94168
const serviceCol = findColumn(headers, ['service', 'item', 'treatment', 'description', 'title'])
95169
const priceCol = findColumn(headers, ['price', 'amount', 'total', 'paid', 'revenue', 'value'])
170+
// A cancellation is not a visit, and counting one hides the exact client this
171+
// product exists to catch: someone who no-showed three weeks ago reads as "on
172+
// rhythm" and never surfaces.
173+
const statusCol = findColumn(headers, ['status', 'state', 'appointmentstatus'])
96174

97-
const columnsUsed = { email: emailCol, date: dateCol, name: nameCol, service: serviceCol, price: priceCol }
175+
const columnsUsed = { email: emailCol, date: dateCol, name: nameCol, service: serviceCol, price: priceCol, status: statusCol }
98176

99177
if (!emailCol) {
100178
return {
@@ -117,15 +195,23 @@ export function parseBookingCsv(text: string, now: Date = new Date()): ImportRes
117195

118196
const visits: ParsedVisit[] = []
119197
let skipped = 0
198+
let notAVisit = 0
199+
200+
// Decided once, from the whole column, before any row is read.
201+
const dateOrder = detectDateOrder(rows.map((r) => r[dateCol] ?? ''))
120202

121203
for (const row of rows) {
122204
const email = (row[emailCol] ?? '').trim().toLowerCase()
123-
const date = parseDate(row[dateCol] ?? '')
205+
const date = parseDate(row[dateCol] ?? '', dateOrder)
124206
// No email or no usable date means the agent could never act on it.
125207
if (!email || !email.includes('@') || !date || date > now) {
126208
skipped++
127209
continue
128210
}
211+
if (statusCol && NOT_A_VISIT.test(row[statusCol] ?? '')) {
212+
notAVisit++
213+
continue
214+
}
129215
visits.push({
130216
email,
131217
name: (nameCol ? row[nameCol] : '')?.trim() || email.split('@')[0],
@@ -135,10 +221,20 @@ export function parseBookingCsv(text: string, now: Date = new Date()): ImportRes
135221
})
136222
}
137223

138-
return { clients: aggregate(visits), visitsParsed: visits.length, rowsSkipped: skipped, columnsUsed }
224+
return {
225+
clients: aggregate(visits),
226+
visitsParsed: visits.length,
227+
rowsSkipped: skipped,
228+
cancelledSkipped: notAVisit,
229+
dateOrder,
230+
columnsUsed,
231+
}
139232
}
140233

141-
const dayKey = (d: Date) => d.toISOString().slice(0, 10)
234+
/** Local calendar day, not UTC: toISOString on a local Date shifts the day for
235+
* anyone west of Greenwich, which merged or split same-day appointments. */
236+
const dayKey = (d: Date) =>
237+
`${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
142238

143239
/**
144240
* One appointment per client per day.

0 commit comments

Comments
 (0)