11// This script validates the form inputs before submission and updates fields if necessary
22
33document . addEventListener ( "DOMContentLoaded" , ( ) => {
4- // load icons from the datalist options
5- const icons = Array . from ( document . querySelectorAll ( "#icon-list option" ) ) . map ( option => option . value . trim ( ) ) ;
64
7- // load colours from invisible element
8- const colours = { } ;
9- const invisibleColours = document . querySelectorAll ( "#invisible-colours span" ) ;
10- invisibleColours . forEach ( span => {
11- const [ name , hex ] = span . textContent . trim ( ) . split ( ":" ) ;
12- colours [ name ] = hex ;
13- } ) ;
5+ // MARK: icons
146
157 // update icon preview
168 const iconInput = document . getElementById ( "icon" ) ;
179 const iconPreview = document . getElementById ( "icon-preview" ) ;
1810 const customIconPreview = document . getElementById ( "custom-icon-preview" ) ;
1911
12+ // load icons from the datalist options
13+ const icons = Array . from ( document . querySelectorAll ( "#icon-list option" ) ) . map ( option => option . value . trim ( ) ) ;
14+
2015 iconInput . addEventListener ( "input" , ( ) => {
2116 if ( iconInput . value . startsWith ( "ph-" ) ) {
2217 // remove the "ph-" prefix if it exists
@@ -51,10 +46,20 @@ document.addEventListener("DOMContentLoaded", () => {
5146 }
5247 } ) ;
5348
49+ // MARK: colours
50+
5451 // update colour preview
5552 const colourPicker = document . getElementById ( "color_colour" ) ;
5653 const colourText = document . getElementById ( "text_colour" ) ;
5754
55+ // load colours from invisible element
56+ const colours = { } ;
57+ const invisibleColours = document . querySelectorAll ( "#invisible-colours span" ) ;
58+ invisibleColours . forEach ( span => {
59+ const [ name , hex ] = span . textContent . trim ( ) . split ( ":" ) ;
60+ colours [ name ] = hex ;
61+ } ) ;
62+
5863 function syncColourInputs ( fromText ) {
5964 if ( fromText ) {
6065 if ( Object . keys ( colours ) . includes ( colourText . value ) ) {
@@ -82,13 +87,17 @@ document.addEventListener("DOMContentLoaded", () => {
8287 }
8388 } ) ;
8489
85- // update duration/end time
86- const endTimeInput = document . getElementById ( "end_time" ) ;
90+ // MARK: time entry
91+
92+ const timeFields = document . getElementById ( "time-fields" ) ;
93+ const addTimeButton = document . getElementById ( "add-time" ) ;
8794 const durationInput = document . getElementById ( "duration" ) ;
88- const startTimeInput = document . getElementById ( "start_time" ) ;
95+
96+ let eventDuration = 0 ; // duration in ms
8997
9098 function formatDateTimeInput ( input ) {
9199 // format the input value to YYYY-MM-DDTHH:MM
100+ if ( ! ( input instanceof Date ) ) return "" ;
92101 const pad = ( num ) => num . toString ( ) . padStart ( 2 , "0" ) ;
93102 const year = input . getFullYear ( ) ;
94103 const month = pad ( input . getMonth ( ) + 1 ) ; // months are zero-indexed
@@ -98,86 +107,224 @@ document.addEventListener("DOMContentLoaded", () => {
98107 return `${ year } -${ month } -${ day } T${ hours } :${ minutes } ` ;
99108 }
100109
101- function updateDuration ( ) {
102- if ( startTimeInput . value && endTimeInput . value ) {
110+ function formatDuration ( input ) {
111+ // convert ms into DD:HH:MM format
112+ if ( input < 0 ) input = 0 ;
113+ const days = Math . floor ( input / ( 1000 * 60 * 60 * 24 ) ) ;
114+ const hours = Math . floor ( ( input % ( 1000 * 60 * 60 * 24 ) ) / ( 1000 * 60 * 60 ) ) ;
115+ const minutes = Math . floor ( ( input % ( 1000 * 60 * 60 ) ) / ( 1000 * 60 ) ) ;
116+ return [
117+ days . toString ( ) . padStart ( 2 , "0" ) ,
118+ hours . toString ( ) . padStart ( 2 , "0" ) ,
119+ minutes . toString ( ) . padStart ( 2 , "0" )
120+ ] . join ( ":" ) ;
121+ }
122+
123+ function parseDuration ( input ) {
124+ // parse DD:HH:MM format into milliseconds
125+ if ( ! / ^ \d { 2 } : \d { 2 } : \d { 2 } $ / . test ( input ) ) return 0 ;
126+ const [ days , hours , minutes ] = input . split ( ":" ) . map ( Number ) ;
127+ return ( days * 24 * 60 * 60 + hours * 60 * 60 + minutes * 60 ) * 1000 ;
128+ }
129+
130+ function validateEndTime ( endTimeInput ) {
131+ // makes sure the end time is after the start time
132+ const entry = endTimeInput . closest ( ".time-entry" ) ;
133+ const startTimeInput = entry . querySelector ( "input[name='start_time[]']" ) ;
134+ if ( ! startTimeInput || ! endTimeInput ) return ;
135+ const startTime = new Date ( startTimeInput . value ) ;
136+ const endTime = new Date ( endTimeInput . value ) ;
137+ if ( startTime >= endTime ) {
138+ endTimeInput . setCustomValidity ( "End time must be after start time." ) ;
139+ } else {
140+ endTimeInput . setCustomValidity ( "" ) ;
141+ }
142+ }
143+
144+ function syncEndTimes ( ) {
145+ // sync all end times based on the start time and event duration
146+ document . querySelectorAll ( ".time-entry" ) . forEach ( entry => {
147+ const startTimeInput = entry . querySelector ( "input[name='start_time[]']" ) ;
148+ const endTimeInput = entry . querySelector ( "input[name='end_time[]']" ) ;
149+ if ( ! startTimeInput ) return ;
103150 const startTime = new Date ( startTimeInput . value ) ;
104- const endTime = new Date ( endTimeInput . value ) ;
151+ const endTime = new Date ( startTime . getTime ( ) + eventDuration ) ;
152+ endTimeInput . value = formatDateTimeInput ( endTime ) ;
153+ validateEndTime ( endTimeInput ) ;
154+ } ) ;
155+ }
105156
106- let duration = endTime - startTime ;
107- if ( duration < 0 ) {
108- duration = 0 ;
109- }
110- // convert duration into DD:HH:MM format
111- const days = Math . floor ( duration / ( 1000 * 60 * 60 * 24 ) ) ;
112- const hours = Math . floor ( ( duration % ( 1000 * 60 * 60 * 24 ) ) / ( 1000 * 60 * 60 ) ) ;
113- const minutes = Math . floor ( ( duration % ( 1000 * 60 * 60 ) ) / ( 1000 * 60 ) ) ;
114-
115- // prepend 0s if necessary
116- const formattedDuration = [
117- days . toString ( ) . padStart ( 2 , '0' ) ,
118- hours . toString ( ) . padStart ( 2 , '0' ) ,
119- minutes . toString ( ) . padStart ( 2 , '0' )
120- ] . join ( ':' ) ;
121- durationInput . value = formattedDuration ;
157+ function updateDuration ( endInput ) {
158+ // update duration
159+ const entry = endInput . closest ( ".time-entry" ) ;
160+ if ( ! entry ) return ;
161+
162+ const startTimeInput = entry . querySelector ( "input[name='start_time[]']" ) ;
163+
164+ if ( ! startTimeInput || ! startTimeInput . value || ! endInput . value ) return ;
165+
166+ const startTime = new Date ( startTimeInput . value ) ;
167+ const endTime = new Date ( endInput . value ) ;
168+ const duration = endTime . getTime ( ) - startTime . getTime ( ) ;
169+
170+ if ( duration < 0 ) {
171+ endInput . setCustomValidity ( "End time must be after start time." ) ;
172+ return ;
122173 }
174+
175+ endInput . setCustomValidity ( "" ) ;
176+ eventDuration = duration ;
177+ durationInput . value = formatDuration ( eventDuration ) ;
178+ syncEndTimes ( ) ;
123179 }
124180
125- function updateEndTime ( ) {
126- if ( startTimeInput . value && durationInput . value ) {
127- // confirm that the duration is in DD:HH:MM format
128- if ( / ^ \d { 2 } : (?: [ 0 1 ] \d | 2 [ 0 - 3 ] ) : [ 0 - 5 ] \d $ / . test ( durationInput . value ) ) {
129- const [ days , hours , minutes ] = durationInput . value . split ( ':' ) . map ( Number ) ;
130- const startTime = new Date ( startTimeInput . value ) ;
181+ function updateFutureStartTimes ( changedInput ) {
182+ const allEntries = Array . from ( timeFields . querySelectorAll ( ".time-entry" ) ) ;
183+ const currentIndex = allEntries . findIndex ( entry => entry . contains ( changedInput ) ) ;
131184
132- // calculate end time
133- startTime . setDate ( startTime . getDate ( ) + days ) ;
134- startTime . setHours ( startTime . getHours ( ) + hours ) ;
135- startTime . setMinutes ( startTime . getMinutes ( ) + minutes ) ;
185+ if ( currentIndex < 0 || currentIndex + 1 >= allEntries . length ) return ;
136186
137- // update end time input
138- endTimeInput . value = formatDateTimeInput ( startTime ) ;
187+ let delta = 7 * 24 * 60 * 60 * 1000 ; // default is a week
188+ if ( currentIndex > 0 ) {
189+ // if multiple entries, set the delta to the duration of the previous entry
190+ const previousEntry = allEntries [ currentIndex - 1 ] . querySelector ( "input[name='start_time[]']" ) ;
191+ if ( previousEntry . value && changedInput . value ) {
192+ delta = new Date ( changedInput . value ) . getTime ( ) - new Date ( previousEntry . value ) . getTime ( ) ;
139193 }
140194 }
195+
196+ for ( let i = currentIndex + 1 ; i < allEntries . length ; i ++ ) {
197+ const prevStartInput = allEntries [ i - 1 ] . querySelector ( "input[name='start_time[]']" ) ;
198+ const currStartInput = allEntries [ i ] . querySelector ( "input[name='start_time[]']" ) ;
199+ const currEndInput = allEntries [ i ] . querySelector ( "input[name='end_time[]']" ) ;
200+
201+ if ( ! prevStartInput . value ) continue ;
202+
203+ const newStartTime = new Date ( new Date ( prevStartInput . value ) . getTime ( ) + delta ) ;
204+ currStartInput . value = formatDateTimeInput ( newStartTime ) ;
205+ const newEndTime = new Date ( newStartTime . getTime ( ) + eventDuration ) ;
206+ currEndInput . value = formatDateTimeInput ( newEndTime ) ;
207+ validateEndTime ( currEndInput ) ;
208+ }
141209 }
142210
143- startTimeInput . addEventListener ( "input" , ( ) => {
144- updateDuration ( ) ;
145- updateEndTime ( ) ;
211+ timeFields . addEventListener ( "input" , ( event ) => {
212+ const input = event . target ;
213+
214+ if ( input . name === "start_time[]" ) {
215+ updateFutureStartTimes ( input ) ;
216+ const entry = input . closest ( ".time-entry" ) ;
217+ const endTimeInput = entry . querySelector ( "input[name='end_time[]']" ) ;
218+ const startTime = new Date ( input . value ) ;
219+ endTimeInput . value = formatDateTimeInput ( new Date ( startTime . getTime ( ) + eventDuration ) ) ;
220+ validateEndTime ( endTimeInput ) ;
221+ } else if ( input . name === "end_time[]" ) {
222+ updateDuration ( input ) ;
223+ validateEndTime ( input ) ;
224+ }
146225 } ) ;
147- durationInput . addEventListener ( "input" , ( ) => updateEndTime ( ) ) ;
148- endTimeInput . addEventListener ( "input" , ( ) => updateDuration ( ) ) ;
149226
150- // check if end time is after start time
151- endTimeInput . addEventListener ( "input" , ( ) => {
152- if ( startTimeInput . value && endTimeInput . value ) {
153- const startTime = new Date ( startTimeInput . value ) ;
154- const endTime = new Date ( endTimeInput . value ) ;
227+ durationInput . addEventListener ( "input" , ( ) => {
228+ duration = parseDuration ( durationInput . value ) ;
229+ if ( duration > 0 ) {
230+ eventDuration = duration ;
231+ syncEndTimes ( ) ;
232+ durationInput . setCustomValidity ( "" ) ;
233+ } else {
234+ durationInput . setCustomValidity ( "Please provide a valid duration in DD:HH:MM format." ) ;
235+ }
236+ } ) ;
155237
156- if ( endTime <= startTime ) {
157- endTimeInput . setCustomValidity ( "End time must be after start time" ) ;
158- } else {
159- endTimeInput . setCustomValidity ( "" ) ;
238+ if ( addTimeButton ) {
239+ addTimeButton . addEventListener ( "click" , ( event ) => {
240+ const allEntries = timeFields . querySelectorAll ( ".time-entry" ) ;
241+
242+ const lastEntry = allEntries [ allEntries . length - 1 ] ;
243+ const prevStartInput = lastEntry . querySelector ( "input[name='start_time[]']" ) ;
244+ if ( ! prevStartInput || ! prevStartInput . value ) return ;
245+
246+ let delta = 7 * 24 * 60 * 60 * 1000 ; // default is a week
247+ if ( allEntries . length > 1 ) {
248+ const penultimateEntry = allEntries [ allEntries . length - 2 ] ;
249+ const penultimateStartInput = penultimateEntry . querySelector ( "input[name='start_time[]']" ) ;
250+ delta = new Date ( prevStartInput . value ) . getTime ( ) - new Date ( penultimateStartInput . value ) . getTime ( ) ;
160251 }
252+
253+ const newStartTime = new Date ( new Date ( prevStartInput . value ) . getTime ( ) + delta ) ;
254+ const newEndTime = eventDuration > 0 ? new Date ( newStartTime . getTime ( ) + eventDuration ) : NaN ;
255+
256+ const newEntry = document . createElement ( "div" ) ;
257+ newEntry . className = "row g-3 time-entry" ;
258+ newEntry . innerHTML = `
259+ <div class="form-floating col-md-4">
260+ <input type="datetime-local" name="start_time[]" id="start_time" class="form-control" value="${ formatDateTimeInput ( newStartTime ) } " required>
261+ <label for="start_time">Start Time</label>
262+ <div class="invalid-feedback">Please provide a start time</div>
263+ <div class="valid-feedback">Looks good!</div>
264+ </div>
265+
266+ <div class="form-floating col-md-4">
267+ <input type="datetime-local" name="end_time[]" id="end_time" class="form-control" value="${ formatDateTimeInput ( newEndTime ) } ">
268+ <label for="end_time">End Time</label>
269+ <div class="invalid-feedback">Endtime must be after start time and match the duration</div>
270+ <div class="valid-feedback">Looks good!</div>
271+ </div>
272+
273+ <div class="col-md-4 d-flex align-items-center">
274+ <button type="button" class="btn btn-danger remove-time-entry"><i class="ph-bold ph-trash"></i> Remove</button>
275+ </div>
276+ ` ;
277+ timeFields . appendChild ( newEntry ) ;
278+ } ) ;
279+ }
280+
281+ timeFields . addEventListener ( "click" , ( event ) => {
282+ if ( ! event . target . classList . contains ( "remove-time-entry" ) ) return ;
283+
284+ const entry = event . target . closest ( ".time-entry" ) ;
285+ const precedingEntry = entry . previousElementSibling ;
286+
287+ entry . remove ( ) ;
288+
289+ if ( precedingEntry && precedingEntry . classList . contains ( "time-entry" ) ) {
290+ const startTimeInput = precedingEntry . querySelector ( "input[name='start_time[]']" ) ;
291+ if ( startTimeInput ) updateFutureStartTimes ( startTimeInput ) ;
292+ } else if ( document . quwrySelector ( ".time-entry" ) ) {
293+ const firstEntry = document . querySelector ( ".time-entry" ) . querySelector ( "input[name='start_time[]']" ) ;
294+ if ( firstEntry ) updateFutureStartTimes ( firstEntry ) ;
161295 }
162296 } ) ;
163297
164- // check if endtime = starttime + duration
165- endTimeInput . addEventListener ( "input" , ( ) => {
166- if ( startTimeInput . value && durationInput . value ) {
167- const startTime = new Date ( startTimeInput . value ) ;
168- const [ days , hours , minutes ] = durationInput . value . split ( ':' ) . map ( Number ) ;
169- startTime . setDate ( startTime . getDate ( ) + days ) ;
170- startTime . setHours ( startTime . getHours ( ) + hours ) ;
171- startTime . setMinutes ( startTime . getMinutes ( ) + minutes ) ;
172- const endTime = new Date ( endTimeInput . value ) ;
173- if ( endTime . getTime ( ) !== startTime . getTime ( ) ) {
174- endTimeInput . setCustomValidity ( "End time does not match duration" ) ;
175- } else {
176- endTimeInput . setCustomValidity ( "" ) ;
298+ function initialiseTimes ( ) {
299+ const firstEntry = timeFields . querySelector ( ".time-entry" ) ;
300+ if ( ! firstEntry ) return ;
301+
302+ const startTimeInput = firstEntry . querySelector ( "input[name='start_time[]']" ) ;
303+ const endTimeInput = firstEntry . querySelector ( "input[name='end_time[]']" ) ;
304+
305+ if ( startTimeInput . value && endTimeInput . value ) {
306+ const initialDuration = new Date ( endTimeInput . value ) . getTime ( ) - new Date ( startTimeInput . value ) . getTime ( ) ;
307+ if ( initialDuration >= 0 ) {
308+ eventDuration = initialDuration ;
309+ durationInput . value = formatDuration ( eventDuration ) ;
310+ }
311+ } else {
312+ const initialDuration = parseDuration ( durationInput . value ) ;
313+ if ( initialDuration > 0 ) {
314+ eventDuration = initialDuration ;
315+ if ( startTimeInput . value ) {
316+ const startTime = new Date ( startTimeInput . value ) ;
317+ endTimeInput . value = formatDateTimeInput ( new Date ( startTime . getTime ( ) + eventDuration ) ) ;
318+ }
177319 }
178320 }
179- } ) ;
180321
322+ document . querySelectorAll ( "input[name='end_time[]']" ) . forEach ( validateEndTime ) ;
323+ }
324+
325+ initialiseTimes ( ) ;
326+
327+ // MARK: form validation
181328
182329 // form validation
183330 const form = document . querySelector ( "form" ) ;
@@ -195,7 +342,7 @@ document.addEventListener("DOMContentLoaded", () => {
195342 } , false ) ;
196343
197344 // trigger events on load
198- [ iconInput , colourText , startTimeInput , endTimeInput ] . forEach ( input => {
345+ [ iconInput , colourText ] . forEach ( input => {
199346 if ( input && input . value ) {
200347 input . dispatchEvent ( new Event ( 'input' , { bubbles : true } ) ) ;
201348 }
0 commit comments