2
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Next.js 15 × IndexedDB でポモドーロ代替テクニックのフロータイムアプリを作った

Posted at

FlowTimeはポモドーロテクニックの改善版「フロータイムテクニック」をベースにしたフォーカスタイマー + タスク管理アプリです。クラウドなし・完全ローカル(IndexedDB)で動作し、セッション履歴から集中時間・休憩時間・タスク内訳を統計ダッシュボードで可視化します。本記事では本リポジトリの技術的な全体像と、とくに統計ダッシュボードの実装を深掘りします。

公開ページ:https://flow.yattask.app/


ハイライト

  • Next.js 15(App Router)+ React 19、Cloudflare Workers へ OpenNext でデプロイ
  • データは IndexedDB のみ。日跨ぎセッションは分割保存、集計はすべてオンデマンド
  • 統計ダッシュボード: KPI、積み上げエリア(Recharts)、タスク内訳、GitHub 互換ヒートマップ、時系列タイムライン
  • データ更新は EventTarget(dbEvents)で購読し、SWR/useSyncExternalStore で即時反映
  • Timezone/週起点は dayjs.tz + isoWeek で一貫管理。WCAG 2.2 AA の配色

リポジトリ構成

  • Source: src/(Next.js App Router)。主要ディレクトリ: app/, components/, db/, hooks/, lib/, services/, stores/, types/
  • Public: public/(Service Worker を src/sw.js からコピー)
  • Docs: docs/(本記事、設計ノートなど)

品質ゲート(PR 前の必須チェック):

npx biome check --write --config-path ./biome.json
npx tsc --noEmit  # もしくは `npm test`

技術スタック

このプロジェクトで使っている主なライブラリと、その役割や設定のポイントをまとめました(括弧内は主なバージョン)。

  • 基盤/ランタイム

    • Next.js 15(next@15.3.x): App Router と Server Components を採用。pageExtensionsmd/mdx を含めて、MDX をページとして扱えるようにしています。next.config.ts では Webpack を拡張し、特定の MDX を文字列として取り込むことで(Cloudflare 環境でも)fs に依存しないようにしています。
    • React 19(react@19.1.x): Server Componentsに加えて、next/dynamic で動的 import も使っています。
    • TypeScript(typescript@5.9.x): 型は厳しめに。パスエイリアスは @srcvitest.config.ts/tsconfig.json)。
    • Cloudflare Workers へのデプロイ: OpenNext(@opennextjs/cloudflare@1.6.x)でビルド/デプロイ(npm run deploy/preview)。開発中は initOpenNextCloudflareForDev() を呼び出します。wrangler@4.xcloudflare-env.d.ts で環境変数も型安全に扱えます。
  • UI/スタイル

    • Tailwind CSS 4(tailwindcss@4.1.x): darkMode: 'class'。Markdown 用の Typography 設定を theme.extend.typography で用意。tailwind-merge(ユーティリティ競合の解消)と class-variance-authority(バリアント設計)も併用しています。
    • テーマ/ダークモード: next-themes で OS/ユーザーの選択に連動(.proseprose-invert に対応)。
    • UI プリミティブ: Radix UI(@radix-ui/react-*)と Headless UI(@headlessui/react)。アイコンは lucide-react
    • アニメーション: 必要に応じて tw-animate-css を利用。
  • コンテンツ/MDX/Markdown

    • MDX v3: @next/mdx + @mdx-js/*next.config.tsasset/source を使い、src/content/{blog,persona} 配下の MDX をビルド時に「生文字列」として同梱します(Workers でもファイル I/O に頼りません)。
    • Markdown レンダリング: react-markdown + remark-gfm/remark-breaks/rehype-raw/rehype-sanitize/rehype-highlight。シンタックスハイライトは highlight.js
    • AST ユーティリティ: unist-util-visit
  • 状態管理/データアクセス

    • IndexedDB: idb@8 を薄いトランザクション層でラップ(src/db/idb/transaction.ts)。書き込み時は dbEvents(EventTarget)が change を飛ばします。
    • クライアントステート: zustand@5(ストア)+ immer(不変更新)+ use-sync-external-store(購読用の標準 API)。
    • データ取得/キャッシュ: swr@2。SWR キーに lastChange を混ぜ、再取得のトリガにしています。
    • 状態機械: @xstate/fsm(タイマーなどを FSM で表現)。
    • ワーカー連携: comlink(必要なら comlink-loader も)。
    • ユーティリティ: lodash-es/fast-deep-equal/uuid
  • 日付/時刻/タイムゾーン

    • dayjs + timezone + isoWeek: 週の起点や日付境界を一貫管理。chartjs-adapter-dayjs-4 は Chart.js 側の表示/TZ を合わせるために使用。
    • date-fns: 期間の整形など一部で補助的に使用。
  • 可視化

    • Recharts(recharts@2): 時系列の積み上げエリアや KPI 用のチャートに使用。
    • Chart.js(chart.js@4)+ react-chartjs-2: 円弧ゲージやミニチャートなど、必要な箇所で使用。
  • バリデーション/スキーマ

    • zod@4: DB に保存するオブジェクト(たとえば RawSession)のスキーマと安全なパースに使用。
    • tsafe/schema-dts: 型の補助と、構造化データ(JSON-LD)を型安全に扱うために使用。
  • PWA/サービスワーカー

    • src/sw.js: install/activateskipWaitingclients.claim を呼ぶ、必要最小限の実装。ビルド時に public/sw.js へコピー(npm run build-sw)。現時点ではプリキャッシュなし(workbox-cli は将来拡張用)。
  • テスト/品質

    • Unit: Vitest(vitest@3jsdom)。@testing-library/react/@testing-library/jest-dom を併用。IndexedDB は fake-indexeddb でモック。時間は @sinonjs/fake-timers で固定し、timezone-mock で TZ 差を吸収。プロパティベーステストに fast-check
    • E2E: Playwright(@playwright/test@1.54)。playwright.config.ts で dev サーバ(3001)を自動起動します。
    • Lint/Format: ESLint(eslint@9 + eslint-config-next@15)、フォーマッタは Biome(@biomejs/biome@2.1)。PR 前は「Biome + 型チェック/テスト」を必須にしています。
  • ビルド/設定

    • next.config.ts: @next/mdx を適用しつつ、asset/source のルールを追加。transpilePackages: ['zod'] を指定。開発時は Cloudflare Dev を初期化します。
    • tailwind.config.ts: v4 の設定。darkMode: 'class'、Typography のライト/ダーク両対応、カスタムユーティリティ(heatmap-vars など)。
    • vitest.config.ts: jsdom、tests/setup.ts を読み込み、@ エイリアスを定義。
    • playwright.config.ts: webServer 設定で dev を起動し、tests/e2e を対象にします。

詳しくは本文の各セクション(例: 「オフライン完結・オンデマンド集計」「時系列チャート」)もあわせてご覧ください。


アーキテクチャの要点

オフライン完結・オンデマンド集計

データは idb を薄いトランザクションラッパ(src/db/idb/transaction.ts)で扱います。サーバは不要、集計テーブルも持ちません(ロールアップ廃止)。可視化で必要な情報はすべて「その場で」計算します。

// src/db/idb/transaction.ts(抜粋)
export const dbEvents = new EventTarget()

export async function tx(stores, mode, fn) {
  const db = await openDB()
  const tr = db.transaction(stores, mode)
  try {
    const result = await fn(tr)
    await tr.done
    if (mode === 'readwrite') dbEvents.dispatchEvent(new Event('change'))
    return result
  } catch (err) {
    tr.abort(); await tr.done.catch(() => {})
    throw err
  }
}

書込み後に dbEvents: EventTargetchange を発火。読み取り側は SWR キーや useSyncExternalStore で購読し、自律的に再フェッチ・再描画します。

時間・タイムゾーンの扱い

dayjs.tz()isoWeek を全箇所で徹底。日/週/月/年の境界はユーザーのローカル TZ で決め、ストアのキー(UIDate)は YYYY-MM-DD です。

// src/lib/dayjsUtc.ts(要旨)
dayjsLib.extend(utc); dayjsLib.extend(timezone); dayjsLib.extend(isoWeek)
export const tz = Intl.DateTimeFormat().resolvedOptions().timeZone
dayjsLib.tz.setDefault(tz)

データモデルと保存戦略

RawSession

// src/types/stats.ts(要旨)
export const RawSessionSchema = z.object({
  sessionId: z.string().uuid(),
  startEpochMs: z.number().int().nonnegative(),
  endEpochMs: z.number().int().nonnegative(),
  type: z.enum(['focus','break']),
  timeZone: z.string(),
  userId: z.string().uuid(),
  timerId: z.string().uuid(),
  id: z.number().int().optional(),
  taskId: z.string().uuid().nullable().optional(),
})
export type RawSession = z.infer<typeof RawSessionSchema>

セッション保存は raw_sessions ストアに一本化。日跨ぎは分割保存し、startEpochMs インデックスで期間取得を最適化します。

読み取りと変更購読

// src/hooks/useDbLiveQuery.ts(抜粋)
export function useDbLiveQuery<T>(fn: () => Promise<T>, deps: readonly unknown[]) {
  const [snapshot, setSnapshot] = useState<T>()
  const subscribe = useCallback((cb) => {
    const h = () => { fn().then(setSnapshot); cb() }
    dbEvents.addEventListener('change', h)
    return () => dbEvents.removeEventListener('change', h)
  }, [fn])
  const value = useSyncExternalStore(subscribe, () => snapshot, () => snapshot)
  useEffect(() => { let a = true; fn().then(v => a && setSnapshot(v)); return () => { a = false } }, deps)
  return value
}

useRawSessions() はこのフックを用い、期間内の生セッションを常に最新で返します。

// src/hooks/useRawSessions.ts(抜粋)
export function useRawSessions(startEpochMs: number, endEpochMs: number) {
  const data = useDbLiveQuery(async () => {
    return tx(['raw_sessions'], 'readonly', async tr => {
      const store: any = tr.objectStore('raw_sessions')
      const index = store.index('startEpochMs')
      const range = IDBKeyRange.bound(startEpochMs, endEpochMs)
      return index.getAll(range)
    })
  }, [startEpochMs, endEpochMs])
  return { data: data ?? [], loading: data === undefined }
}

統計ダッシュボード(/stats)の全体像

画面エントリは src/app/stats/page.tsx。実体は StatsDashboard を動的 import しています。

// src/app/stats/page.tsx
import dynamic from 'next/dynamic'
const StatsDashboard = dynamic(() => import('@/components/stats/StatsDashboard'))
export default function StatsPage() { return <StatsDashboard /> }

レイアウト(視覚的な責務分離)

StatsDashboard.tsx は次のセクションで構成します。

  1. ヘッダー + 期間タブ(RangeTabs)+ KPI(SummaryCards)
  2. 時系列チャート(TimelineChart)+ 指標カード(BreakRatio/AvgSession)
  3. タスク内訳(TaskBreakdownCard)
  4. 52 週 Heatmap
  5. Activity Timeline(セッション一覧)
  6. フッター(最終更新)

SWR と useDbChangeStore.lastChange を組み合わせ、DB 変更のたびに KPI/サブコンポーネントを再計算します。

// src/components/stats/StatsDashboard.tsx(要旨)
const [period, setPeriod] = useState<'daily'|'weekly'|'monthly'|'yearly'>('daily')
const timeRange = useMemo(() => createTimeRange(dayjs().tz(tz), period), [period])
const { data: totals, isLoading } = useAggregatedTotals(timeRange, `${todayIso}-${lastChange}`)

KPI: SummaryCards / 指標カード

KPI は「集中時間 / 休憩時間 / セッション数」の 3 枚。時間フォーマットは lib/timeFormat.ts に集約し、全 UI で統一します。

// src/lib/timeFormat.ts(抜粋)
export function msToTimeString(ms: number, mode: 'hm'|'h' = 'h') {
  if (ms < 0 || Number.isNaN(ms)) return ''
  if (mode === 'hm') return minutesToHM(Math.round(ms / 60_000))
  return (ms / 3.6e6).toFixed(1)
}

休憩比率/平均セッション長は SVG 円弧ゲージで表現。しきい値で色を切替、アニメーションは CSS のみで滑らかに。

// src/components/stats/BreakRatioIndicator.tsx(抜粋)
const ratio = totals.focusSec + totals.breakSec === 0 ? 0 : totals.breakSec / (totals.focusSec + totals.breakSec)
const strokeDashoffset = c * (1 - ratio)  // c=2πr

時系列チャート: TimelineChart(Recharts)

期間ごとの集中/休憩を積み上げエリアで表示。timelineUtils.ts で時間範囲メタとバケット化を一元管理します。

// src/components/stats/timelineUtils.ts(抜粋)
export function getRangeMeta(range, dateISO, tz) {
  // daily: hour bucket, weekly/monthly: day, yearly: week
}
export function bucketizeSessions(sessions, meta) {
  const focusMs = Array(meta.bucketCount).fill(0)
  const breakMs = Array(meta.bucketCount).fill(0)
  for (const s of sessions) {
    const segStart = Math.max(s.startEpochMs, meta.start)
    const segEnd = Math.min(s.endEpochMs, meta.end)
    if (segEnd <= segStart) continue
    const firstIdx = Math.floor((segStart - meta.start) / meta.bucketMs)
    const lastIdx = Math.floor((segEnd - meta.start - 1) / meta.bucketMs)
    for (let i = firstIdx; i <= lastIdx; i += 1) {
      const bStart = meta.start + i * meta.bucketMs
      const bEnd = bStart + meta.bucketMs
      const overlap = Math.min(segEnd, bEnd) - Math.max(segStart, bStart)
      if (overlap <= 0) continue
      const target = s.type === 'focus' ? focusMs : breakMs
      target[i] += overlap
    }
  }
  return { labels: meta.labels, focusMs, breakMs }
}

描画側では useElementSize + ResizeObserver で高さを動的計算し、ツールチップの系列順を固定(Focus → Break)して認知負荷を下げています。スクリーンリーダ向けに sr-only の表を併設。

// src/components/stats/TimelineChart.tsx(要旨)
const [containerRef, { width }] = useElementSize()
const height = Math.max(240, Math.min(480, Math.round(width * 0.65)))
<Tooltip itemSorter={(item) => SERIES_ORDER.indexOf(item.dataKey as 'focus'|'break')} />

タスク内訳: TaskBreakdownCard

AggregatedTotals.taskStats は実装差によって「秒合計(数値)」または「内訳オブジェクト」の場合があるため、ノーマライズしてから集計します。タイトルは tasksStore と突合して解決し、論理削除済みは除外。

// src/components/stats/TaskBreakdownCard.tsx(要旨)
function normalizeTaskStat(v: number | { focusSec:number; breakSec?:number; focusSessions?:number }) {
  if (typeof v === 'object') return { sec: (v.focusSec??0) + (v.breakSec??0), sessions: v.focusSessions??0 }
  return { sec: v ?? 0, sessions: 0 }
}

52 週 Heatmap(GitHub 互換)

getDailyBuckets()UIDate(YYYY-MM-DD)をキーにした日次合計を返却し、それを月曜始まり 52×7 のグリッドへ投影します。濃淡は四分位で決定、データが少ない場合はフォールバックしきい値を用意して視覚破綻を防ぎます。

// src/services/totalsService.ts(該当部)
export async function getDailyBuckets(start: UIDate, end: UIDate) {
  const map = new Map<UIDate, TotalsRow>()
  await tx(['raw_sessions'], 'readonly', async tr => {
    const idx = tr.objectStore('raw_sessions').index('startEpochMs')
    for (let cur = await idx.openCursor(IDBKeyRange.bound(startUtc, endUtc)); cur; cur = await cur.continue()) {
      const s = cur.value as RawSession
      const uid = dayjs.utc(s.startEpochMs).tz(s.timeZone).format('YYYY-MM-DD') as UIDate
      // 行の生成/加算(focusSec/breakSec/sessionCount, taskStats...)
    }
  })
  return [...map.values()].sort((a, b) => (a.id < b.id ? -1 : 1))
}

無効化戦略(SWR × dbEvents)

SWR キーに lastChange を混ぜることで、DB 書込みのたびに KPI/集計が更新されます。生セッションは useDbLiveQuery が購読し、即座にレンダリングへ反映します。

// src/hooks/useAggregatedTotals.ts(抜粋)
const { data, isLoading } = useSWR([
  'aggregated-totals',
  range.cacheKey(),             // 期間
  `${todayIso}-${lastChange}`,  // DB 変更インバリデータ
], () => getAggregatedTotals(range))

アクセシビリティとデザイン

  • コントラストはライト/ダーク共に WCAG 2.2 AA を意識した配色
  • TimelineChartsr-only テーブルを併設(数値は単位とともに)
  • 週の起点・日付フォーマットは ISO/ローカル TZ で統一し、表記ブレを排除

パフォーマンスの工夫

  • 期間取得は startEpochMs インデックスを範囲指定(IDBKeyRange.bound)で O(log n)
  • バケット化は「重複区間長」のみを加算し、再計算は useMemo で最小化
  • Heatmap は 52×7 セルを一次配列で生成 → CSS Grid に投影(Githubの草と同じ仕様を目指した)

まとめ

FlowTimeは「保存時ロールアップに頼らず、フロントだけでオンデマンド集計・可視化」を実証するサンプルです。インデックス設計、TZ/週起点の一貫性、再計算の境界を見極めれば、クライアント単独でも複合的なダッシュボードは成立するとわかりました。
KPI や可視化は容易に増やせるため、プロダクトの成長とともに段階的に指標を育てる運用が可能です。

2
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
2
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?