From 44d89fa047038f28e5f27962b8f4fa2c0406d717 Mon Sep 17 00:00:00 2001 From: katakata0522 Date: Thu, 6 Aug 2026 23:00:21 +0900 Subject: [PATCH] =?UTF-8?q?main.js=E3=81=AE=E8=B2=AC=E5=8B=99=E3=82=92?= =?UTF-8?q?=E5=B0=82=E7=94=A8=E3=83=A2=E3=82=B8=E3=83=A5=E3=83=BC=E3=83=AB?= =?UTF-8?q?=E3=81=B8=E5=88=86=E9=9B=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/scripts/minify.cjs | 6 + en/index.html | 2 +- index.html | 2 +- js/calendar-reminder.js | 58 ++++ js/language-suggestion.js | 88 +++++++ js/main.js | 307 ++-------------------- js/pwa-install.js | 76 ++++++ js/region-navigation.js | 76 ++++++ js/service-worker-registration.js | 27 ++ js/widget-referral.js | 18 ++ ko/index.html | 2 +- scripts/asset-sync.cjs | 29 +- sw.js | 10 +- tests/growth-migration.test.cjs | 4 +- tests/main-responsibility-split.test.cjs | 59 +++++ tests/playpoint-regression.test.cjs | 26 +- tests/static-calculator-delivery.test.cjs | 7 +- tw/index.html | 2 +- 18 files changed, 487 insertions(+), 312 deletions(-) create mode 100644 js/calendar-reminder.js create mode 100644 js/language-suggestion.js create mode 100644 js/pwa-install.js create mode 100644 js/region-navigation.js create mode 100644 js/service-worker-registration.js create mode 100644 js/widget-referral.js create mode 100644 tests/main-responsibility-split.test.cjs diff --git a/.github/scripts/minify.cjs b/.github/scripts/minify.cjs index ca1b23dc..15ed4f8f 100644 --- a/.github/scripts/minify.cjs +++ b/.github/scripts/minify.cjs @@ -41,6 +41,12 @@ const cssTargets = [ const jsTargets = [ 'sw.js', 'js/main.js', + 'js/region-navigation.js', + 'js/language-suggestion.js', + 'js/calendar-reminder.js', + 'js/pwa-install.js', + 'js/widget-referral.js', + 'js/service-worker-registration.js', 'js/main-calculator-ui.js', 'js/calculator.js', 'js/ui.js', diff --git a/en/index.html b/en/index.html index c90be3ed..4a0576e6 100644 --- a/en/index.html +++ b/en/index.html @@ -378,7 +378,7 @@

Q. Is my weekly reward diary data saved?

- + diff --git a/index.html b/index.html index 32cf8bfb..95b8763c 100644 --- a/index.html +++ b/index.html @@ -460,7 +460,7 @@

Q. ほくほくリワード日記のデータは保存されますか?

- + diff --git a/js/calendar-reminder.js b/js/calendar-reminder.js new file mode 100644 index 00000000..7b7b4756 --- /dev/null +++ b/js/calendar-reminder.js @@ -0,0 +1,58 @@ +'use strict'; + +import { CONFIGS, STATE, ANALYTICS, getNextFridayCalendarWindow } from './config.js'; + +export function bindCalendarReminderEvents() { + if (STATE.dom.downloadIcalBtn) { + STATE.dom.downloadIcalBtn.addEventListener('click', () => downloadICS()); + } + if (STATE.dom.registerGoogleCalBtn) { + STATE.dom.registerGoogleCalBtn.addEventListener('click', () => { + ANALYTICS.track('calendar_reminder_added', { + region: STATE.currentRegion, + calendar_type: 'google' + }); + }); + } +} + +// ICSファイルのダウンロードロジック +export function downloadICS() { + const config = CONFIGS[STATE.currentRegion]; + const texts = config.uiText; + const summary = texts.calSubject; + const description = texts.calDetails.replace(/\n/g, '\\n'); + + const calendarWindow = getNextFridayCalendarWindow(STATE.currentRegion === 'US'); + + const icsLines = [ + 'BEGIN:VCALENDAR', + 'VERSION:2.0', + 'PRODID:-//PlayPoint//NONSGML Calendar//EN', + 'BEGIN:VEVENT', + `SUMMARY:${summary}`, + `DESCRIPTION:${description}`, + `DTSTART:${calendarWindow.start}`, + `DTEND:${calendarWindow.end}`, + 'RRULE:FREQ=WEEKLY;BYDAY=FR', + 'SEQUENCE:0', + 'STATUS:CONFIRMED', + 'TRANSP:TRANSPARENT', + 'END:VEVENT', + 'END:VCALENDAR' + ]; + const icsString = icsLines.join('\r\n'); + const blob = new Blob([icsString], { type: 'text/calendar;charset=utf-8' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = texts.icsFilename; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + URL.revokeObjectURL(url); + ANALYTICS.track('calendar_reminder_added', { + region: STATE.currentRegion, + calendar_type: 'ical' + }); +} diff --git a/js/language-suggestion.js b/js/language-suggestion.js new file mode 100644 index 00000000..9d6be458 --- /dev/null +++ b/js/language-suggestion.js @@ -0,0 +1,88 @@ +'use strict'; + +import { STATE, CONSTANTS } from './config.js'; +import { + isEnglishPath, + isKoreanPath, + isTaiwanPath, + switchRegion +} from './region-navigation.js'; + +export function bindLanguageSuggestionDismiss() { + if (!STATE.dom.closeLangBannerBtn) return; + + STATE.dom.closeLangBannerBtn.addEventListener('click', () => { + if (STATE.dom.languageSuggestionBanner) { + STATE.dom.languageSuggestionBanner.classList.add(CONSTANTS.CLASS_HIDDEN); + } + try { + sessionStorage.setItem('playpointLangBannerClosed', 'true'); + } catch (e) { + console.error("セッションストレージの書き込みに失敗しました:", e); + } + }); +} + +// 言語提案バナーの表示ロジック +export function checkLanguageSuggestion() { + if (!STATE.dom.languageSuggestionBanner) return; + + let isClosed = false; + try { + isClosed = sessionStorage.getItem('playpointLangBannerClosed') === 'true'; + } catch (e) { + console.error("セッションストレージの読み込みに失敗しました:", e); + } + if (isClosed) return; + + let preferredRegion = null; + try { + preferredRegion = localStorage.getItem(CONSTANTS.STORAGE_REGION_KEY); + } catch (e) { + console.error("ローカルストレージの読み込みに失敗しました:", e); + } + + if (preferredRegion) return; + + const browserLang = (navigator.language || navigator.userLanguage || '').toLowerCase(); + + let targetRegion = null; + let messageText = ''; + let buttonText = ''; + let isCurrentMatch = false; + + if (browserLang.startsWith('ko')) { + targetRegion = 'KR'; + messageText = '한국어 버전이 있습니다!'; + buttonText = '한국어로 전환'; + isCurrentMatch = isKoreanPath(); + } else if (browserLang.startsWith('zh-tw') || browserLang.startsWith('zh-hk')) { + targetRegion = 'TW'; + messageText = '提供繁體中文版本!'; + buttonText = '切換至繁體中文'; + isCurrentMatch = isTaiwanPath(); + } else if (browserLang.startsWith('en')) { + targetRegion = 'US'; + messageText = 'English version is available!'; + buttonText = 'Switch to English'; + isCurrentMatch = isEnglishPath(); + } + + if (targetRegion && !isCurrentMatch) { + const spanEl = STATE.dom.languageSuggestionBanner.querySelector('span'); + const btnEl = STATE.dom.switchToEnBtn; + if (spanEl && btnEl) { + spanEl.textContent = messageText; + btnEl.textContent = buttonText; + + const newBtn = btnEl.cloneNode(true); + btnEl.parentNode.replaceChild(newBtn, btnEl); + STATE.dom.switchToEnBtn = newBtn; + + newBtn.addEventListener('click', () => { + switchRegion(targetRegion); + }); + } + STATE.dom.languageSuggestionBanner.classList.remove(CONSTANTS.CLASS_HIDDEN); + } +} diff --git a/js/main.js b/js/main.js index a72b67b1..80b4060d 100644 --- a/js/main.js +++ b/js/main.js @@ -1,21 +1,30 @@ 'use strict'; -import { CONFIGS, STATE, CONSTANTS, ANALYTICS, getNextFridayCalendarWindow } from './config.js'; +import { CONFIGS, STATE, CONSTANTS, ANALYTICS } from './config.js'; import { UI } from './ui.js'; import { DIARY } from './diary.js'; import { SHARE } from './share.js'; import { CALC } from './calculator.js'; import { simplifyMainCalculatorLayout, updateSimplifiedCalculatorCopy } from './main-calculator-ui.js?v=fe1ecf8545'; import { initWebVitalsMonitoring } from './web-vitals.js'; - -let deferredInstallPrompt = null; +import { + applyRegionFromPath, + isEnglishPath, + isKoreanPath, + isTaiwanPath, + switchRegion as navigateToRegion +} from './region-navigation.js'; +import { bindLanguageSuggestionDismiss, checkLanguageSuggestion } from './language-suggestion.js'; +import { bindCalendarReminderEvents, downloadICS } from './calendar-reminder.js'; +import { initPwaInstallPrompt } from './pwa-install.js'; +import { trackWidgetReferral } from './widget-referral.js'; +import { registerServiceWorker } from './service-worker-registration.js'; // 早い段階から観測し、送信は既存の同意管理と匿名区分に限定する。 initWebVitalsMonitoring(); +initPwaInstallPrompt(); -export const isEnglishPath = () => /\/en(\/|$)/.test(window.location.pathname); -export const isKoreanPath = () => /\/ko(\/|$)/.test(window.location.pathname); -export const isTaiwanPath = () => /\/tw(\/|$)/.test(window.location.pathname); +export { isEnglishPath, isKoreanPath, isTaiwanPath }; function bindEvent(element, eventName, listener, options) { if (element) element.addEventListener(eventName, listener, options); @@ -57,14 +66,6 @@ function trackResultLinkClicks(event) { } } -function runWhenIdle(callback, timeout = 2000) { - if ('requestIdleCallback' in window) { - window.requestIdleCallback(callback, { timeout }); - return; - } - window.setTimeout(callback, Math.min(timeout, 1200)); -} - // 言語テキスト更新後に、記事一覧の実件数をテンプレートへ反映する function updateArticleCount() { const countEl = document.querySelector('.article-count'); @@ -89,40 +90,7 @@ export function updateUIForRegion() { } export function switchRegion(newRegion) { - if (!CONFIGS[newRegion] || STATE.currentRegion === newRegion) return; - STATE.currentRegion = newRegion; - try { - localStorage.setItem(CONSTANTS.STORAGE_REGION_KEY, newRegion); - } catch (e) { - console.error("地域設定の保存に失敗しました:", e); - UI.showToast("地域設定の保存に失敗しました。", 'error'); - } - - // URLのディレクトリ構成に基づいて静的ページ間を相互遷移させる - const isEn = isEnglishPath(); - const isKo = isKoreanPath(); - const isTw = isTaiwanPath(); - const prefix = (isEn || isKo || isTw) ? '../' : './'; - - let nextUrl = ''; - if (newRegion === 'JP') { - nextUrl = (isEn || isKo || isTw) ? '../' : './'; - } else if (newRegion === 'US') { - nextUrl = prefix + 'en/'; - } else if (newRegion === 'KR') { - nextUrl = prefix + 'ko/'; - } else if (newRegion === 'TW') { - nextUrl = prefix + 'tw/'; - } - - if (nextUrl) { - window.location.href = nextUrl; - } else { - document.querySelectorAll(".region-switch button").forEach(button => { - button.classList.toggle(CONSTANTS.CLASS_ACTIVE, button.dataset.region === newRegion); - }); - updateUIForRegion(); - } + return navigateToRegion(newRegion, updateUIForRegion); } // DOM要素のバインドとイベントリスナーの登録(初期化処理) @@ -161,25 +129,8 @@ export function init() { bindEvent(STATE.dom.exportDiaryBtn, 'click', () => DIARY.exportDiary()); bindEvent(STATE.dom.importDiaryBtn, 'click', () => DIARY.toggleImportArea()); bindEvent(STATE.dom.confirmImportBtn, 'click', () => DIARY.executeImport()); - if (STATE.dom.closeLangBannerBtn) STATE.dom.closeLangBannerBtn.addEventListener('click', () => { - if (STATE.dom.languageSuggestionBanner) { - STATE.dom.languageSuggestionBanner.classList.add(CONSTANTS.CLASS_HIDDEN); - } - try { - sessionStorage.setItem('playpointLangBannerClosed', 'true'); - } catch (e) { - console.error("セッションストレージの書き込みに失敗しました:", e); - } - }); - if (STATE.dom.downloadIcalBtn) STATE.dom.downloadIcalBtn.addEventListener('click', () => downloadICS()); - if (STATE.dom.registerGoogleCalBtn) { - STATE.dom.registerGoogleCalBtn.addEventListener('click', () => { - ANALYTICS.track('calendar_reminder_added', { - region: STATE.currentRegion, - calendar_type: 'google' - }); - }); - } + bindLanguageSuggestionDismiss(); + bindCalendarReminderEvents(); // Enterキー押下での計算実行 bindEnterAction([STATE.dom.neededPoints, STATE.dom.baseRate, STATE.dom.multiplier], () => CALC.calculate()); @@ -218,23 +169,7 @@ export function init() { if (STATE.dom.copyrightYear) STATE.dom.copyrightYear.textContent = new Date().getFullYear(); - try { - if (isEnglishPath()) { - STATE.currentRegion = 'US'; - localStorage.setItem(CONSTANTS.STORAGE_REGION_KEY, 'US'); - } else if (isKoreanPath()) { - STATE.currentRegion = 'KR'; - localStorage.setItem(CONSTANTS.STORAGE_REGION_KEY, 'KR'); - } else if (isTaiwanPath()) { - STATE.currentRegion = 'TW'; - localStorage.setItem(CONSTANTS.STORAGE_REGION_KEY, 'TW'); - } else { - // URLと表示言語を一致させるため、ルートは常に日本語として扱う。 - STATE.currentRegion = 'JP'; - } - } catch (e) { - console.error("地域設定の読み込みに失敗しました:", e); - } + applyRegionFromPath(); document.querySelectorAll(".region-switch button").forEach(button => { button.classList.toggle(CONSTANTS.CLASS_ACTIVE, button.dataset.region === STATE.currentRegion); @@ -247,209 +182,7 @@ export function init() { checkLanguageSuggestion(); trackWidgetReferral(); - // PWAサービスワーカーの登録 - if ('serviceWorker' in navigator) { - window.addEventListener('load', () => { - runWhenIdle(() => { - const swPath = (isEnglishPath() || isKoreanPath() || isTaiwanPath()) ? '../sw.js' : './sw.js'; - navigator.serviceWorker.register(swPath, { updateViaCache: 'none' }) - .then((reg) => { - console.log('ServiceWorker registered successfully:', reg.scope); - void reg.update().catch(err => console.warn('ServiceWorker update check failed:', err)); - }) - .catch(err => console.error('ServiceWorker registration failed:', err)); - }); - }); - } -} - -function getInstallCopy() { - const copy = { - JP: { - title: '次回もすぐ日記を開けます', - body: 'この端末のホーム画面に追加すると、金曜の記録をすぐ始められます。', - button: 'ホーム画面に追加' - }, - US: { - title: 'Open your diary faster next time', - body: 'Add this tool to your device for quicker weekly entries.', - button: 'Install app' - }, - KR: { - title: '다음 일지를 더 빠르게 열 수 있어요', - body: '이 도구를 기기에 추가하면 매주 기록을 빠르게 시작할 수 있습니다.', - button: '앱 설치' - }, - TW: { - title: '下次更快開啟日記', - body: '將此工具加到裝置,即可更快開始每週記錄。', - button: '安裝應用程式' - } - }; - return copy[STATE.currentRegion] || copy.JP; -} - -function showInstallPromptAfterDiarySave() { - if (!deferredInstallPrompt || !STATE.dom.diaryMode) return; - if (document.getElementById('pwa-install-card')) return; - - const copy = getInstallCopy(); - const card = document.createElement('aside'); - card.id = 'pwa-install-card'; - card.className = 'pwa-install-card'; - - const text = document.createElement('div'); - const title = document.createElement('strong'); - const body = document.createElement('p'); - const button = document.createElement('button'); - title.textContent = copy.title; - body.textContent = copy.body; - button.type = 'button'; - button.textContent = copy.button; - text.append(title, body); - card.append(text, button); - STATE.dom.diaryMode.appendChild(card); - - button.addEventListener('click', async () => { - const promptEvent = deferredInstallPrompt; - deferredInstallPrompt = null; - card.remove(); - await promptEvent.prompt(); - const choice = await promptEvent.userChoice; - if (choice.outcome === 'accepted') { - ANALYTICS.track('pwa_install_accepted', { - region: STATE.currentRegion, - install_surface: 'after_diary_save' - }); - } - }, { once: true }); -} - -function trackWidgetReferral() { - const params = new URLSearchParams(window.location.search); - if (params.get('entry') !== 'widget') return; - try { - if (sessionStorage.getItem('playpoint:widget-referral-tracked') === 'true') return; - sessionStorage.setItem('playpoint:widget-referral-tracked', 'true'); - } catch (error) { - console.warn('ウィジェット流入の重複防止設定を保存できませんでした。', error); - } - ANALYTICS.track('widget_referral_landed', { - region: STATE.currentRegion, - entry_surface: 'embedded_widget' - }); -} - -window.addEventListener('beforeinstallprompt', (event) => { - event.preventDefault(); - deferredInstallPrompt = event; -}); - -document.addEventListener('playpoint:diary-saved', showInstallPromptAfterDiarySave); - -// 言語提案バナーの表示ロジック -export function checkLanguageSuggestion() { - if (!STATE.dom.languageSuggestionBanner) return; - - let isClosed = false; - try { - isClosed = sessionStorage.getItem('playpointLangBannerClosed') === 'true'; - } catch (e) { - console.error("セッションストレージの読み込みに失敗しました:", e); - } - if (isClosed) return; - - let preferredRegion = null; - try { - preferredRegion = localStorage.getItem(CONSTANTS.STORAGE_REGION_KEY); - } catch (e) { - console.error("ローカルストレージの読み込みに失敗しました:", e); - } - - if (preferredRegion) return; - - const browserLang = (navigator.language || navigator.userLanguage || '').toLowerCase(); - - let targetRegion = null; - let messageText = ''; - let buttonText = ''; - let isCurrentMatch = false; - - if (browserLang.startsWith('ko')) { - targetRegion = 'KR'; - messageText = '한국어 버전이 있습니다!'; - buttonText = '한국어로 전환'; - isCurrentMatch = isKoreanPath(); - } else if (browserLang.startsWith('zh-tw') || browserLang.startsWith('zh-hk')) { - targetRegion = 'TW'; - messageText = '提供繁體中文版本!'; - buttonText = '切換至繁體中文'; - isCurrentMatch = isTaiwanPath(); - } else if (browserLang.startsWith('en')) { - targetRegion = 'US'; - messageText = 'English version is available!'; - buttonText = 'Switch to English'; - isCurrentMatch = isEnglishPath(); - } - - if (targetRegion && !isCurrentMatch) { - const spanEl = STATE.dom.languageSuggestionBanner.querySelector('span'); - const btnEl = STATE.dom.switchToEnBtn; - if (spanEl && btnEl) { - spanEl.textContent = messageText; - btnEl.textContent = buttonText; - - const newBtn = btnEl.cloneNode(true); - btnEl.parentNode.replaceChild(newBtn, btnEl); - STATE.dom.switchToEnBtn = newBtn; - - newBtn.addEventListener('click', () => { - switchRegion(targetRegion); - }); - } - STATE.dom.languageSuggestionBanner.classList.remove(CONSTANTS.CLASS_HIDDEN); - } -} - -// ICSファイルのダウンロードロジック -export function downloadICS() { - const config = CONFIGS[STATE.currentRegion]; - const texts = config.uiText; - const summary = texts.calSubject; - const description = texts.calDetails.replace(/\n/g, '\\n'); - - const calendarWindow = getNextFridayCalendarWindow(STATE.currentRegion === 'US'); - - const icsLines = [ - 'BEGIN:VCALENDAR', - 'VERSION:2.0', - 'PRODID:-//PlayPoint//NONSGML Calendar//EN', - 'BEGIN:VEVENT', - `SUMMARY:${summary}`, - `DESCRIPTION:${description}`, - `DTSTART:${calendarWindow.start}`, - `DTEND:${calendarWindow.end}`, - 'RRULE:FREQ=WEEKLY;BYDAY=FR', - 'SEQUENCE:0', - 'STATUS:CONFIRMED', - 'TRANSP:TRANSPARENT', - 'END:VEVENT', - 'END:VCALENDAR' - ]; - const icsString = icsLines.join('\r\n'); - const blob = new Blob([icsString], { type: 'text/calendar;charset=utf-8' }); - const url = URL.createObjectURL(blob); - const a = document.createElement('a'); - a.href = url; - a.download = texts.icsFilename; - document.body.appendChild(a); - a.click(); - document.body.removeChild(a); - URL.revokeObjectURL(url); - ANALYTICS.track('calendar_reminder_added', { - region: STATE.currentRegion, - calendar_type: 'ical' - }); + registerServiceWorker(); } // 初期ロード完了時の発火 diff --git a/js/pwa-install.js b/js/pwa-install.js new file mode 100644 index 00000000..31daa666 --- /dev/null +++ b/js/pwa-install.js @@ -0,0 +1,76 @@ +'use strict'; + +import { STATE, ANALYTICS } from './config.js'; + +let deferredInstallPrompt = null; + +function getInstallCopy() { + const copy = { + JP: { + title: '次回もすぐ日記を開けます', + body: 'この端末のホーム画面に追加すると、金曜の記録をすぐ始められます。', + button: 'ホーム画面に追加' + }, + US: { + title: 'Open your diary faster next time', + body: 'Add this tool to your device for quicker weekly entries.', + button: 'Install app' + }, + KR: { + title: '다음 일지를 더 빠르게 열 수 있어요', + body: '이 도구를 기기에 추가하면 매주 기록을 빠르게 시작할 수 있습니다.', + button: '앱 설치' + }, + TW: { + title: '下次更快開啟日記', + body: '將此工具加到裝置,即可更快開始每週記錄。', + button: '安裝應用程式' + } + }; + return copy[STATE.currentRegion] || copy.JP; +} + +function showInstallPromptAfterDiarySave() { + if (!deferredInstallPrompt || !STATE.dom.diaryMode) return; + if (document.getElementById('pwa-install-card')) return; + + const copy = getInstallCopy(); + const card = document.createElement('aside'); + card.id = 'pwa-install-card'; + card.className = 'pwa-install-card'; + + const text = document.createElement('div'); + const title = document.createElement('strong'); + const body = document.createElement('p'); + const button = document.createElement('button'); + title.textContent = copy.title; + body.textContent = copy.body; + button.type = 'button'; + button.textContent = copy.button; + text.append(title, body); + card.append(text, button); + STATE.dom.diaryMode.appendChild(card); + + button.addEventListener('click', async () => { + const promptEvent = deferredInstallPrompt; + deferredInstallPrompt = null; + card.remove(); + await promptEvent.prompt(); + const choice = await promptEvent.userChoice; + if (choice.outcome === 'accepted') { + ANALYTICS.track('pwa_install_accepted', { + region: STATE.currentRegion, + install_surface: 'after_diary_save' + }); + } + }, { once: true }); +} + +export function initPwaInstallPrompt() { + window.addEventListener('beforeinstallprompt', (event) => { + event.preventDefault(); + deferredInstallPrompt = event; + }); + + document.addEventListener('playpoint:diary-saved', showInstallPromptAfterDiarySave); +} diff --git a/js/region-navigation.js b/js/region-navigation.js new file mode 100644 index 00000000..66122356 --- /dev/null +++ b/js/region-navigation.js @@ -0,0 +1,76 @@ +'use strict'; + +import { CONFIGS, STATE, CONSTANTS } from './config.js'; +import { UI } from './ui.js'; + +export const isEnglishPath = () => /\/en(\/|$)/.test(window.location.pathname); +export const isKoreanPath = () => /\/ko(\/|$)/.test(window.location.pathname); +export const isTaiwanPath = () => /\/tw(\/|$)/.test(window.location.pathname); + +export function applyRegionFromPath() { + try { + if (isEnglishPath()) { + STATE.currentRegion = 'US'; + localStorage.setItem(CONSTANTS.STORAGE_REGION_KEY, 'US'); + } else if (isKoreanPath()) { + STATE.currentRegion = 'KR'; + localStorage.setItem(CONSTANTS.STORAGE_REGION_KEY, 'KR'); + } else if (isTaiwanPath()) { + STATE.currentRegion = 'TW'; + localStorage.setItem(CONSTANTS.STORAGE_REGION_KEY, 'TW'); + } else { + // URLと表示言語を一致させるため、ルートは常に日本語として扱う。 + STATE.currentRegion = 'JP'; + } + } catch (e) { + console.error("地域設定の読み込みに失敗しました:", e); + } +} + +export function switchRegion(newRegion, updateUIForRegion = () => {}) { + if (!CONFIGS[newRegion] || STATE.currentRegion === newRegion) return; + STATE.currentRegion = newRegion; + try { + localStorage.setItem(CONSTANTS.STORAGE_REGION_KEY, newRegion); + } catch (e) { + console.error("地域設定の保存に失敗しました:", e); + UI.showToast("地域設定の保存に失敗しました。", 'error'); + } + + // URLのディレクトリ構成に基づいて静的ページ間を相互遷移させる + const isEn = isEnglishPath(); + const isKo = isKoreanPath(); + const isTw = isTaiwanPath(); + const prefix = (isEn || isKo || isTw) ? '../' : './'; + + let nextUrl = ''; + if (newRegion === 'JP') { + nextUrl = (isEn || isKo || isTw) ? '../' : './'; + } else if (newRegion === 'US') { + nextUrl = prefix + 'en/'; + } else if (newRegion === 'KR') { + nextUrl = prefix + 'ko/'; + } else if (newRegion === 'TW') { + nextUrl = prefix + 'tw/'; + } + + if (nextUrl) { + window.location.href = nextUrl; + } else { + document.querySelectorAll(".region-switch button").forEach(button => { + button.classList.toggle(CONSTANTS.CLASS_ACTIVE, button.dataset.region === newRegion); + }); + updateUIForRegion(); + } +} + +if (typeof window !== 'undefined' && window.__TEST_ENV__) { + window.PP_APP = window.PP_APP || {}; + window.PP_APP.REGION_NAVIGATION = { + applyRegionFromPath, + isEnglishPath, + isKoreanPath, + isTaiwanPath, + switchRegion + }; +} diff --git a/js/service-worker-registration.js b/js/service-worker-registration.js new file mode 100644 index 00000000..d9158fb8 --- /dev/null +++ b/js/service-worker-registration.js @@ -0,0 +1,27 @@ +'use strict'; + +import { isEnglishPath, isKoreanPath, isTaiwanPath } from './region-navigation.js'; + +function runWhenIdle(callback, timeout = 2000) { + if ('requestIdleCallback' in window) { + window.requestIdleCallback(callback, { timeout }); + return; + } + window.setTimeout(callback, Math.min(timeout, 1200)); +} + +export function registerServiceWorker() { + if (!('serviceWorker' in navigator)) return; + + window.addEventListener('load', () => { + runWhenIdle(() => { + const swPath = (isEnglishPath() || isKoreanPath() || isTaiwanPath()) ? '../sw.js' : './sw.js'; + navigator.serviceWorker.register(swPath, { updateViaCache: 'none' }) + .then((reg) => { + console.log('ServiceWorker registered successfully:', reg.scope); + void reg.update().catch(err => console.warn('ServiceWorker update check failed:', err)); + }) + .catch(err => console.error('ServiceWorker registration failed:', err)); + }); + }); +} diff --git a/js/widget-referral.js b/js/widget-referral.js new file mode 100644 index 00000000..d4be512c --- /dev/null +++ b/js/widget-referral.js @@ -0,0 +1,18 @@ +'use strict'; + +import { STATE, ANALYTICS } from './config.js'; + +export function trackWidgetReferral() { + const params = new URLSearchParams(window.location.search); + if (params.get('entry') !== 'widget') return; + try { + if (sessionStorage.getItem('playpoint:widget-referral-tracked') === 'true') return; + sessionStorage.setItem('playpoint:widget-referral-tracked', 'true'); + } catch (error) { + console.warn('ウィジェット流入の重複防止設定を保存できませんでした。', error); + } + ANALYTICS.track('widget_referral_landed', { + region: STATE.currentRegion, + entry_surface: 'embedded_widget' + }); +} diff --git a/ko/index.html b/ko/index.html index b64d954e..64a0be75 100644 --- a/ko/index.html +++ b/ko/index.html @@ -378,7 +378,7 @@

Q. 주간 리워드 일지 데이터는 저장되나요?

- + diff --git a/scripts/asset-sync.cjs b/scripts/asset-sync.cjs index 64eca29e..5409838a 100644 --- a/scripts/asset-sync.cjs +++ b/scripts/asset-sync.cjs @@ -22,6 +22,12 @@ const ROOT_SERVICE_WORKER_ASSETS = [ const APP_MODULE_FILES = [ 'js/config.js', + 'js/region-navigation.js', + 'js/language-suggestion.js', + 'js/calendar-reminder.js', + 'js/pwa-install.js', + 'js/widget-referral.js', + 'js/service-worker-registration.js', 'js/ui.js', 'js/diary.js', 'js/calculator.js', @@ -83,10 +89,19 @@ function syncMainCalculatorUiImportVersion(rootDir, version) { } function syncServiceWorkerRegistration(rootDir) { - const mainJsPath = path.join(rootDir, 'js/main.js'); - if (!fs.existsSync(mainJsPath)) return; - - const currentContent = fs.readFileSync(mainJsPath, 'utf8'); + const candidatePaths = [ + 'js/service-worker-registration.js', + 'js/main.js' + ]; + const targetRelativePath = candidatePaths.find((candidate) => { + const candidatePath = path.join(rootDir, candidate); + return fs.existsSync(candidatePath) + && fs.readFileSync(candidatePath, 'utf8').includes('navigator.serviceWorker.register'); + }); + if (!targetRelativePath) return; + + const targetPath = path.join(rootDir, targetRelativePath); + const currentContent = fs.readFileSync(targetPath, 'utf8'); if (currentContent.includes("updateViaCache: 'none'")) return; const oldRegistration = `navigator.serviceWorker.register(swPath)\n .then(reg => console.log('ServiceWorker registered successfully:', reg.scope))`; @@ -94,11 +109,11 @@ function syncServiceWorkerRegistration(rootDir) { const updatedContent = currentContent.replace(oldRegistration, newRegistration); if (updatedContent === currentContent) { - throw new Error('Service Worker登録処理を更新できませんでした。'); + throw new Error(`Service Worker登録処理を更新できませんでした: ${targetRelativePath}`); } - fs.writeFileSync(mainJsPath, updatedContent, 'utf8'); - console.log('Enabled immediate Service Worker update checks.'); + fs.writeFileSync(targetPath, updatedContent, 'utf8'); + console.log(`Enabled immediate Service Worker update checks in ${targetRelativePath}.`); } function collectAssetVersions(rootDir) { diff --git a/sw.js b/sw.js index f9320115..6410e4c0 100644 --- a/sw.js +++ b/sw.js @@ -1,7 +1,7 @@ 'use strict'; const CACHE_PREFIX = 'playpoint-calc-v'; -const CACHE_NAME = 'playpoint-calc-v20260806_1748-fad37fab'; +const CACHE_NAME = 'playpoint-calc-v20260806_1748-ad5fad04'; const ASSETS = [ './', './style.css?v=08116211ba', @@ -26,7 +26,13 @@ const ASSETS = [ './js/calculator.js', './js/share.js', './js/main-calculator-ui.js?v=fe1ecf8545', - './js/main.js?v=fbe8708023', + './js/main.js?v=416109bf12', + './js/region-navigation.js', + './js/language-suggestion.js', + './js/calendar-reminder.js', + './js/pwa-install.js', + './js/widget-referral.js', + './js/service-worker-registration.js', './js/intent-tracking.js?v=5cdd51c178', './js/consent.js?v=55813d3bcb', './js/third-party.js?v=30f1e46c0b', diff --git a/tests/growth-migration.test.cjs b/tests/growth-migration.test.cjs index c08004f8..3461ffca 100644 --- a/tests/growth-migration.test.cjs +++ b/tests/growth-migration.test.cjs @@ -68,7 +68,7 @@ test('プライバシー文書はWeb版と認定CMPの運用に一致する', () test('再訪と配布の主要イベントだけを許可する', () => { const config = read('js/config.js'); - const main = read('js/main.js'); + const pwaInstall = read('js/pwa-install.js'); const diary = read('js/diary.js'); const embed = read('embed.html'); @@ -80,7 +80,7 @@ test('再訪と配布の主要イベントだけを許可する', () => { ]) { assert.ok(config.includes(eventName), `${eventName} が許可イベントにありません`); } - assert.match(main, /beforeinstallprompt/); + assert.match(pwaInstall, /beforeinstallprompt/); assert.match(diary, /playpoint:diary-saved/); assert.match(embed, //); assert.match(embed, /widget_code_copied/); diff --git a/tests/main-responsibility-split.test.cjs b/tests/main-responsibility-split.test.cjs new file mode 100644 index 00000000..debb29f5 --- /dev/null +++ b/tests/main-responsibility-split.test.cjs @@ -0,0 +1,59 @@ +'use strict'; + +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); + +const root = path.resolve(__dirname, '..'); +const read = relativePath => fs.readFileSync(path.join(root, relativePath), 'utf8'); + +const moduleFiles = [ + 'js/region-navigation.js', + 'js/language-suggestion.js', + 'js/calendar-reminder.js', + 'js/pwa-install.js', + 'js/widget-referral.js', + 'js/service-worker-registration.js' +]; + +test('main.jsは初期化と画面調停に集中し独立責務を各モジュールへ委譲する', () => { + const main = read('js/main.js'); + + for (const file of moduleFiles) { + const importPath = './' + path.basename(file); + assert.ok(main.includes(importPath), `main.jsに${importPath}のimportがありません`); + } + + for (const extractedMarker of [ + 'beforeinstallprompt', + 'BEGIN:VCALENDAR', + 'widget_referral_landed', + 'navigator.serviceWorker.register', + '한국어 버전이 있습니다!' + ]) { + assert.ok(!main.includes(extractedMarker), `main.jsに分離済み責務が残っています: ${extractedMarker}`); + } +}); + +test('分離した責務の実装と既存イベント名は専用モジュールに保持する', () => { + assert.ok(read('js/pwa-install.js').includes('beforeinstallprompt')); + assert.ok(read('js/pwa-install.js').includes("track('pwa_install_accepted'")); + assert.ok(read('js/calendar-reminder.js').includes('BEGIN:VCALENDAR')); + assert.ok(read('js/calendar-reminder.js').includes("track('calendar_reminder_added'")); + assert.ok(read('js/widget-referral.js').includes("track('widget_referral_landed'")); + assert.ok(read('js/service-worker-registration.js').includes('navigator.serviceWorker.register')); + assert.ok(read('js/language-suggestion.js').includes('한국어 버전이 있습니다!')); +}); + +test('新しい実行時モジュールは圧縮・キャッシュ更新・オフライン先読みに含める', () => { + const minify = read('.github/scripts/minify.cjs'); + const assetSync = read('scripts/asset-sync.cjs'); + const serviceWorker = read('sw.js'); + + for (const file of moduleFiles) { + assert.ok(minify.includes(`'${file}'`), `圧縮対象にありません: ${file}`); + assert.ok(assetSync.includes(`'${file}'`), `キャッシュ改訂対象にありません: ${file}`); + assert.ok(serviceWorker.includes(`'./${file}'`), `Service Worker先読みにありません: ${file}`); + } +}); diff --git a/tests/playpoint-regression.test.cjs b/tests/playpoint-regression.test.cjs index 540945db..ecfdc733 100644 --- a/tests/playpoint-regression.test.cjs +++ b/tests/playpoint-regression.test.cjs @@ -1434,13 +1434,18 @@ test('AdSenseタグはConsent Mode設定後に読み込みGoogle認定CMPを起 }); test('トップページはブラウザ言語だけでクライアントサイドリダイレクトしない', () => { - const script = fs.readFileSync(path.join(root, 'js', 'main.js'), 'utf8'); + const main = fs.readFileSync(path.join(root, 'js', 'main.js'), 'utf8'); + const regionNavigation = fs.readFileSync(path.join(root, 'js', 'region-navigation.js'), 'utf8'); + const languageSuggestion = fs.readFileSync(path.join(root, 'js', 'language-suggestion.js'), 'utf8'); + const sources = [main, regionNavigation, languageSuggestion].join('\n'); - assert.ok(!script.includes("window.location.href = './en/'")); - assert.ok(!script.includes("window.location.href = './ko/'")); - assert.ok(!script.includes("window.location.href = './tw/'")); - assert.ok(script.includes("STATE.currentRegion = 'JP';")); - assert.ok(script.includes('checkLanguageSuggestion')); + assert.ok(!sources.includes("window.location.href = './en/'")); + assert.ok(!sources.includes("window.location.href = './ko/'")); + assert.ok(!sources.includes("window.location.href = './tw/'")); + assert.ok(regionNavigation.includes("STATE.currentRegion = 'JP';")); + assert.ok(main.includes('checkLanguageSuggestion')); + assert.ok(languageSuggestion.includes('navigator.language')); + assert.ok(!languageSuggestion.includes('window.location.href')); }); test('ブログのH1はPlay Points攻略記事の検索意図と一致する', () => { @@ -1708,12 +1713,17 @@ test('モバイルの補助リンクと共有ボタンは縦に増えすぎな test('モバイル初期表示外の重い領域は遅延描画される', () => { const css = fs.readFileSync(path.join(root, 'style.css'), 'utf8'); const main = fs.readFileSync(path.join(root, 'js', 'main.js'), 'utf8'); + const serviceWorkerRegistration = fs.readFileSync( + path.join(root, 'js', 'service-worker-registration.js'), + 'utf8' + ); assert.ok(css.includes('#diaryMode')); assert.ok(css.includes('#reverseMode')); assert.ok(css.includes('content-visibility: auto')); - assert.ok(main.includes('requestIdleCallback')); - assert.ok(main.includes('navigator.serviceWorker.register')); + assert.ok(main.includes('registerServiceWorker')); + assert.ok(serviceWorkerRegistration.includes('requestIdleCallback')); + assert.ok(serviceWorkerRegistration.includes('navigator.serviceWorker.register')); }); test('記事クラスタは検索意図別LPへ文脈に合う内部リンクを持つ', () => { diff --git a/tests/static-calculator-delivery.test.cjs b/tests/static-calculator-delivery.test.cjs index 59763011..8d7ff337 100644 --- a/tests/static-calculator-delivery.test.cjs +++ b/tests/static-calculator-delivery.test.cjs @@ -100,13 +100,16 @@ test('UIモジュールの旧HTML向けフォールバックと静的HTML向け test('UIモジュールは内容ハッシュ付きで読み込み、Service Workerも即時更新確認する', () => { const mainSource = read('js/main.js'); + const serviceWorkerRegistration = read('js/service-worker-registration.js'); const serviceWorker = read('sw.js'); const assetSync = read('scripts/asset-sync.cjs'); assert.match(mainSource, /from '\.\/main-calculator-ui\.js\?v=[a-f0-9]{10}';/); - assert.match(mainSource, /register\(swPath, \{ updateViaCache: 'none' \}\)/); - assert.match(mainSource, /reg\.update\(\)/); + assert.match(mainSource, /from '\.\/service-worker-registration\.js';/); + assert.match(serviceWorkerRegistration, /register\(swPath, \{ updateViaCache: 'none' \}\)/); + assert.match(serviceWorkerRegistration, /reg\.update\(\)/); assert.match(serviceWorker, /'\.\/js\/main-calculator-ui\.js\?v=[a-f0-9]{10}'/); + assert.match(serviceWorker, /'\.\/js\/service-worker-registration\.js'/); assert.match(assetSync, /syncMainCalculatorUiImportVersion/); assert.match(assetSync, /syncServiceWorkerRegistration/); assert.match(assetSync, /mainCalculatorUiVersion/); diff --git a/tw/index.html b/tw/index.html index 4f5156ac..744b74c8 100644 --- a/tw/index.html +++ b/tw/index.html @@ -378,7 +378,7 @@

Q. 每週獎勵紀錄的資料會被儲存嗎?

- +