tessera-learn 0.0.5 → 0.0.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -64,7 +64,15 @@ export function validateProject(projectRoot: string): ValidationResult {
64
64
  errors.push(...pageResults.errors);
65
65
  warnings.push(...pageResults.warnings);
66
66
 
67
- // 4. Cross-cutting validations
67
+ // 4. Contract-bypass checks on project-root shell files
68
+ for (const shellFile of ['layout.svelte', 'quiz.svelte']) {
69
+ const shellPath = resolve(projectRoot, shellFile);
70
+ if (existsSync(shellPath)) {
71
+ validateContractBypass(readSourceFileCached(shellPath), shellFile, errors);
72
+ }
73
+ }
74
+
75
+ // 5. Cross-cutting validations
68
76
  if (config) {
69
77
  crossValidate(config, pageResults, errors, warnings);
70
78
  }
@@ -498,6 +506,57 @@ interface PagesValidationResult extends ValidationResult {
498
506
  pages: PageInfo[];
499
507
  }
500
508
 
509
+ /**
510
+ * Validate a single page .svelte file. Used for both section-level (flat) and
511
+ * lesson-level pages — the validation is identical, only the containing
512
+ * directory differs.
513
+ */
514
+ function validatePageFile(
515
+ filePath: string,
516
+ projectRoot: string,
517
+ assetsDir: string,
518
+ navIndex: number,
519
+ errors: string[],
520
+ warnings: string[],
521
+ assetExistsCache: Map<string, boolean>
522
+ ): { page: PageInfo; isQuiz: boolean; isGradedQuiz: boolean } {
523
+ const fileRel = relative(projectRoot, filePath);
524
+ const content = readSourceFileCached(filePath);
525
+
526
+ const pageConfig = validatePageConfig(content, fileRel, errors);
527
+
528
+ const isQuiz = !!pageConfig?.quiz;
529
+ let isGradedQuiz = false;
530
+ if (pageConfig?.quiz) {
531
+ validateQuizConfig(pageConfig.quiz, fileRel, errors);
532
+ if ((pageConfig.quiz as { graded?: unknown }).graded === true) {
533
+ isGradedQuiz = true;
534
+ }
535
+ }
536
+
537
+ const completesOnView = validateCompletesOn(pageConfig, fileRel, errors);
538
+
539
+ validateAssetRefs(content, fileRel, assetsDir, warnings, assetExistsCache);
540
+ validateQuestionComponents(content, fileRel, errors);
541
+ validateContractBypass(content, fileRel, errors);
542
+ if (
543
+ pageConfig?.quiz &&
544
+ !HAS_USE_QUESTION_RE.test(content) &&
545
+ !HAS_QUESTION_TAG_RE.test(content)
546
+ ) {
547
+ warnings.push(
548
+ `${fileRel}: quiz page has no question components or useQuestion() calls — ` +
549
+ `the quiz will have nothing to score`
550
+ );
551
+ }
552
+
553
+ return {
554
+ page: { fileRel, navIndex, hasGradedQuiz: isGradedQuiz, hasQuiz: isQuiz, completesOnView },
555
+ isQuiz,
556
+ isGradedQuiz,
557
+ };
558
+ }
559
+
501
560
  function validatePages(
502
561
  pagesDir: string,
503
562
  assetsDir: string,
@@ -581,34 +640,19 @@ function validatePages(
581
640
  }
582
641
 
583
642
  for (const fileName of sectionSvelteFiles) {
584
- const filePath = resolve(sectionPath, fileName);
585
- const fileRel = relative(projectRoot, filePath);
586
- const content = readSourceFileCached(filePath);
587
-
588
- const pageConfig = validatePageConfig(content, fileRel, errors);
589
- const navIndex = totalPages;
643
+ const result = validatePageFile(
644
+ resolve(sectionPath, fileName),
645
+ projectRoot,
646
+ assetsDir,
647
+ totalPages,
648
+ errors,
649
+ warnings,
650
+ assetExistsCache
651
+ );
590
652
  totalPages++;
591
-
592
- let pageHasGradedQuiz = false;
593
- if (pageConfig?.quiz) {
594
- totalQuizzes++;
595
- validateQuizConfig(pageConfig.quiz, fileRel, errors);
596
- if ((pageConfig.quiz as { graded?: unknown }).graded === true) {
597
- hasGradedQuiz = true;
598
- pageHasGradedQuiz = true;
599
- }
600
- }
601
-
602
- const completesOnView = validateCompletesOn(pageConfig, fileRel, errors);
603
- pages.push({
604
- fileRel,
605
- navIndex,
606
- hasGradedQuiz: pageHasGradedQuiz,
607
- hasQuiz: !!pageConfig?.quiz,
608
- completesOnView,
609
- });
610
-
611
- validateAssetRefs(content, fileRel, assetsDir, warnings, assetExistsCache);
653
+ if (result.isQuiz) totalQuizzes++;
654
+ if (result.isGradedQuiz) hasGradedQuiz = true;
655
+ pages.push(result.page);
612
656
  }
613
657
 
614
658
  // Get lesson directories
@@ -663,38 +707,19 @@ function validatePages(
663
707
 
664
708
  // Validate each .svelte file
665
709
  for (const fileName of svelteFiles) {
666
- const filePath = resolve(lessonPath, fileName);
667
- const fileRel = relative(projectRoot, filePath);
668
- const content = readSourceFileCached(filePath);
669
-
670
- const pageConfig = validatePageConfig(content, fileRel, errors);
671
- const navIndex = totalPages;
710
+ const result = validatePageFile(
711
+ resolve(lessonPath, fileName),
712
+ projectRoot,
713
+ assetsDir,
714
+ totalPages,
715
+ errors,
716
+ warnings,
717
+ assetExistsCache
718
+ );
672
719
  totalPages++;
673
-
674
- let pageHasGradedQuiz = false;
675
- if (pageConfig?.quiz) {
676
- totalQuizzes++;
677
-
678
- // Validate quiz config
679
- validateQuizConfig(pageConfig.quiz, fileRel, errors);
680
-
681
- if ((pageConfig.quiz as { graded?: unknown }).graded === true) {
682
- hasGradedQuiz = true;
683
- pageHasGradedQuiz = true;
684
- }
685
- }
686
-
687
- const completesOnView = validateCompletesOn(pageConfig, fileRel, errors);
688
- pages.push({
689
- fileRel,
690
- navIndex,
691
- hasGradedQuiz: pageHasGradedQuiz,
692
- hasQuiz: !!pageConfig?.quiz,
693
- completesOnView,
694
- });
695
-
696
- // Check $assets references
697
- validateAssetRefs(content, fileRel, assetsDir, warnings, assetExistsCache);
720
+ if (result.isQuiz) totalQuizzes++;
721
+ if (result.isGradedQuiz) hasGradedQuiz = true;
722
+ pages.push(result.page);
698
723
  }
699
724
  }
700
725
  }
@@ -774,7 +799,7 @@ function validateCompletesOn(
774
799
 
775
800
  function validateQuizConfig(quiz: unknown, fileRel: string, errors: string[]): void {
776
801
  if (!quiz || typeof quiz !== 'object') return;
777
- const cfg = quiz as { maxAttempts?: unknown; graded?: unknown };
802
+ const cfg = quiz as Record<string, unknown>;
778
803
 
779
804
  if (cfg.maxAttempts !== undefined) {
780
805
  const val = cfg.maxAttempts;
@@ -785,9 +810,258 @@ function validateQuizConfig(quiz: unknown, fileRel: string, errors: string[]): v
785
810
  }
786
811
  }
787
812
 
788
- if (cfg.graded !== undefined && typeof cfg.graded !== 'boolean') {
813
+ for (const field of ['graded', 'gatesProgress', 'showFeedback']) {
814
+ if (cfg[field] !== undefined && typeof cfg[field] !== 'boolean') {
815
+ errors.push(
816
+ `${fileRel}: quiz.${field} must be a boolean, got ${typeof cfg[field]}`
817
+ );
818
+ }
819
+ }
820
+ }
821
+
822
+ // ---------- Question Component Validation ----------
823
+
824
+ const QUESTION_COMPONENT_REQUIRED: Record<string, string[]> = {
825
+ MultipleChoice: ['question', 'options', 'correct'],
826
+ FillInTheBlank: ['question', 'answers'],
827
+ Matching: ['question', 'pairs'],
828
+ Sorting: ['question', 'items', 'targets', 'correct'],
829
+ };
830
+
831
+ type PropValue =
832
+ | { kind: 'string'; value: string }
833
+ | { kind: 'expr'; raw: string }
834
+ | { kind: 'bool' };
835
+
836
+ /** Extract a balanced {...} or [...] span starting at startIndex, or null. */
837
+ function extractBalanced(source: string, startIndex: number): string | null {
838
+ const open = source[startIndex];
839
+ if (open !== '{' && open !== '[') return null;
840
+ let depth = 0;
841
+ let inString: string | null = null;
842
+ let escaped = false;
843
+ for (let i = startIndex; i < source.length; i++) {
844
+ const char = source[i];
845
+ if (escaped) {
846
+ escaped = false;
847
+ continue;
848
+ }
849
+ if (char === '\\' && inString) {
850
+ escaped = true;
851
+ continue;
852
+ }
853
+ if (inString) {
854
+ if (char === inString) inString = null;
855
+ continue;
856
+ }
857
+ if (char === '"' || char === "'" || char === '`') {
858
+ inString = char;
859
+ continue;
860
+ }
861
+ if (char === '{' || char === '[') depth++;
862
+ if (char === '}' || char === ']') {
863
+ depth--;
864
+ if (depth === 0) return source.slice(startIndex, i + 1);
865
+ }
866
+ }
867
+ return null;
868
+ }
869
+
870
+ /**
871
+ * Parse the props of an opening tag starting just after the component name.
872
+ * Returns null if the tag can't be parsed cleanly — callers then skip it
873
+ * rather than risk a false positive.
874
+ */
875
+ function parseTagProps(content: string, start: number): Map<string, PropValue> | null {
876
+ const props = new Map<string, PropValue>();
877
+ let i = start;
878
+ while (i < content.length) {
879
+ while (i < content.length && /\s/.test(content[i])) i++;
880
+ if (i >= content.length) return null;
881
+ const c = content[i];
882
+ if (c === '>') return props;
883
+ if (c === '/' && content[i + 1] === '>') return props;
884
+ // Spread / shorthand expression — skip the whole {...} block.
885
+ if (c === '{') {
886
+ const block = extractBalanced(content, i);
887
+ if (!block) return null;
888
+ i += block.length;
889
+ continue;
890
+ }
891
+ const nameMatch = /^[A-Za-z_][\w-]*/.exec(content.slice(i));
892
+ if (!nameMatch) return null;
893
+ const propName = nameMatch[0];
894
+ i += propName.length;
895
+ while (i < content.length && /\s/.test(content[i])) i++;
896
+ if (content[i] !== '=') {
897
+ props.set(propName, { kind: 'bool' });
898
+ continue;
899
+ }
900
+ i++;
901
+ while (i < content.length && /\s/.test(content[i])) i++;
902
+ const v = content[i];
903
+ if (v === '"' || v === "'") {
904
+ const end = content.indexOf(v, i + 1);
905
+ if (end === -1) return null;
906
+ props.set(propName, { kind: 'string', value: content.slice(i + 1, end) });
907
+ i = end + 1;
908
+ } else if (v === '{') {
909
+ const block = extractBalanced(content, i);
910
+ if (!block) return null;
911
+ props.set(propName, { kind: 'expr', raw: block.slice(1, -1).trim() });
912
+ i += block.length;
913
+ } else {
914
+ return null;
915
+ }
916
+ }
917
+ return null;
918
+ }
919
+
920
+ function staticArray(prop: PropValue | undefined): unknown[] | null {
921
+ if (prop?.kind !== 'expr' || !prop.raw.startsWith('[')) return null;
922
+ try {
923
+ const parsed = JSON5.parse(prop.raw);
924
+ return Array.isArray(parsed) ? parsed : null;
925
+ } catch {
926
+ return null;
927
+ }
928
+ }
929
+
930
+ function staticNumber(prop: PropValue | undefined): number | null {
931
+ if (prop?.kind !== 'expr') return null;
932
+ try {
933
+ const parsed = JSON5.parse(prop.raw);
934
+ return typeof parsed === 'number' ? parsed : null;
935
+ } catch {
936
+ return null;
937
+ }
938
+ }
939
+
940
+ function validateQuestionComponents(
941
+ content: string,
942
+ fileRel: string,
943
+ errors: string[]
944
+ ): void {
945
+ const names = Object.keys(QUESTION_COMPONENT_REQUIRED).join('|');
946
+ const tagStartRe = new RegExp(`<(${names})(?=[\\s/>])`, 'g');
947
+ const seenIds = new Set<string>();
948
+ let m: RegExpExecArray | null;
949
+ while ((m = tagStartRe.exec(content)) !== null) {
950
+ const name = m[1];
951
+ const props = parseTagProps(content, m.index + m[0].length);
952
+ if (!props) continue;
953
+
954
+ for (const req of QUESTION_COMPONENT_REQUIRED[name]) {
955
+ if (!props.has(req)) {
956
+ errors.push(`${fileRel}: <${name}> is missing required prop "${req}"`);
957
+ }
958
+ }
959
+
960
+ const idProp = props.get('id');
961
+ if (idProp?.kind === 'string') {
962
+ if (seenIds.has(idProp.value)) {
963
+ errors.push(
964
+ `${fileRel}: duplicate question id "${idProp.value}" — each question on a page needs a unique id`
965
+ );
966
+ }
967
+ seenIds.add(idProp.value);
968
+ }
969
+
970
+ if (name === 'MultipleChoice') {
971
+ const options = staticArray(props.get('options'));
972
+ const correct = staticNumber(props.get('correct'));
973
+ if (options && correct !== null) {
974
+ if (!Number.isInteger(correct) || correct < 0 || correct >= options.length) {
975
+ errors.push(
976
+ `${fileRel}: <MultipleChoice> correct={${correct}} is out of range for ${options.length} options (valid: 0–${options.length - 1})`
977
+ );
978
+ }
979
+ }
980
+ } else if (name === 'Sorting') {
981
+ const items = staticArray(props.get('items'));
982
+ const targets = staticArray(props.get('targets'));
983
+ const correct = staticArray(props.get('correct'));
984
+ if (items && correct && correct.length !== items.length) {
985
+ errors.push(
986
+ `${fileRel}: <Sorting> correct has ${correct.length} entries but items has ${items.length} — they must be parallel arrays`
987
+ );
988
+ }
989
+ if (targets && correct) {
990
+ for (const idx of correct) {
991
+ if (
992
+ typeof idx !== 'number' ||
993
+ !Number.isInteger(idx) ||
994
+ idx < 0 ||
995
+ idx >= targets.length
996
+ ) {
997
+ errors.push(
998
+ `${fileRel}: <Sorting> correct contains ${JSON.stringify(idx)}, out of range for ${targets.length} targets (valid: 0–${targets.length - 1})`
999
+ );
1000
+ break;
1001
+ }
1002
+ }
1003
+ }
1004
+ } else if (name === 'Matching') {
1005
+ const pairs = staticArray(props.get('pairs'));
1006
+ if (pairs) {
1007
+ const bad = pairs.some(
1008
+ (p) =>
1009
+ typeof p !== 'object' ||
1010
+ p === null ||
1011
+ typeof (p as { left?: unknown }).left !== 'string' ||
1012
+ typeof (p as { right?: unknown }).right !== 'string'
1013
+ );
1014
+ if (bad) {
1015
+ errors.push(
1016
+ `${fileRel}: <Matching> pairs must be an array of { left: string, right: string } objects`
1017
+ );
1018
+ }
1019
+ }
1020
+ } else if (name === 'FillInTheBlank') {
1021
+ const answers = staticArray(props.get('answers'));
1022
+ if (answers) {
1023
+ if (answers.length === 0) {
1024
+ errors.push(`${fileRel}: <FillInTheBlank> answers must not be empty`);
1025
+ } else if (answers.some((a) => typeof a !== 'string')) {
1026
+ errors.push(
1027
+ `${fileRel}: <FillInTheBlank> answers must be an array of strings`
1028
+ );
1029
+ }
1030
+ }
1031
+ }
1032
+ }
1033
+ }
1034
+
1035
+ // ---------- Contract Bypass Detection ----------
1036
+
1037
+ const QUIZ_COMPLETE_DISPATCH_RE =
1038
+ /(?:new\s+CustomEvent\s*\(\s*['"]tessera-quiz-complete['"]|dispatchEvent\s*\([\s\S]{0,120}tessera-quiz-complete)/;
1039
+ const RUNTIME_INTERNAL_IMPORT_RE = /from\s+['"]tessera-learn\/runtime\//;
1040
+ const HAS_USE_QUESTION_RE = /\buseQuestion\s*\(/;
1041
+ const HAS_QUESTION_TAG_RE = new RegExp(
1042
+ `<(${Object.keys(QUESTION_COMPONENT_REQUIRED).join('|')})(?=[\\s/>])`
1043
+ );
1044
+
1045
+ /**
1046
+ * Detect ways an author file can bypass the LMS data contract. These check
1047
+ * source text for known escape hatches — they never inspect course content,
1048
+ * so they constrain how you wire things up, not what you build.
1049
+ */
1050
+ function validateContractBypass(
1051
+ content: string,
1052
+ fileRel: string,
1053
+ errors: string[]
1054
+ ): void {
1055
+ if (QUIZ_COMPLETE_DISPATCH_RE.test(content)) {
1056
+ errors.push(
1057
+ `${fileRel}: dispatches "tessera-quiz-complete" directly — submit through ` +
1058
+ `useQuiz().submit() so the result reaches the LMS`
1059
+ );
1060
+ }
1061
+ if (RUNTIME_INTERNAL_IMPORT_RE.test(content)) {
789
1062
  errors.push(
790
- `${fileRel}: quiz.graded must be a boolean, got ${typeof cfg.graded}`
1063
+ `${fileRel}: imports from tessera-learn/runtime/* use the public hooks ` +
1064
+ `(useQuiz, useQuestion, useNavigation, …) instead`
791
1065
  );
792
1066
  }
793
1067
  }
@@ -36,7 +36,7 @@ function missingApiError(
36
36
  standard,
37
37
  `Tessera: this course is configured for ${label} but ${detail} ` +
38
38
  `The course must be launched from an LMS that provides the ${label} runtime. ` +
39
- `If you are testing locally, run \`npm run preview\` instead, or set export.standard to "web".`
39
+ `If you are testing locally, run \`npm run dev\` instead, or set export.standard to "web".`
40
40
  );
41
41
  }
42
42
 
@@ -1,13 +1,12 @@
1
1
  /**
2
- * Optional callback that surfaces the LMS's last-error code/message after
3
- * a failed call, so warning logs can name the actual cause instead of a
4
- * generic "LMS call failed".
2
+ * Surfaces LMSGetLastError / LMSGetErrorString / LMSGetDiagnostic so failure
3
+ * logs can name the cause instead of a generic "LMS call failed". SCORM
4
+ * Cloud uses the diagnostic to name the offending data-model element.
5
5
  */
6
6
  export interface LMSErrorReporter {
7
- /** Last error from `LMSGetLastError` / `GetLastError`. */
8
7
  code(): string;
9
- /** Human-readable message from `LMSGetErrorString` / `GetErrorString`. */
10
8
  message(code: string): string;
9
+ diagnostic?(code: string): string;
11
10
  }
12
11
 
13
12
  /** Default attempt count for LMS retry loops (one initial + two retries). */
@@ -33,19 +32,53 @@ function logRetryGiveUp(
33
32
  lastErrCode: string,
34
33
  context: string | undefined
35
34
  ): void {
36
- let detail = '';
37
- if (errorReporter && lastErrCode && lastErrCode !== '0') {
38
- try {
39
- const msg = errorReporter.message(lastErrCode);
40
- detail = ` (LMS error ${lastErrCode}${msg ? `: ${msg}` : ''})`;
41
- } catch {}
42
- }
43
35
  const ctx = context ? ` [${context}]` : '';
44
36
  console.warn(
45
- `Tessera: LMS call failed after retries${ctx}${detail}, continuing without persistence`
37
+ `Tessera: LMS call failed after retries${ctx}${formatLMSErrorDetail(errorReporter, lastErrCode)}, continuing without persistence`
46
38
  );
47
39
  }
48
40
 
41
+ export function formatLMSErrorDetail(
42
+ errorReporter: LMSErrorReporter | undefined,
43
+ code: string
44
+ ): string {
45
+ if (!errorReporter || !code || code === '0') return '';
46
+ let msg = '';
47
+ let diag = '';
48
+ try { msg = errorReporter.message(code); } catch {}
49
+ try { diag = errorReporter.diagnostic?.(code) ?? ''; } catch {}
50
+ let detail = ` (LMS error ${code}`;
51
+ if (msg) detail += `: ${msg}`;
52
+ if (diag && diag !== msg) detail += ` — ${diag}`;
53
+ detail += ')';
54
+ return detail;
55
+ }
56
+
57
+ /** Sync call that warns with the LMS error code on failure (terminate-path). */
58
+ export function callSyncOrWarn(
59
+ fn: () => any,
60
+ context: string,
61
+ errorReporter?: LMSErrorReporter
62
+ ): boolean {
63
+ let ok = false;
64
+ try {
65
+ ok = lmsCallSucceeded(fn());
66
+ } catch (err) {
67
+ console.warn(
68
+ `Tessera: LMS call threw [${context}] during terminate`,
69
+ err
70
+ );
71
+ return false;
72
+ }
73
+ if (!ok) {
74
+ const code = readLastErrorCode(errorReporter);
75
+ console.warn(
76
+ `Tessera: LMS call failed [${context}] during terminate${formatLMSErrorDetail(errorReporter, code)}`
77
+ );
78
+ }
79
+ return ok;
80
+ }
81
+
49
82
  /**
50
83
  * Retry wrapper for LMS API calls.
51
84
  * Retries up to maxRetries times with exponential backoff.
@@ -68,17 +101,25 @@ export async function withRetry(
68
101
  context?: string
69
102
  ): Promise<boolean> {
70
103
  let lastErrCode = '';
104
+ let threw = false;
105
+ let lastError: unknown;
71
106
  for (let attempt = 0; attempt < maxRetries; attempt++) {
107
+ threw = false;
72
108
  try {
73
109
  if (lmsCallSucceeded(fn())) return true;
74
- } catch {
75
- // API call threw treat as failure
110
+ } catch (err) {
111
+ threw = true;
112
+ lastError = err;
76
113
  }
77
114
  lastErrCode = readLastErrorCode(errorReporter);
78
115
  if (attempt < maxRetries - 1) {
79
116
  await new Promise((r) => setTimeout(r, backoffMs(attempt)));
80
117
  }
81
118
  }
119
+ if (threw) {
120
+ const ctx = context ? ` [${context}]` : '';
121
+ console.warn(`Tessera: LMS call threw${ctx} on final retry`, lastError);
122
+ }
82
123
  logRetryGiveUp(errorReporter, lastErrCode, context);
83
124
  return false;
84
125
  }
@@ -275,6 +316,36 @@ export function formatHHMMSS(totalSeconds: number): string {
275
316
  return `${hh}:${mm}:${ss}.00`;
276
317
  }
277
318
 
319
+ /**
320
+ * SCORM 2004 4E §4.2/§4.3 define CMIDecimal-like elements as real(10,7) —
321
+ * `String(1/3)` exceeds that and trips SCORM Cloud with error 406. Rounds,
322
+ * then trims trailing zeros (no padded "0.8500000" forms).
323
+ */
324
+ export function formatReal107(value: number): string {
325
+ if (!Number.isFinite(value)) return '0';
326
+ const rounded = Math.round(value * 1e7) / 1e7;
327
+ return rounded
328
+ .toFixed(7)
329
+ .replace(/(\.\d*?)0+$/, '$1')
330
+ .replace(/\.$/, '');
331
+ }
332
+
333
+ /**
334
+ * SCORM 2004 4E §3.3.10.1 references ISO 8601 §5.3.3 — local date+time, no
335
+ * zone designator. Strict validators reject `Z`, `±hh:mm`, and fractional
336
+ * seconds with error 406. UTC components are used so writes don't drift
337
+ * across local-TZ flips even though the format is zone-free.
338
+ */
339
+ export function formatISO8601Timestamp(date: Date): string {
340
+ const yyyy = date.getUTCFullYear();
341
+ const mm = String(date.getUTCMonth() + 1).padStart(2, '0');
342
+ const dd = String(date.getUTCDate()).padStart(2, '0');
343
+ const hh = String(date.getUTCHours()).padStart(2, '0');
344
+ const mi = String(date.getUTCMinutes()).padStart(2, '0');
345
+ const ss = String(date.getUTCSeconds()).padStart(2, '0');
346
+ return `${yyyy}-${mm}-${dd}T${hh}:${mi}:${ss}`;
347
+ }
348
+
278
349
  /**
279
350
  * Format seconds as ISO 8601 duration: PT1H30M45S
280
351
  */