Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
169b6b5
feat(#207): SW notificationclick で trip / block へ deep link 遷移
kuu13580 Jul 30, 2026
0226aeb
test(#207): deep link 系 hook の unit test を追加
kuu13580 Jul 30, 2026
c22d5ce
docs(#207): 通知ドキュメントに deep link 実装を反映
kuu13580 Jul 30, 2026
bc92db0
fix(#207): CodeRabbit 2nd round 指摘対応 (focusBlock=0 弾き + 同値再通知)
kuu13580 Aug 1, 2026
cf02eda
fix(#207): 対象ブロック描画の遅延で scroll が発火しないレースを修正
kuu13580 Aug 1, 2026
a6638e7
fix(#207): PWA background → 別 trip 通知タップで遷移しないケースを解消
kuu13580 Aug 1, 2026
71cb265
chore(#207): 通知タップ→遷移の切り分け用 IndexedDB Logger を追加
kuu13580 Aug 1, 2026
d667ea0
chore(#207): 診断パネルを gate 無し常時表示に変更 (chore branch のみ)
kuu13580 Aug 1, 2026
a4ceaf6
chore(#207): 診断ログ各行にビルド識別子 (DEBUG_LOG_VERSION) を埋め込む
kuu13580 Aug 1, 2026
c4a306e
chore(#207): VitePWA precache から firebase-messaging-sw.js を除外
kuu13580 Aug 1, 2026
240d71b
fix(#207): extractDeepLink を notification.click_action にフォールバック
kuu13580 Aug 1, 2026
2ec6624
fix(#207): FCM SW に skipWaiting + clients.claim 追加で強制即時切替
kuu13580 Aug 1, 2026
3cc9fe7
fix(#207): client 側から FCM SW の update を強制トリガー
kuu13580 Aug 1, 2026
8260594
fix(#207): lastNavigatedRef の永続化バグを時間窓 dedupe に変更
kuu13580 Aug 1, 2026
e9ef636
chore(#207): Round 1 削減 - Cache safety net / dedupe / update トリガー削除
kuu13580 Aug 1, 2026
5e6015d
chore(#207): Round 2 - registration.update() だけ restore (Cache/dedupe…
kuu13580 Aug 1, 2026
5f43d18
docs(#207): 実機検証で判明した仕様をメモに残す
kuu13580 Aug 1, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 44 additions & 0 deletions docs/memo/notification_deeplink_findings.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
# 通知タップ deep link 実機検証で判明した仕様 (Issue #207)

> 本メモは検証ブランチ `chore/issue207_debug-logger` (PR #216 / DO NOT MERGE)
> で Android PWA 実機を使って一連の切り分けを行った結果、**検証で確定した仕様**
> のみを記録する。実装の意思決定は #209 の commit / PR description に残す。

## FCM SW の update lifecycle

- **FCM SW は root scope 外 (`/firebase-cloud-messaging-push-scope`) に登録される**ため、navigation 由来の SW update check が発火しない
- `navigator.serviceWorker.register()` の byte-diff check は PWA セッション中 1 回のみ (呼び出し側の promise cache により再呼び出しされない)
- **明示 `registration.update()` が唯一の SW 更新経路**
- SW default lifecycle だと new SW は install → waiting のまま、既存 client が全部閉じるまで activate されない
- install イベントで `self.skipWaiting()` を呼ぶと即 waiting をスキップして activate に遷移する
- activate イベントで `self.clients.claim()` を呼ぶと既存 client (root scope 含む) の controller にもなれる

## VitePWA precache との干渉

- `workbox.globPatterns: ['**/*.{js,...}']` は **firebase-messaging-sw.js を precache 対象に取り込む**
- 結果、Chrome の `register('/firebase-messaging-sw.js')` 時に VitePWA sw.js の fetch handler が cache 経由で古い版を返す → 新 SW が実機で永遠に active にならない
- **対策**: `workbox.globIgnores` + `navigateFallbackDenylist` で firebase-messaging-sw.js を除外

## FCM payload に載る deep link の位置

- Firebase Admin SDK v13 の `WebpushFCMOptions(link=...)` を指定して送ると、SDK v12.16 の client 側では **`data.FCM_MSG.notification.click_action`** に写された payload が届く
- foreground 通知 (`useForegroundNotificationToast` の自前 `showNotification`) 経由の場合は **`data.link`** に相対 URL が入る (別経路)
- extract 順は `click_action` を primary、`data.link` を fallback にする

## Firebase Hosting のデフォルト cache header

- 静的ファイルに `cache-control: max-age=3600` (1 時間) がデフォルトで付く
- **SW file にこれが効くと deploy 直後でも update が実機に届くまで最悪 1 時間遅延する**
- `firebase.json` の `headers` で `/firebase-messaging-sw.js` に `Cache-Control: no-cache, no-store, must-revalidate` を指定して抑制

## task-killed 状態からの通知タップ

- PWA を recents から swipe out した完全終了状態で通知タップすると、SW の `matchAll` は **0 件**を返す (phantom client を返さない)
- `clients.openWindow(url)` で PWA が起動し、**渡した URL が尊重されて focusBlock まで適用**される (Android Chrome 実機で確認)
- postMessage 未着ケース想定の Cache Storage safety net (SW → client の遅延伝達経路) は **不要**と確認された

## block DOM の描画タイミング

- `ViewTripLayout` は `useBlocks(pageId).isLoading` 中は `TimelineSkeleton` を返す構造 → 該当 page への `setSelectedPageId` 直後は `data-block-id` の DOM が未存在
- rAF ポーリングだと Skeleton → Timeline 差し替え待ちで空回りする → **MutationObserver で DOM 変更 event 単位に補足**
- staging Cold Start では Skeleton → Timeline 差し替えまで 3 秒を超えるケースがあり、**timeout は 8 秒**確保する
4 changes: 2 additions & 2 deletions docs/memo/notification_roadmap.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,9 +53,9 @@ graph LR
- [x] Deep link URL 埋め込み (`?focusBlock={block_id}` を FCM `data.link` に付与)
- [x] フォアグラウンド OS 通知 (`useForegroundNotificationToast` → `registration.showNotification`)

### Phase 6b-9: 未実装 (別 PR / Follow-up)
### Phase 6b-9

- [ ] SW `notificationclick` ハンドラ (tap 時に `focusBlock` を消費してブロックまでスクロール)
- [x] SW `notificationclick` ハンドラ (#207): tap で既存 client に postMessage → SPA navigate、無ければ openWindow。client 側は `useFocusBlockOnMount` で block まで scroll
- [ ] Phase 7: stg 環境 E2E テスト (iOS/Android/Desktop 実機)
- [ ] Phase 8: prod リリース + Cloud Scheduler ジョブ作成
- [ ] Phase 9 (#173): `sent_notifications` 掃除 cron
Expand Down
2 changes: 1 addition & 1 deletion docs/notifications.md
Original file line number Diff line number Diff line change
Expand Up @@ -196,7 +196,7 @@ tick が 60 秒を超えると次 tick と重なる。閾値と対処:
- 素材: FA (frontend/src/assets/icons) + Lucide MapPin。オレンジバッジ (`#f4a261`) + 白抜き
- **Badge** (Android status bar 等の小モノクロアイコン、96px、透過 PNG): `badge.png` を全通知で共通使用。紙飛行機シルエット (favicon 由来)、OS 側でアクセントカラーへリカラーされる。icon と別 URL にしないと Android で四角い塗りになる
- 生成スクリプト: `scripts/gen_notification_icons.py`
- **Deep link**: `/trip/{urlId}?focusBlock={blockId}` (該当ブロックにスクロール、Phase 2 で完全実装)
- **Deep link**: `/trip/{urlId}?focusBlock={blockId}`。SW `notificationclick` (`frontend/public/firebase-messaging-sw.js`) が受け、既存 PWA/tab (focused > visible > 任意) に postMessage で client-side navigate、無ければ `clients.openWindow`。client 側は `useFocusBlockOnMount` で `useBlock(id)` から pageId を得て page 切替 → `[data-block-id]` を rAF 待機して `scrollIntoView` (center)。処理後 `focusBlock` は replaceState で除去。

## 6. プラットフォーム対応

Expand Down
24 changes: 24 additions & 0 deletions firebase.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,14 @@
"source": "**",
"destination": "/index.html"
}
],
"headers": [
{
"source": "/firebase-messaging-sw.js",
"headers": [
{ "key": "Cache-Control", "value": "no-cache, no-store, must-revalidate" }
]
}
]
},
{
Expand All @@ -20,6 +28,14 @@
"source": "**",
"destination": "/index.html"
}
],
"headers": [
{
"source": "/firebase-messaging-sw.js",
"headers": [
{ "key": "Cache-Control", "value": "no-cache, no-store, must-revalidate" }
]
}
]
},
{
Expand All @@ -31,6 +47,14 @@
"source": "**",
"destination": "/index.html"
}
],
"headers": [
{
"source": "/firebase-messaging-sw.js",
"headers": [
{ "key": "Cache-Control", "value": "no-cache, no-store, must-revalidate" }
]
}
]
}
]
Expand Down
145 changes: 145 additions & 0 deletions frontend/public/firebase-messaging-sw.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,151 @@
// biome-ignore-all lint/correctness/noUndeclaredVariables: firebase / importScripts は SW context の global
// biome-ignore-all lint/correctness/noUnusedFunctionParameters: onBackgroundMessage の signature は Firebase 規定

// notificationclick handler は Firebase SDK の import / init より前に登録する必要がある。
// Firebase Messaging SDK は内部で notificationclick リスナーを付け stopImmediatePropagation
// することがあり、後付けで addEventListener すると invocation されないケースがある。
// (`firebase-js-sdk/packages/messaging/src/listeners/sw-listeners.ts` の実装参照)

// --- 診断ロガー (client 側 lib/debugLogger.ts と同じ DB / store) ---
// Android PWA から console を取れない環境向け。SW から IndexedDB に書いて client 側で
// 読み出す。運用機能ではないので消しても実装挙動には影響しない。
// DEBUG_LOG_VERSION は診断コード修正のたびに手動で bump する。client 側 debugLogger.ts の
// DEBUG_LOG_VERSION と対で更新。ログ各行に埋め込まれるので、実機で古い SW が動いているのか
// 新しい SW が動いているのかを共有ログから判別できる (SW 更新は非同期でユーザ操作依存なため)。
const DEBUG_LOG_VERSION = 'v09-sw-2026-08-01';
const DEBUG_DB = 'fcm-debug-log';
const DEBUG_STORE = 'entries';
const DEBUG_MAX = 500;
const openDebugDb = () =>
new Promise((resolve, reject) => {
const req = indexedDB.open(DEBUG_DB, 1);
req.onupgradeneeded = () => {
const db = req.result;
if (!db.objectStoreNames.contains(DEBUG_STORE)) {
db.createObjectStore(DEBUG_STORE, { autoIncrement: true });
}
};
req.onsuccess = () => resolve(req.result);
req.onerror = () => reject(req.error);
});
const debugLog = async (tag, message, data) => {
try {
const db = await openDebugDb();
const tx = db.transaction(DEBUG_STORE, 'readwrite');
const store = tx.objectStore(DEBUG_STORE);
const safeData = data === undefined ? null : JSON.parse(JSON.stringify(data));
store.add({ ts: Date.now(), version: DEBUG_LOG_VERSION, tag, message, data: safeData });
const countReq = store.count();
countReq.onsuccess = () => {
const excess = countReq.result - DEBUG_MAX;
if (excess <= 0) return;
const cursorReq = store.openCursor();
let remaining = excess;
cursorReq.onsuccess = e => {
const cursor = e.target.result;
if (!cursor || remaining <= 0) return;
cursor.delete();
remaining -= 1;
cursor.continue();
};
};
} catch (_) {
// logger must not throw
}
};

// SW script が evaluate されたタイミングを残す。SW 更新のタイミング把握用。
void debugLog('SW', 'sw script evaluated');

// 新版 SW の active 待ち (デフォルト挙動) だと既存 tab が全部閉じるまで古い SW が動き続ける。
// notificationclick handler / extractDeepLink の修正が実機に届かないケースが観測されたため
// install → skipWaiting、activate → clients.claim で強制的に即切替させる。
self.addEventListener('install', event => {
void debugLog('SW', 'install (skipWaiting)');
event.waitUntil(self.skipWaiting());
});
self.addEventListener('activate', event => {
void debugLog('SW', 'activate (clients.claim)');
event.waitUntil(self.clients.claim());
});
// [Round 1 削減] SKIP_WAITING message handler は client 側の
// registration.update() 経路と対で削除。install → skipWaiting のデフォルトルートのみで
// 更新が成立するか検証する。

// FCM payload に載る deep link の位置は複数の可能性がある:
// - `data.FCM_MSG.notification.click_action`: Firebase Admin SDK で WebpushFCMOptions(link=...) を
// 指定した場合、SDK 12.x が実 payload に写す形。実機ログで確認済で **これが primary**
// - `data.FCM_MSG.fcmOptions.link`: SDK バージョンや Admin SDK 経路によってはこちらに入るとの
// 資料あり (残しておく安全策)
// - `data.link`: フォアグラウンド通知 (useForegroundNotificationToast) の自前 showNotification 経由
const extractDeepLink = notification => {
const data = notification?.data ?? {};
const fcm = data?.FCM_MSG;
return fcm?.notification?.click_action ?? fcm?.fcmOptions?.link ?? data?.link ?? null;
};

// PWA / ブラウザ tab 両方 push 登録している端末で、ユーザが今触ってる方を選ぶための優先度。
const pickTargetClient = clientsList => {
return (
clientsList.find(c => c.focused) ?? clientsList.find(c => c.visibilityState === 'visible') ?? clientsList[0] ?? null
);
};

// [Round 1 削減] Cache Storage safety net (storePendingIntent) と client 側 applyIntent 経路を削除。
// SW 更新問題 (真の原因) が解決した今、postMessage + focus + openWindow fallback だけで十分か検証する。

self.addEventListener('notificationclick', event => {
event.notification.close();
void debugLog('SW', 'notificationclick', { data: event.notification.data });

const link = extractDeepLink(event.notification);
void debugLog('SW', 'link extracted', { link });
if (!link) return;

// payload 経路経由の open-redirect を防ぐ (別 origin URL は捨てる)。
let targetUrl;
try {
targetUrl = new URL(link, self.location.origin);
} catch (err) {
void debugLog('SW', 'URL parse fail', { err: String(err) });
return;
}
if (targetUrl.origin !== self.location.origin) {
void debugLog('SW', 'origin mismatch', { target: targetUrl.origin, self: self.location.origin });
return;
}

event.waitUntil(
(async () => {
const clientsList = await self.clients.matchAll({ type: 'window', includeUncontrolled: true });
void debugLog('SW', 'matchAll', {
n: clientsList.length,
clients: clientsList.map(c => ({ url: c.url, focused: c.focused, vis: c.visibilityState })),
});
const target = pickTargetClient(clientsList);
void debugLog('SW', 'target picked', { url: target ? target.url : null });

if (target) {
// WindowClient.navigate() を使うとフルリロードで atom / SWR / scroll 位置が飛ぶので、
// 代わりに client 側で React Router の navigate を呼んでもらう。
target.postMessage({ type: 'FCM_NAVIGATE', url: targetUrl.href });
void debugLog('SW', 'postMessage sent', { url: targetUrl.href });
try {
await target.focus();
void debugLog('SW', 'focus ok');
} catch (err) {
// focus はユーザ操作起源でないと reject されるが、postMessage は届いてるので許容
void debugLog('SW', 'focus fail', { err: String(err) });
}
return;
}

void debugLog('SW', 'openWindow fallback', { url: targetUrl.href });
await self.clients.openWindow(targetUrl.href);
})()
);
});

importScripts('https://www.gstatic.com/firebasejs/12.16.0/firebase-app-compat.js');
importScripts('https://www.gstatic.com/firebasejs/12.16.0/firebase-messaging-compat.js');

Expand Down
4 changes: 4 additions & 0 deletions frontend/src/App.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
import { useAtomValue } from 'jotai';
import { Route, Routes } from 'react-router-dom';
import { isOfflineReadAtom } from './atoms/network';
import { DebugLogPanel } from './components/DebugLogPanel';
import { NoIndex } from './components/NoIndex';
import { Title } from './components/Title';
import { useFcmNavigationListener } from './hooks/useFcmNavigationListener';
import { useForegroundNotificationToast } from './hooks/useForegroundNotificationToast';
import { useNetworkToast } from './hooks/useNetworkToast';
import { usePageTracking } from './hooks/usePageTracking';
Expand All @@ -19,6 +21,7 @@ const App = () => {
useNetworkToast();
usePageTracking();
useForegroundNotificationToast();
useFcmNavigationListener();

return (
<>
Expand Down Expand Up @@ -52,6 +55,7 @@ const App = () => {
}
/>
</Routes>
<DebugLogPanel />
</>
);
};
Expand Down
59 changes: 59 additions & 0 deletions frontend/src/components/DebugLogPanel.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import { toast } from 'sonner';
import { clearAllLogs, DEBUG_LOG_VERSION, readAllLogs } from '@/lib/debugLogger';

/**
* 診断ブランチ (chore/issue207_debug-logger) 専用: 右下に Copy / Clear ボタンを常時表示する。
* PR #216 は Draft & DO NOT MERGE 前提。gate 無しで install 直後から使える状態にしておく。
*/

const copyText = async (text: string): Promise<boolean> => {
try {
await navigator.clipboard.writeText(text);
return true;
} catch {
return false;
}
};

const DebugLogPanel = () => {
const onCopy = async () => {
const logs = await readAllLogs();
const controllerUrl = navigator.serviceWorker?.controller?.scriptURL ?? '(no controller)';
const header = [
'=== fcm debug log ===',
`client version: ${DEBUG_LOG_VERSION}`,
`sw controller: ${controllerUrl}`,
`current URL: ${window.location.href}`,
`copied at: ${new Date().toISOString()}`,
'=====================',
].join('\n');
const body = logs || '(no entries)';
const payload = `${header}\n${body}`;
const lines = body.split('\n').length;
const ok = await copyText(payload);
if (ok) {
toast.success(`${lines} 件コピー (+ header)`);
} else {
// クリップボード拒否時は prompt で見せて手動コピー
window.prompt('コピーしてください', payload);
}
};

const onClear = async () => {
await clearAllLogs();
toast.success('ログをクリア');
};

return (
<div className='fixed right-2 bottom-2 z-50 flex gap-1 rounded-lg bg-black/80 p-1 text-10px text-white'>
<button type='button' onClick={onCopy} className='cursor-pointer rounded bg-teal-600 px-2 py-1 hover:bg-teal-500'>
Copy Logs
</button>
<button type='button' onClick={onClear} className='cursor-pointer rounded bg-red-600 px-2 py-1 hover:bg-red-500'>
Clear
</button>
</div>
);
};

export { DebugLogPanel };
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ export function BlockScheduleView({ block, isNow, className }: BlockScheduleView
return (
<div
ref={resizeRef}
data-block-id={block.id}
className={cn(
'relative flex w-full flex-col gap-2 rounded-lg bg-linear-to-r from-teal-400 px-4 py-2',
isHovered ? 'to-teal-400' : 'to-teal-500',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ export function BlockTransportationView({ block, isNow, className }: BlockTransp
return (
<div
ref={resizeRef}
data-block-id={block.id}
className={cn(
'relative flex w-full flex-col gap-2 rounded-lg bg-gradient-to-r from-sky-50 px-2 py-2 sm:px-4',
isHovered ? 'to-sky-50' : 'to-sky-100',
Expand Down
13 changes: 12 additions & 1 deletion frontend/src/hooks/useBlocks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,11 +14,22 @@ const BLOCKS_BASE_PATH = '/blocks';
* pageId に紐づく Block をすべて取得するフック
*/
export const useBlocks = (pageId: number | null, options?: Pick<SWRConfiguration, 'refreshInterval'>) => {
const { mutate: globalMutate, cache } = useSWRConfig();
const { data, error, isLoading } = useSWR<Block[]>(
pageId ? `${PAGES_BASE_PATH}/${pageId}/blocks` : null,
async (url: string) => {
const res = await fetcher(url);
return z.array(blockFromApi).parse(res);
const parsed = z.array(blockFromApi).parse(res);
// list fetch で得た block を個別 key にも撒いて useBlock(id) の重複 fetch を避ける
// (通知タップの deep link 解決経路で効く)。ただし既存値は上書きしない:
// 楽観更新中の値を list revalidation の古いサーバ値で巻き戻すのを防ぐため。
for (const block of parsed) {
const individualKey = `${BLOCKS_BASE_PATH}/${block.id}`;
if (cache.get(individualKey)?.data === undefined) {
globalMutate(individualKey, block, { revalidate: false });
}
}
return parsed;
},
options
);
Expand Down
Loading
Loading