1+ // src/core/timeClock.ts
2+
3+ export type ClockTime = { h : number ; m : number } ;
4+
5+ function getOptions ( q : any ) : string [ ] {
6+ const opts = q ?. options ?? q ?. choices ?? q ?. mcqOptions ?? q ?. answers ;
7+ return Array . isArray ( opts ) ? opts . map ( String ) : [ ] ;
8+ }
9+
10+ function clamp ( n : number , min : number , max : number ) {
11+ return Math . max ( min , Math . min ( max , n ) ) ;
12+ }
13+
14+ export function parseClockTimeFromString ( s : string ) : ClockTime | null {
15+ const m = String ( s ?? "" ) . match ( / \b ( \d { 1 , 2 } ) \s * : \s * ( \d { 2 } ) \b / ) ;
16+ if ( ! m ) return null ;
17+ const h = Number ( m [ 1 ] ) ;
18+ const mm = Number ( m [ 2 ] ) ;
19+ if ( ! Number . isFinite ( h ) || ! Number . isFinite ( mm ) ) return null ;
20+ if ( mm < 0 || mm > 59 ) return null ;
21+ if ( h < 0 || h > 23 ) return null ;
22+ return { h, m : mm } ;
23+ }
24+
25+ function analogHour ( h24 : number ) {
26+ const h = ( ( h24 % 12 ) + 12 ) % 12 ;
27+ return h === 0 ? 12 : h ;
28+ }
29+
30+ function matchOptionIndexByTime ( opts : string [ ] , t : ClockTime ) : number {
31+ const th = analogHour ( t . h ) ;
32+ for ( let i = 0 ; i < opts . length ; i ++ ) {
33+ const ot = parseClockTimeFromString ( opts [ i ] ) ;
34+ if ( ! ot ) continue ;
35+ if ( analogHour ( ot . h ) === th && ot . m === t . m ) return i ;
36+ }
37+ return - 1 ;
38+ }
39+
40+ function letterToIndex ( v : string ) : number | null {
41+ const s = String ( v ?? "" ) . trim ( ) . toUpperCase ( ) ;
42+ const m = s . match ( / \b ( [ A - Z ] ) \b / ) ;
43+ if ( ! m ?. [ 1 ] ) return null ;
44+ return m [ 1 ] . charCodeAt ( 0 ) - 65 ;
45+ }
46+
47+ function numberToIndex ( v : any , len : number ) : number | null {
48+ const n = typeof v === "number" ? v : Number ( String ( v ?? "" ) . trim ( ) ) ;
49+ if ( ! Number . isFinite ( n ) ) return null ;
50+ if ( n >= 0 && n < len ) return n ;
51+ if ( n >= 1 && n <= len ) return n - 1 ;
52+ return null ;
53+ }
54+
55+ function getQuestionId ( q : any ) : string | null {
56+ const id = q ?. id ?? q ?. qid ?? q ?. uid ?? q ?. key ?? q ?. slug ?? q ?. questionId ?? q ?. uuid ?? q ?. meta ?. id ;
57+ if ( typeof id === "string" && id . trim ( ) ) return id . trim ( ) ;
58+ if ( typeof id === "number" && Number . isFinite ( id ) ) return String ( id ) ;
59+ return null ;
60+ }
61+
62+ export function looksLikeTimeMcq ( q : any ) : boolean {
63+ const opts = getOptions ( q ) ;
64+ if ( opts . length < 3 ) return false ;
65+
66+ const timeLike = opts . filter ( ( o ) => / \b \d { 1 , 2 } \s * : \s * \d { 2 } \b / . test ( String ( o ) ) ) . length ;
67+ if ( timeLike >= 3 ) return true ;
68+
69+ const text = `${ q ?. prompt ?? q ?. question ?? q ?. stem ?? q ?. text ?? "" } ` . toLowerCase ( ) ;
70+ return text . includes ( "time" ) && ( text . includes ( "clock" ) || timeLike >= 2 ) ;
71+ }
72+
73+ /**
74+ * IMPORTANT FIX:
75+ * If options exist, we FIRST map answer => option index (letter/index/text).
76+ * Only then do we parse a time string from the answer, and even then ONLY if it matches an option.
77+ */
78+ export function resolveClockTimeFromSet ( set : any , q : any , index : number ) : ClockTime | null {
79+ const opts = getOptions ( q ) ;
80+
81+ // explicit fields (rare; if present, still require matching an option when options exist)
82+ const explicit =
83+ q ?. clockTime ?? q ?. time ?? q ?. meta ?. clockTime ?? q ?. meta ?. time ?? q ?. data ?. clockTime ?? q ?. data ?. time ;
84+ if ( explicit != null ) {
85+ const t = parseClockTimeFromString ( String ( explicit ) ) ;
86+ if ( t ) {
87+ if ( opts . length === 0 ) return t ;
88+ const mi = matchOptionIndexByTime ( opts , t ) ;
89+ if ( mi >= 0 ) return parseClockTimeFromString ( opts [ mi ] ) ?? t ;
90+ }
91+ }
92+
93+ // answer key by id or parallel
94+ const qid = getQuestionId ( q ) ;
95+ const ak = Array . isArray ( set ?. answerKey ) ? set . answerKey : [ ] ;
96+
97+ let answer : any = undefined ;
98+ if ( qid ) {
99+ const hit = ak . find ( ( k : any ) => String ( k ?. questionId ?? k ?. id ?? k ?. qid ?? "" ) === qid ) ;
100+ answer = hit ?. answer ?? hit ?. value ?? hit ?. correct ?? hit ;
101+ }
102+ if ( answer == null && ak . length === ( Array . isArray ( set ?. questions ) ? set . questions . length : - 1 ) ) {
103+ const k = ak [ index ] ;
104+ answer = k ?. answer ?? k ?. value ?? k ?. correct ?? k ;
105+ }
106+
107+ const ansStr = answer == null ? "" : String ( answer ) ;
108+
109+ // ✅ If MCQ options exist: prefer letter/index/text mapping to OPTIONS (prevents “random time” bug)
110+ if ( opts . length > 0 && ansStr . trim ( ) ) {
111+ const li = letterToIndex ( ansStr ) ;
112+ if ( li != null && li >= 0 && li < opts . length ) {
113+ const t = parseClockTimeFromString ( opts [ li ] ) ;
114+ if ( t ) return t ;
115+ }
116+
117+ const ni = numberToIndex ( answer , opts . length ) ;
118+ if ( ni != null ) {
119+ const t = parseClockTimeFromString ( opts [ ni ] ) ;
120+ if ( t ) return t ;
121+ }
122+
123+ const exactIdx = opts . findIndex ( ( o ) => String ( o ) . trim ( ) === ansStr . trim ( ) ) ;
124+ if ( exactIdx >= 0 ) {
125+ const t = parseClockTimeFromString ( opts [ exactIdx ] ) ;
126+ if ( t ) return t ;
127+ }
128+ }
129+
130+ // Parse time from answer text ONLY if it matches an option (answers can contain multiple times)
131+ if ( answer != null ) {
132+ const t = parseClockTimeFromString ( ansStr ) ;
133+ if ( t ) {
134+ if ( opts . length === 0 ) return t ;
135+ const mi = matchOptionIndexByTime ( opts , t ) ;
136+ if ( mi >= 0 ) return parseClockTimeFromString ( opts [ mi ] ) ?? t ;
137+ }
138+ }
139+
140+ // Last resort: pick the first time-like option (keeps worksheet complete if key is odd)
141+ for ( const o of opts ) {
142+ const t = parseClockTimeFromString ( String ( o ) ) ;
143+ if ( t ) return t ;
144+ }
145+
146+ return null ;
147+ }
148+
149+ export function clockSvgDataUri ( time : ClockTime , size = 160 ) : string {
150+ const h = ( ( time . h % 12 ) + 12 ) % 12 ;
151+ const m = clamp ( time . m , 0 , 59 ) ;
152+
153+ const minuteDeg = ( m / 60 ) * 360 ;
154+ const hourDeg = ( ( h + m / 60 ) / 12 ) * 360 ;
155+
156+ const ticks = Array . from ( { length : 12 } )
157+ . map ( ( _ , i ) => {
158+ const thick = i % 3 === 0 ? 2.2 : 1.4 ;
159+ const y1 = 6 ;
160+ const y2 = i % 3 === 0 ? 16 : 13 ;
161+ const deg = i * 30 ;
162+ return `<line x1="50" y1="${ y1 } " x2="50" y2="${ y2 } " stroke="black" stroke-width="${ thick } " stroke-linecap="round" transform="rotate(${ deg } 50 50)" />` ;
163+ } )
164+ . join ( "" ) ;
165+
166+ const svg = `<?xml version="1.0" encoding="UTF-8"?>
167+ <svg xmlns="http://www.w3.org/2000/svg" width="${ size } " height="${ size } " viewBox="0 0 100 100" role="img" aria-label="Clock">
168+ <circle cx="50" cy="50" r="48" fill="white" stroke="black" stroke-width="2" />
169+ ${ ticks }
170+ <line x1="50" y1="50" x2="50" y2="22" stroke="black" stroke-width="4" stroke-linecap="round" transform="rotate(${ hourDeg } 50 50)" />
171+ <line x1="50" y1="50" x2="50" y2="12" stroke="black" stroke-width="3" stroke-linecap="round" transform="rotate(${ minuteDeg } 50 50)" />
172+ <circle cx="50" cy="50" r="2.5" fill="black" />
173+ </svg>` ;
174+
175+ return `data:image/svg+xml;charset=utf-8,${ encodeURIComponent ( svg ) } ` ;
176+ }
0 commit comments