@@ -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+ */
5566function 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 = / c a n c e l | n o .? s h o w | v o i d | r e f u n d | d e c l i n | a b a n d o n | d e l e t e d | r e m o v e d / 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