開発者が複数のイベント通知を優先度と排他ルールで統合するフローを指し示している。TypeScript、Reactによる実装、Playwrightでのテスト、アクセシビリティ対応も描かれている。

概要

学習アプリやSaaS で「達成」「初回」「連続記録」などのイベントを通知/トーストで返す設計を考えると、複数のイベントが同時に発火するケースが避けられません。
例えば「初めての記録 + 目標達成 + 連続日数更新」が同じ操作で同時に成立する瞬間もあります。
これを「全部出す」と画面が騒がしくなり、「最後のだけ出す」と重要なものが埋もれてしまいます。

優先度ソート + 排他ルール + 上限件数 の3つを最初に決めて、ユーティリティ関数 1 箇所に閉じ込めると、後から種類を増やしても破綻しません。
本記事では実装パターンと、Playwright での race condition を踏まないテスト方法を解説します。

設計の3つの判断軸

通知/マイルストーンを統合するロジックには以下を順に決めます:

  1. 優先度の comparator: 種類ごとに数値を振って sort で並べる
  2. 排他ルール: 「初回イベントは他と排他」など、特定種類が出たら他を消す
  3. 上限件数: 出して良い最大数(多くは 2 件程度)

TypeScript 実装

type MilestoneKind = 'first_log' | 'achievement' | 'streak_record' | 'best_record'

interface Milestone {
  kind: MilestoneKind
  label: string
}

const PRIORITY: Record<MilestoneKind, number> = {
  achievement: 1,
  streak_record: 2,
  best_record: 3,
  first_log: 100, // 排他扱いのため他より低優先(実際は単独で返るので並びは無関係)
}

export function mergeMilestones(milestones: Milestone[]): Milestone[] {
  if (milestones.length === 0) return []

  // 排他ルール: first_log があれば単独表示
  const firstLog = milestones.find((m) => m.kind === 'first_log')
  if (firstLog) return [firstLog]

  // それ以外は優先度順に最大2件
  return [...milestones]
    .sort((a, b) => PRIORITY[a.kind] - PRIORITY[b.kind])
    .slice(0, 2)
}

ポイント:

  • 排他ルールを関数内に閉じ込めます。呼び出し側で気を遣う必要はありません
  • 入力配列を直接 sort せず、[...milestones] でコピーしてから(純関数)
  • テストは「空 / first_log のみ / first_log + 他種混在 / 上限超え」の4パターン

Playwright での race-free 検証

通知/マイルストーンが API レスポンスと UI 表示の両方に現れるケースは、UI の DOM だけ待つと race condition を踏みやすいです。
page.waitForResponse でサーバ応答を待ちながら同期的に assert します。

test('目標達成時にマイルストーン1件が返る', async ({ page }) => {
  await loginAsDemo(page)
  await openRecordDialog(page)
  await page.locator('#quick-log-count').fill('20')

  // ボタンクリックと API レスポンス待ちを Promise.all で同時開始
  const [response] = await Promise.all([
    page.waitForResponse(
      (r) => r.url().endsWith('/api/goal-logs') && r.request().method() === 'POST',
      { timeout: 10_000 },
    ),
    page.getByRole('button', { name: '今日を保存する' }).click(),
  ])
  const body = await response.json()

  // サーバ応答を直接 assert
  expect(body.milestones).toHaveLength(1)
  expect(body.milestones[0].kind).toBe('achievement')

  // UI 反映も別途確認
  await expect(page.locator('.milestone-celebration')).toBeVisible({ timeout: 10_000 })
})

なぜ Promise.all 同時開始か:

  • 先に click() してから waitForResponse を書くと、レスポンスが既に届いていた場合に永久に待ちます
  • 必ずレスポンスを「取り損ねない」ためには、await の前にイベントを開始します

prefers-reduced-motion を CSS の computedStyle で検証

アニメーション付きの祝福演出は @media (prefers-reduced-motion: reduce) でスパークル等を止める実装が必要です。
Playwright で確実に検証するには getComputedStyle::before を直接見ます。

test('reduced motion 環境ではアニメーションが停止する', async ({ browser }) => {
  const context = await browser.newContext({ reducedMotion: 'reduce' })
  const page = await context.newPage()

  // ... マイルストーン発火操作 ...

  const sparkle = await page.locator('.milestone-achievement').evaluate((el) => {
    const styles = window.getComputedStyle(el, '::before')
    return {
      opacity: styles.opacity,
      animationName: styles.animationName,
      animationDuration: styles.animationDuration,
    }
  })

  // 環境によって挙動が分かれるので OR で柔軟に
  expect(sparkle.opacity).toBe('0')
  const motionDisabled =
    sparkle.animationName === 'none' ||
    sparkle.animationDuration === '0.01ms' || // global guard 経由
    sparkle.animationDuration === '0s'
  expect(motionDisabled).toBe(true)
})

留意点:

  • getComputedStyle(el, '::before') で疑似要素のスタイルが取れます
  • グローバルなガード(*, *::before { animation-duration: 0.01ms !important })が併用されている場合は '0.01ms' のチェックも必要です
  • aria-live="polite" + role="status" をコンポーネントに設定して、スクリーンリーダー利用者にも届くようにします

E2E でノイズを潰すための seed パターン

「初回イベントは排他」のような排他ルールがあると、E2E で別シナリオを検証する際に「想定外に first_log が出てしまう」現象が起きます。
テスト前に「前日に1件 done 記録を入れる」だけのヘルパーを用意しておくと、後段のテストが安定します。

async function seedPriorLog(page: Page): Promise<void> {
  const yesterday = addDays(getE2ETodayKey(), -1)
  await apiPost(page, '/api/logs', {
    logDate: yesterday, recordKind: 'done', /* ... */
  })
}

// 各テストの先頭で
await seedPriorLog(page)

シナリオ独立性を「前提を作るヘルパー」で確保し、本検証のロジックを汚染しません。

まとめ

  • 通知/マイルストーン系は 優先度 + 排他 + 上限の3軸で設計し、ユーティリティ1関数に閉じます
  • Playwright は waitForResponsePromise.all でクリックと同時開始すると race-free になります
  • アニメーションのアクセシビリティは getComputedStyle(el, '::before') で検証できます
  • 「特定イベントが他を排他する」ケースの E2E は、seed ヘルパーで前提を作っておきます

次にやりたいこと

  • 通知種類を増やしたときの優先度衝突テストを property-based に(fast-check 等)
  • マイルストーン発火率の analytics 連携で「祝福→継続率」の相関を可視化