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 を採用。pageExtensions
にmd/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
): 型は厳しめに。パスエイリアスは@
→src
(vitest.config.ts
/tsconfig.json
)。 - Cloudflare Workers へのデプロイ: OpenNext(
@opennextjs/cloudflare@1.6.x
)でビルド/デプロイ(npm run deploy/preview
)。開発中はinitOpenNextCloudflareForDev()
を呼び出します。wrangler@4.x
とcloudflare-env.d.ts
で環境変数も型安全に扱えます。
- Next.js 15(
-
UI/スタイル
- Tailwind CSS 4(
tailwindcss@4.1.x
):darkMode: 'class'
。Markdown 用の Typography 設定をtheme.extend.typography
で用意。tailwind-merge
(ユーティリティ競合の解消)とclass-variance-authority
(バリアント設計)も併用しています。 - テーマ/ダークモード:
next-themes
で OS/ユーザーの選択に連動(.prose
はprose-invert
に対応)。 - UI プリミティブ: Radix UI(
@radix-ui/react-*
)と Headless UI(@headlessui/react
)。アイコンはlucide-react
。 - アニメーション: 必要に応じて
tw-animate-css
を利用。
- Tailwind CSS 4(
-
コンテンツ/MDX/Markdown
- MDX v3:
@next/mdx
+@mdx-js/*
。next.config.ts
でasset/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
。
- MDX v3:
-
状態管理/データアクセス
- 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
。
- IndexedDB:
-
日付/時刻/タイムゾーン
-
dayjs
+timezone
+isoWeek
: 週の起点や日付境界を一貫管理。chartjs-adapter-dayjs-4
は Chart.js 側の表示/TZ を合わせるために使用。 -
date-fns
: 期間の整形など一部で補助的に使用。
-
-
可視化
- Recharts(
recharts@2
): 時系列の積み上げエリアや KPI 用のチャートに使用。 - Chart.js(
chart.js@4
)+react-chartjs-2
: 円弧ゲージやミニチャートなど、必要な箇所で使用。
- Recharts(
-
バリデーション/スキーマ
-
zod@4
: DB に保存するオブジェクト(たとえばRawSession
)のスキーマと安全なパースに使用。 -
tsafe
/schema-dts
: 型の補助と、構造化データ(JSON-LD)を型安全に扱うために使用。
-
-
PWA/サービスワーカー
-
src/sw.js
:install/activate
でskipWaiting
とclients.claim
を呼ぶ、必要最小限の実装。ビルド時にpublic/sw.js
へコピー(npm run build-sw
)。現時点ではプリキャッシュなし(workbox-cli
は将来拡張用)。
-
-
テスト/品質
- Unit: Vitest(
vitest@3
、jsdom
)。@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 + 型チェック/テスト」を必須にしています。
- Unit: Vitest(
-
ビルド/設定
-
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: EventTarget
が change
を発火。読み取り側は 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
は次のセクションで構成します。
- ヘッダー + 期間タブ(RangeTabs)+ KPI(SummaryCards)
- 時系列チャート(TimelineChart)+ 指標カード(BreakRatio/AvgSession)
- タスク内訳(TaskBreakdownCard)
- 52 週 Heatmap
- Activity Timeline(セッション一覧)
- フッター(最終更新)
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 を意識した配色
-
TimelineChart
にsr-only
テーブルを併設(数値は単位とともに) - 週の起点・日付フォーマットは ISO/ローカル TZ で統一し、表記ブレを排除
パフォーマンスの工夫
- 期間取得は
startEpochMs
インデックスを範囲指定(IDBKeyRange.bound
)で O(log n) - バケット化は「重複区間長」のみを加算し、再計算は
useMemo
で最小化 - Heatmap は 52×7 セルを一次配列で生成 → CSS Grid に投影(Githubの草と同じ仕様を目指した)
まとめ
FlowTimeは「保存時ロールアップに頼らず、フロントだけでオンデマンド集計・可視化」を実証するサンプルです。インデックス設計、TZ/週起点の一貫性、再計算の境界を見極めれば、クライアント単独でも複合的なダッシュボードは成立するとわかりました。
KPI や可視化は容易に増やせるため、プロダクトの成長とともに段階的に指標を育てる運用が可能です。