PageSourceSearch

https://korekara-support.web.app/src/lib/diagnostic-core.js

js korekara-support.web.app collected 2026-10-03 12:28:01 UTC 23,908 bytes, 562 lines download raw bytes

1/**
2 * diagnostic-core.js — 診断エンジン共通モジュール v2
3 *
4 * 2つのスコアリング形式をサポート:
5 *   1. 準備度モード: scoring = { excellent, good, fair, start }
6 *   2. タイプ診断モード: scoring = { resultBands: [ { minRate, label, icon, description, advice }, ... ] }
7 *
8 * アドバイス指定形式も2通りサポート:
9 *   - q.advices = { A: { text, task }, B: ... }  ← 従来形式
10 *   - choice.advice = { text, task }              ← 新形式(kasoushiki等)
11 */
12(function () {
13  'use strict';
14
15  /* =========================================
16     設問順序(ミニアプリ版・SPEC §3.2 変更①)
17     シャッフルを無効化: シニアの再現性・サポート性を優先し、
18     呼び出し箇所を変えずに恒等関数として振る舞う
19     ========================================= */
20  function shuffle(arr) {
21    return arr.slice();
22  }
23
24  /* =========================================
25     localStorage ヘルパー
26     ========================================= */
27  function loadState(key, version) {
28    try {
29      var raw = localStorage.getItem(key);
30      if (!raw) return null;
31      var state = JSON.parse(raw);
32      if (state.version !== version) { localStorage.removeItem(key); return null; }
33      return state;
34    } catch (e) { return null; }
35  }
36
37  function saveState(key, version, data) {
38    try {
39      data.version = version;
40      localStorage.setItem(key, JSON.stringify(data));
41    } catch (e) { /* noop */ }
42  }
43
44  function clearState(key) {
45    try { localStorage.removeItem(key); } catch (e) { /* noop */ }
46  }
47
48  /* =========================================
49     スコア計算
50     ========================================= */
51  function calcScore(answers, questions) {
52    var maxScore = 0;
53    var totalScore = 0;
54    answers.forEach(function (ans) {
55      var q = questions.find(function (qq) { return qq.id === ans.questionId; });
56      if (!q) return;
57      var maxC = 0;
58      q.choices.forEach(function (c) { if (c.score > maxC) maxC = c.score; });
59      maxScore += maxC;
60      totalScore += (ans.score || 0);
61    });
62    return maxScore > 0 ? Math.round((totalScore / maxScore) * 100) : 0;
63  }
64
65  /* 準備度モード: excellent/good/fair/start 形式 */
66  function getScoringMessage(rate, scoring) {
67    var levels = ['excellent', 'good', 'fair', 'start'];
68    for (var i = 0; i < levels.length; i++) {
69      var s = scoring[levels[i]];
70      if (s && rate >= s.minRate) return s.message;
71    }
72    return '';
73  }
74
75  /* タイプ診断モード: resultBands 形式 */
76  function getResultBand(rate, bands) {
77    var sorted = bands.slice().sort(function (a, b) { return b.minRate - a.minRate; });
78    for (var i = 0; i < sorted.length; i++) {
79      if (rate >= sorted[i].minRate) return sorted[i];
80    }
81    return sorted[sorted.length - 1] || null;
82  }
83
84  /* アドバイスオブジェクト取得(2形式対応) */
85  function getAdviceObj(q, choice) {
86    if (q.advices && q.advices[choice.label]) return q.advices[choice.label];
87    if (choice.advice) return choice.advice;
88    return null;
89  }
90
91  /* =========================================
92     メイン: createDiagnostic
93     ========================================= */
94  window.createDiagnostic = function (opts) {
95    var data                 = opts.data;
96    var mountEl              = opts.mountEl;
97    var showAdvice           = opts.showAdvicePerQuestion !== false;
98    var onComplete           = opts.onComplete || function () {};
99    var onEvent              = opts.onEvent || function () {}; /* ミニアプリ版(§3.2③): 計測フック */
100    var onExitToTop          = opts.onExitToTop || null;       /* S-02の1問目からS-01へ戻る導線(監査A2) */
101    var meta                 = data.meta;
102    var categoriesRaw        = data.categories || [];
103    var questions            = data.questions;
104    var scoring              = data.scoring;
105    var persistKey           = 'diag-' + meta.id;
106    var isTypeDiag           = !!(scoring && scoring.resultBands);
107    /* 属性(プロフィール)質問 — 指定があれば診断の冒頭に固定順で出題。
108       スコア対象外・アドバイス画面なし。回答は profile として onComplete に渡す */
109    var profileQuestions     = opts.profileQuestions || data.profileQuestions || [];
110
111    /* categories は配列でもオブジェクトでも対応 */
112    var categories = Array.isArray(categoriesRaw)
113      ? categoriesRaw
114      : Object.keys(categoriesRaw).map(function (k) {
115          return { id: k, label: categoriesRaw[k].label, icon: categoriesRaw[k].icon };
116        });
117
118    var currentIndex         = 0;
119    var orderedQuestions      = [];
120    var answers              = [];
121    var profileIndex         = 0;
122    var profile              = {};
123
124    /* ---------- HTML テンプレート ---------- */
125    mountEl.innerHTML =
126      '<div class="diag-screen is-active" id="diagStart">' +
127        '<div class="cl-card" style="text-align:center;padding:2rem 1.5rem">' +
128          '<h2 class="diag-start__title">' + meta.title + '</h2>' +
129          '<p class="diag-start__lead">' + (meta.subtitle || meta.description || '') +
130            (meta.estimatedMinutes ? '<span class="diag-start__time">⏱ 所要約' + meta.estimatedMinutes + '分</span>' : '') +
131          '</p>' +
132          '<div id="diagResumeBanner"></div>' +
133          '<button class="cl-btn cl-btn--primary cl-btn--large diag-start__btn" id="diagStartBtn">診断を開始する</button>' +
134        '</div>' +
135      '</div>' +
136
137      '<div class="diag-screen" id="diagQuestion">' +
138        '<div class="diag-progress" role="progressbar" aria-valuemin="0" aria-valuemax="100">' +
139          '<div class="diag-progress__fill" id="diagProgressFill"></div>' +
140        '</div>' +
141        '<div class="diag-back"><a href="#" id="diagBackLink">← 前の質問にもどる</a></div>' +
142        '<div class="diag-meta">' +
143          '<span class="diag-category" id="diagCategoryLabel"></span>' +
144          '<span class="diag-number" id="diagNumber"></span>' +
145        '</div>' +
146        '<div class="diag-question-card">' +
147          '<h2 id="diagQuestionText"></h2>' +
148          '<div class="diag-choices" id="diagChoices"></div>' +
149        '</div>' +
150      '</div>' +
151
152      '<div class="diag-screen" id="diagAdvice">' +
153        '<div class="diag-advice">' +
154          '<div class="diag-advice__header">' +
155            '<span class="diag-advice__icon">💡</span>' +
156            /* 「〜のポイント」は“次の質問”と誤解される(2026-08-15シニアUI検証・吉澤氏指示で変更)。
157               本ミニアプリはshowAdvicePerQuestion:falseのため非表示だが、P2aでじっくり20問を
158               移植するとこの画面が有効になるので、詳細版(shukatsu_sindan_app側)と文言を揃えておく */
159            '<h2>この設問へのアドバイス</h2>' +
160          '</div>' +
161          '<div id="diagAdviceBody"></div>' +
162          '<div class="text-center mt-2">' +
163            '<button class="cl-btn cl-btn--primary cl-btn--large" id="diagNextBtn">次の設問へ進む</button>' +
164          '</div>' +
165        '</div>' +
166      '</div>' +
167
168      '<div class="diag-screen" id="diagResult">' +
169        '<div class="diag-result" id="diagResultBody"></div>' +
170      '</div>';
171
172    /* ---------- DOM参照 ---------- */
173    var $start       = mountEl.querySelector('#diagStart');
174    var $question    = mountEl.querySelector('#diagQuestion');
175    var $advice      = mountEl.querySelector('#diagAdvice');
176    var $result      = mountEl.querySelector('#diagResult');
177    var $progressFill= mountEl.querySelector('#diagProgressFill');
178    var $catLabel    = mountEl.querySelector('#diagCategoryLabel');
179    var $number      = mountEl.querySelector('#diagNumber');
180    var $qText       = mountEl.querySelector('#diagQuestionText');
181    var $choices     = mountEl.querySelector('#diagChoices');
182    var $advBody     = mountEl.querySelector('#diagAdviceBody');
183    var $nextBtn     = mountEl.querySelector('#diagNextBtn');
184    var $startBtn    = mountEl.querySelector('#diagStartBtn');
185    var $resultBody  = mountEl.querySelector('#diagResultBody');
186    var $resumeArea  = mountEl.querySelector('#diagResumeBanner');
187    var $backLink    = mountEl.querySelector('#diagBackLink');
188
189    /* ミニアプリ版(§6.1 S-02): 「← 前の質問にもどる」。answers配列のpopで実装。
190       phaseは現在の画面種別('profile'|'question')で、戻り先の判定に使う */
191    var phase = '';
192    $backLink.addEventListener('click', function (e) {
193      e.preventDefault();
194      if (phase === 'question') {
195        if (currentIndex > 0) {
196          answers.pop();
197          currentIndex--;
198          persistProgress();
199          showQuestionScreen();
200        } else if (profileQuestions.length > 0) {
201          profileIndex = profileQuestions.length - 1;
202          showProfileScreen();
203        }
204      } else if (phase === 'profile') {
205        if (profileIndex > 0) {
206          profileIndex--;
207          showProfileScreen();
208        } else if (onExitToTop) {
209          onExitToTop(); /* 1問目からはトップへ戻れる(§6.6戻る手段常備ãƒ
209»2026-08-12監査A2) */
210        }
211      }
212    });
213
214    function showScreen(el) {
215      [$start, $question, $advice, $result].forEach(function (s) { s.classList.remove('is-active'); });
216      el.classList.add('is-active');
217      window.scrollTo({ top: 0, behavior: 'smooth' });
218    }
219
220    /* ---------- 再開チェック ---------- */
221    var saved = loadState(persistKey, meta.version);
222    if (saved && saved.answers && saved.answers.length > 0 && saved.answers.length < questions.length) {
223      $resumeArea.innerHTML =
224        '<div class="diag-resume-banner">' +
225          '<div class="diag-resume-banner__text">前回の続き(' + saved.answers.length + '/' + questions.length + '問回答済み)があります</div>' +
226          '<div class="diag-resume-banner__actions">' +
227            '<button class="cl-btn cl-btn--primary" id="diagResumeBtn">続きから</button>' +
228            '<button class="cl-btn cl-btn--outline" id="diagResetBtn">はじめからやり直す</button>' +
229          '</div>' +
230        '</div>';
231      mountEl.querySelector('#diagResumeBtn').addEventListener('click', function () {
232        orderedQuestions = saved.orderedIds.map(function (id) {
233          return questions.find(function (q) { return q.id === id; });
234        }).filter(Boolean);
235        answers = saved.answers;
236        profile = saved.profile || {};
237        currentIndex = answers.length;
238        showQuestionScreen();
239      });
240      mountEl.querySelector('#diagResetBtn').addEventListener('click', function () {
241        clearState(persistKey);
242        startFresh();
243      });
244    }
245
246    /* ---------- 開始 ---------- */
247    $startBtn.addEventListener('click', function () { startFresh(); });
248
249    function startFresh() {
250      currentIndex = 0;
251      answers = [];
252      profileIndex = 0;
253      profile = {};
254      orderedQuestions = shuffle(questions);
255      if (profileQuestions.length > 0) {
256        showProfileScreen();
257      } else {
258        showQuestionScreen();
259      }
260    }
261
262    /* ---------- 属性(プロフィール)質問表示 ---------- */
263    function showProfileScreen() {
264      phase = 'profile';
265      $backLink.style.visibility = (profileIndex > 0 || onExitToTop) ? 'visible' : 'hidden';
266      $backLink.textContent = (profileIndex === 0 && onExitToTop) ? '← トップにもどる' : '← 前の質問にもどる';
267      var pq = profileQuestions[profileIndex];
268      var totalSteps = profileQuestions.length + orderedQuestions.length;
269      var pct = ((profileIndex + 1) / totalSteps * 100);
270      $progressFill.style.width = pct + '%';
271      $progressFill.parentElement.setAttribute('aria-valuenow', Math.round(pct));
272      $number.textContent = 'あなたについて ' + (profileIndex + 1) + ' / ' + profileQuestions.length;
273      $catLabel.textContent = '👤 プロフィール';
274      $qText.textContent = pq.question;
275
276      var labels = ['A', 'B', 'C', 'D', 'E'];
277      $choices.innerHTML = '';
278      pq.choices.forEach(function (c, i) {
279        var btn = document.createElement('button');
280        btn.className = 'diag-choice-btn';
281        btn.innerHTML = '<span class="diag-choice-label">' + labels[i] + '</span><span class="diag-choice-text">' + c.text + '</span>';
282        btn.addEventListener('click', function () {
283          profile[pq.key] = { value: c.value, text: c.text };
284          onEvent('profile_answered', { n: profileIndex + 1 }); /* §6.4: n=1〜4 */
285          profileIndex++;
286          if (profileIndex >= profileQuestions.length) {
287            showQuestionScreen();
288          } else {
289            showProfileScreen();
290          }
291        });
292        $choices.appendChild(btn);
293      });
294
295      showScreen($question);
296    }
297
298    /* ---------- 質問表示 ---------- */
299    function showQuestionScreen() {
300      phase = 'question';
301      $backLink.style.visibility = (currentIndex > 0 || profileQuestions.length > 0) ? 'visible' : 'hidden';
302      $backLink.textContent = '← 前の質問にもどる';
303      var q = orderedQuestions[currentIndex];
304      var total = orderedQuestions.length;
305      var totalSteps = profileQuestions.length + total;
306      var pct = ((profileQuestions.length + currentIndex + 1) / totalSteps * 100);
307      $progressFill.style.width = pct + '%';
308      $progressFill.parentElement.setAttribute('aria-valuenow', Math.round(pct));
309      $number.textContent = '第' + (currentIndex + 1) + '問 / ' + total;
310
311      var cat = categories.find(function (c) { return c.id === q.category; });
312      $catLabel.textContent = cat ? (cat.icon ? cat.icon + ' ' + cat.label : cat.label) : q.category;
313      $qText.textContent = q.text || q.question;
314
315      $choices.innerHTML = '';
316      q.choices.forEach(function (c) {
317        var btn = document.createElement('button');
318        btn.className = 'diag-choice-btn';
319        btn.innerHTML = '<span class="diag-choice-label">' + c.label + '</span><span class="diag-choice-text">' + c.text + '</span>';
320        btn.addEventListener('click', function () { selectChoice(q, c); });
321        $choices.appendChild(btn);
322      });
323
324      showScreen($question);
325    }
326
327    /* ---------- 選択 ---------- */
328    function selectChoice(q, choice) {
329      var adviceObj = getAdviceObj(q, choice);
330      answers.push({
331        questionId: q.id,
332        category: q.category,
333        question: q.text || q.question,
334        choiceLabel: choice.label,
335        choiceText: choice.text,
336        score: choice.score || 0,
337        adviceText: adviceObj ? adviceObj.text : '',
338        task: adviceObj ? adviceObj.task : null
339      });
340
341      persistProgress();
342      onEvent('question_answered', { n: answers.length, qid: q.id }); /* §6.4: n=1〜問数(かんたん8・じっくり20・ADR-0033 §4-4) */
343
344      if (showAdvice && adviceObj) {
345        showAdviceScreen(adviceObj);
346      } else {
347        goNext();
348      }
349    }
350
351    /* ---------- アドバイス ---------- */
352    function showAdviceScreen(advice) {
353      var html = '<div class="diag-advice__text"><p>' + advice.text + '</p></div>';
354      if (advice.task) {
355        html += '<div class="diag-task">' +
356          '<h3><span class="diag-task__icon">✅</span> 今日からできること</h3>' +
357          '<p>' + advice.task + '</p>' +
358        '</div>';
359      }
360      $advBody.innerHTML = html;
361      var isLast = currentIndex >= orderedQuestions.length - 1;
362      $nextBtn.textContent = isLast ? '総合結果へ進む' : '次の設問へ進む';
363      $nextBtn.onclick = function () { goNext(); };
364      showScreen($advice);
365    }
366
367    /* ---------- 次へ / 結果 ---------- */
368    function goNext() {
369      currentIndex++;
370      if (currentIndex >= orderedQuestions.length) {
371        showResultScreen();
372      } else {
373        showQuestionScreen();
374      }
375    }
376
377    /* ---------- 進捗保存 ---------- */
378    function persistProgress() {
379      saveState(persistKey, meta.version, {
380        orderedIds: orderedQuestions.map(function (q) { return q.id; }),
381        answers: answers,
382        profile: profile
383      });
384    }
385
386    /* ---------- 結果表示 ---------- */
387    function showResultScreen() {
388      clearState(persistKey);
389
390      var rate = calcScore(answers, questions);
391      var tasksToComplete = answers.filter(function (a) { return a.task; });
392
393      var html = '';
394
395      /* ===== タイプ診断モード (resultBands) ===== */
396      if (isTypeDiag) {
397        var band = getResultBand(rate, scoring.resultBands);
398
399        html += '<div class="diag-result__header">' +
400          '<h2 class="diag-result__title">診断結果</h2>' +
401          '<p class="diag-result__thanks">全ての設問にお答えいただき、ありがとうございました。</p>' +
402        '</div>';
403
404        if (band) {
405          html += '<div class="diag-result-type">' +
406            '<div class="diag-result-type__icon" aria-hidden="true">' + (band.icon || '') + '</div>' +
407            '<div class="diag-result-type__label">' + band.label + '</div>' +
408          '</div>';
409
410          if (band.description) {
411            html += '<div class="diag-result-type__desc">' +
412              '<p>' + band.description + '</p>' +
413            '</div>';
414          }
415
416          if (band.advice) {
417            html += '<div class="diag-result-type__advice">' +
418              '<h3><span>📌</span> 次のステップ・アドバイス</h3>' +
419              '<p>' + band.advice + '</p>' +
420            '</div>';
421          }
422        }
423
424        /* カテゴリ別傾向サマリー */
425        var catSummary = buildCategorySummary(answers, categories);
426        if (catSummary) {
427          html += catSummary;
428        }
429
430      /* ===== 準備度モード (excellent/good/fair/start) ===== */
431      } else {
432        var message = getScoringMessage(rate, scoring);
433
434        html += '<div class="diag-result__header">' +
435          '<h2 class="diag-result__title">診断結果</h2>' +
436          '<p class="diag-result__thanks">全ての設問にお答えいただき、ありがとうございました。</p>' +
437        '</div>';
438
439        /* 改善9: ラベルはリングの外に置く。内円88pxに「準備の進み具合」は
440           12pxでも幅84px必要で収まらないため(実測)。外に出して本文と同じ16pxにする */
441        html += '<div class="diag-score">' +
442          '<div class="diag-score__ring-wrap">' +
443            '<div class="diag-score__ring" id="diagScoreRing">' +
444              '<div class="diag-score__ring-inner">' +
445                '<div class="diag-score__number">' + rate + '<span class="diag-score__pct">%</span></div>' +
446              '</div>' +
447            '</div>' +
448          '</div>' +
449          '<div class="diag-score__label">準備の進み具合</div>' +
450          '<div class="diag-score__message">' + message + '</div>' +
451        '</div>';
452      }
453
454      /* ===== 課題一覧(共通) ===== */
455      if (tasksToComplete.length > 0) {
456        html += '<div class="diag-task-list">' +
457          '<h3>✅ 今日からできること</h3>' +
458          '<ul>';
459        tasksToComplete.forEach(function (item) {
460          html += '<li>' +
461            '<span class="diag-task-list__q">' + item.question + '</span>' +
462            '<span class="diag-task-list__t">' + item.task + '</span>' +
463          '</li>';
464        });
465        html += '</ul></div>';
466      }
467
468      /* ===== 回答の詳細(共通) ===== */
469      html += '<div class="diag-answer-history">' +
470        '<h3>回答の詳細</h3>' +
471        '<p class="diag-answer-history__note">
471各設問の回答内容とアドバイスをご確認いただけます。</p>';
472      answers.forEach(function (ans, i) {
473        var cat = categories.find(function (c) { return c.id === ans.category; });
474        var catLabel = cat ? (cat.icon ? cat.icon + '\u00a0' + cat.label : cat.label) : ans.category;
475        html += '<div class="diag-answer-item">' +
476          '<span class="diag-answer-item__cat">' + catLabel + '</span>' +
477          '<p class="diag-answer-item__q">Q' + (i + 1) + '. ' + ans.question + '</p>' +
478          '<p class="diag-answer-item__sel">→ ' + ans.choiceLabel + '. ' + ans.choiceText + '</p>' +
479          (ans.adviceText ? '<p class="diag-answer-item__adv">' + ans.adviceText + '</p>' : '') +
480          (ans.task ? '<p class="diag-answer-item__task"><strong>課題:</strong> ' + ans.task + '</p>' : '') +
481        '</div>';
482      });
483      html += '</div>';
484
485      html += '<div class="text-center mt-3" id="diagResultActions"></div>';
486
487      $resultBody.innerHTML = html;
488
489      /* 達成率リングのグラデーション設定 */
490      if (!isTypeDiag) {
491        var ring = $resultBody.querySelector('#diagScoreRing');
492        if (ring) {
493          ring.style.setProperty('--ring-pct', rate + '%');
494        }
495      }
496
497      showScreen($result);
498
499      onComplete({
500        rate: rate,
501        answers: answers,
502        profile: profile,
503        resultContainer: $resultBody
504      });
505    }
506
507    /* ---------- カテゴリ別傾向サマリー生成 ---------- */
508    function buildCategorySummary(answerList, cats) {
509      if (!cats || cats.length === 0) return '';
510
511      var catData = {};
512      cats.forEach(function (c) {
513        catData[c.id] = { label: c.label, icon: c.icon || '', scores: [], total: 0, count: 0 };
514      });
515
516      answerList.forEach(function (a) {
517        if (catData[a.category]) {
518          catData[a.category].total += a.score;
519          catData[a.category].count++;
520        }
521      });
522
523      var rows = '';
524      cats.forEach(function (c) {
525        var d = catData[c.id];
526        if (!d || d.count === 0) return;
527        var avg = d.total / d.count;
528
529        /* カテゴリの最大スコアを取得 */
530        var catQs = answerList.filter(function (a) { return a.category === c.id; });
531        var catMaxPossible = 0;
532        catQs.forEach(function (a) {
533          var q = questions.find(function (qq) { return qq.id === a.questionId; });
534          if (!q) return;
535          var mx = 0;
536          q.choices.forEach(function (ch) { if (ch.score > mx) mx = ch.score; });
537          catMaxPossible += mx;
538        });
539        var catTotal = catQs.reduce(function (s, a) { return s + a.score; }, 0);
540        var catPct = catMaxPossible > 0 ? Math.round(catTotal / catMaxPossible * 100) : 0;
541
542        var barColor = catPct >= 76 ? '#e65100' : catPct >= 51 ? '#2d7a2d' : catPct >= 26 ? '#1565c0' : '#5a6670';
543
544        rows += '<div class="diag-cat-row">' +
545          '<span class="diag-cat-row__label">' + (c.icon ? c.icon + '\u00a0' : '') + c.label + '</span>' +
546          '<div class="diag-cat-row__bar-wrap">' +
547            '<div class="diag-cat-row__bar" style="width:' + catPct + '%;background:' + barColor + '"></div>' +
548          '</div>' +
549          '<span class="diag-cat-row__pct">' + catPct + '%</span>' +
550        '</div>';
551      });
552
553      if (!rows) return '';
554
555      return '<div class="diag-category-summary">' +
556        '<h3>カテゴリ別の傾向</h3>' +
557        '<p class="diag-category-summary__note">スコアが高いほど「シンプルなお見送り」の傾向を示します。</p>' +
558        rows +
559      '</div>';
560    }
561  };
562})();

Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.