@insession/extension-pomodoro
依存ゼロの、サーバーを正とするポモドーロタイマー状態機械。
共有のポモドーロタイマー(「部屋のみんなが同じ時計を見ている」)は、微妙に間違えやすいものです。 カウントダウンを持つ側は、フェーズがいつ終わるかを全員と一致させ、サーバー再起動を挟んでもゴミの値から 偽のカウントダウンを再開せずに済ませ、しかも reducer をデータベースクライアントに変えてしまうことなく 「このセッションで自分が何をやるか」の宣言と互いへの声援を扱えなければなりません。
このパッケージは、その配管を全部取り除いた状態機械そのものです:
- 時計はサーバーが持ち、クライアントは刻まない。 動作中の state は減っていくカウンターではなく
endsAt(壁時計の epoch ミリ秒)を持ちます。クライアントはendsAtからカウントダウンを描けば よく、毎秒ブロードキャストする必要はありません。 reduceは純関数。(state, action, payload) => { state, effects } | null。I/O をせず、内部で タイマーを張ることもありません。effect は実行せず記述して返すだけで、nullは「このアクションを 無視する」という意味です(例: 停止中のpause)。- 宣言と声援が組み込み。 各メンバーは「いま何をやっているか」を一行で宣言でき、他人の宣言に対する 声援をトグルできます。スコープも長さの上限もこちら側で面倒を見ます。
restoreは設計として防御的。 ストレージ層が返してきたものを — 壊れた JSON であっても — そのまま渡せば、常に停止した安全な state が返ります。宣言数・声援数には上限が掛かります。- ランタイム依存ゼロ。 ただのオブジェクトに対する純関数の集まりです。パッケージ全体で唯一
「純粋でない」ものは
Date.now()で、時計を動かすアクション(start/pause/skip)とtimerDelay/onTimerだけが読みます。restoreとpersistStateは一切触らないので、保存済みの state の再生は完全に決定論的です。
インストール
Section titled “インストール”npm install @insession/extension-pomodoroESM(dist/index.js)と CommonJS(dist/index.cjs)の両方のエントリポイントに dist/index.d.ts の型を
添えたビルド済みパッケージとして配布され、ランタイム依存はありません。
スペースに載せる
Section titled “スペースに載せる”@insession/space でスペースを組み立てているなら、組み込みは1行です。
extension が自分の名前・reducer・タイマー・永続化の規則をまとめて持っています。
import { createSpace } from '@insession/space';import { pomodoroExtension } from '@insession/extension-pomodoro';
const space = createSpace({ extensions: [pomodoroExtension()] });
space.dispatch('pomodoro', 'start'); // -> [broadcast, schedule-timer]{ name } を渡せば別のキーを占有できます(独立したタイマーを2つ動かす、など)。
このオブジェクトを作るのに @insession/space から何も import していません。あちらの
SpaceExtension を構造的に満たしているだけなので、このパッケージは依存ゼロのままで、
以下の使い方も @insession/space 無しでそのまま通用します。
import { defaultState, onTimer, persistState, reduce, restore, timerDelay, type PomodoroEffect, type PomodoroState,} from '@insession/extension-pomodoro';
// ルームごとに PomodoroState を1つ持つ場所。例えば Map<roomId, PomodoroState>。let state: PomodoroState = defaultState();
// クライアントのアクションが transport(WebSocket 等)経由で届く。`by` は操作したメンバーを// 指し、それをどう導くか(セッション・認証…)はあなたの判断。function onClientAction(action: string, payload: unknown) { const result = reduce(state, action, payload as Record<string, unknown>); if (!result) return; // 無効、または no-op — 何も変わっていないので配信するものも無い state = result.state; for (const effect of result.effects) runEffect(effect); // 下記「Effect」を参照 broadcastToRoom({ type: 'extension-pomodoro', state }); schedulePhaseTimer();}
// フェーズ遷移は自分のタイマー(setTimeout、ジョブキュー…)で駆動する。let phaseTimer: ReturnType<typeof setTimeout> | undefined;function schedulePhaseTimer() { clearTimeout(phaseTimer); const delay = timerDelay(state); if (delay === null) return; // 動作中でない — 張るものは無い phaseTimer = setTimeout(() => { state = onTimer(state).state; // ここでの effects は常に空 broadcastToRoom({ type: 'extension-pomodoro', state }); schedulePhaseTimer(); }, delay);}
// ルーム起動時・最初の入室時にストレージから読む。function loadFromDb(raw: unknown) { state = restore(raw) ?? defaultState();}
// ストレージへ書く前に、セッション限りの participants を落とす。function saveToDb() { db.write(persistState(state));}reduce(state, action, payload) は次の action 文字列を受け付けます:
| アクション | ペイロード | 効果 |
|---|---|---|
start | — | remaining からタイマーを開始する。既に動作中なら no-op(null)。 |
pause | — | タイマーを止め、remaining を凍結する。既に停止中なら no-op。 |
reset | — | フェーズ・サイクル・タイマーを初期化する。ただし config・declarations・participants は保持する。 |
skip | — | 完了サイクルとして数えずに、次のフェーズへ即座に進める。 |
configure | { workMinutes?, breakMinutes? } | フェーズの長さを設定する(1〜120分にクランプ)。停止中のみ。数値に変換できる値(null・''・false・[] はいずれも 0 に変換される)は現在の config へフォールバックせず1分にクランプされる。有限の数値に変換できない値(例: 'nope'・undefined)だけがフォールバックする。 |
declare | { by, text?, uid? } | by の一行宣言を設定する(text が空なら解除)。 |
cheer | { target, by } | target の宣言に対する by の声援をトグルする。自分への声援や、未宣言の相手への声援は no-op。 |
join | { by, uid? } | by をこのセッションの参加者として記録する。 |
leave | { by } | by をセッションの参加者から外す。 |
これ以外の action 文字列はすべて null を返します。ペイロードはワイヤ越しに届くため、あらゆる
フィールドは信用できないものとして使う直前に検証されます — reduce は不正な入力で例外を投げず、
代わりに null を返します。
| エクスポート | シグネチャ | 意味 |
|---|---|---|
defaultState() | () => PomodoroState | 宣言も参加者も無い、停止状態の 25分/5分 の新しい state。 |
reduce | (state, action, payload?) => { state, effects } | null | アクションを1つ適用する。null は「無視する」(無効または no-op)。 |
timerDelay | (state) => number | null | 現在のフェーズが終わるまでのミリ秒。動作中でなければ null。 |
onTimer | (state) => { state, effects } | timerDelay が経過したときに呼ぶ。フェーズを進め、動作を継続する。effects は常に空 — フェーズ遷移は declarations に触れない。 |
restore | (raw: unknown) => PomodoroState | null | ストレージから読んだ state を正規化する。null になるのはオブジェクトでない入力のときだけで、それ以外は常に停止状態で上限が適用される。 |
persistState | (state) => PomodoroState | ストレージへ書く前に participants を落とす(セッション限りのため)。 |
Effects
Section titled “Effects”このパッケージの中で、セッションを跨いで残す価値があるのは declarations だけです。
メンバーが書いた一行宣言は再入室したときに復元されるべきなので、メンバーと space をキーにした
あなたのストレージに属します。どのメンバーが何に変えたかは遷移だけが知っていることなので、
reduce がそれを言い、あなたが書き込みを実行します。
| Effect | いつ |
|---|---|
{ type: 'persist-declaration', uid, text } | サインイン済みのメンバーが宣言した、またはテキストを変更した。 |
{ type: 'delete-declaration', uid } | サインイン済みのメンバーが宣言を消した。 |
// 上の「使い方」で参照している `runEffect`。function runEffect(effect: PomodoroEffect) { if (effect.type === 'persist-declaration') db.upsert(spaceId, effect.uid, effect.text); else db.delete(spaceId, effect.uid);}ゲストは effect を出しません。 ゲストにはストレージをキーにできるアカウントが無いので、 その宣言は state にしか存在しません — 設計上そうなっています。cheer も同様に出しません (保存対象外のため)。
PomodoroState・PomodoroPhase・PomodoroConfig・PomodoroDeclaration・PomodoroParticipant・
PomodoroAction・PomodoroPayload はすべてエクスポートされています。reduce の action 引数が
PomodoroAction ではなく string なのは意図的です — ここはアクション名が信用できない入力として
届くワイヤ境界であり、既知の集合から外れたものはすべて null に落ちます。
なぜ participants を永続化しないのか
Section titled “なぜ participants を永続化しないのか”state.participants が答えるのは「いまこのセッションに誰がいるか」で、これは人が実際に接続している
間だけ意味を持つ signal です。restore は常に空で返し、persistState は書き込み前に落とすので、
古くなった在室リストが再起動を生き延びたり、読まれないままストレージに残り続けたりしません。
なぜ declarations は reset / skip を生き延びるのか
Section titled “なぜ declarations は reset / skip を生き延びるのか”宣言は「このセッションで自分が何をやるか」であって、フェーズごとの state ではありません。reset は
タイマーを初期化するのであって、意図をリセットするわけではないからです。restore が保持するのは
uid を持つ宣言だけで、ゲストの宣言は reduce に渡すメモリ上の state にしか存在せず、リロード時に
意図的に捨てられます。
node --testすべてのテストは、時間に依存しない入力を使うか、その間 Date.now() を固定するかのどちらかなので、
スイート全体が完全に決定論的です — 実時計も、実時間の待ちもありません。
MIT