Skip to the content.

0005. Assignment のドラッグ / リサイズは +server.ts API + Optimistic UI + Last-write-wins

Context

UC-04 (アサインの期間を変更する) で、ResourceTimeline 上で帯をドラッグして移動 / リサイズしたら DB に永続化する必要がある。

選択肢:

transport UI 楽観ロック
(A) +server.ts API + optimistic + last-write-wins PATCH /api/assignments/[id] JSON 即時反映、失敗で revert + toast なし
(B) Form action + use:enhance ?/updateAssignment form data enhance で似たことが書けるが冗長 なし
(C) (A) + バージョン列で楽観ロック API + JSON + If-Match header 同時編集で 409 あり
(D) WebSocket / Server-sent events リアルタイム同期 別ユーザーの変更も即時反映 あり (operational transform)

ドメイン要件:

業界の Gantt / Calendar 慣例:

Decision

(A) を採用。

Transport: +server.tsPATCH /api/assignments/[id]

Body schema

assignmentApiUpdateSchema (web/src/lib/schemas/index.ts):

{
  prevStartDate: DateString,    // SK 再計算用 (旧 SK Delete に必要)
  resourceId: string,
  projectId: string,
  startDate: DateString,        // inclusive
  endDateExclusive: DateString  // exclusive (post-transform 形、ADR 0004)
}

フォームの assignmentCreateSchema と違って .transform()持たない。理由は ADR 0004 の「変換は フォーム境界の 1 箇所だけ」原則。API は app 内部 RPC (frontend → backend) なので、frontend 側で既に endDateExclusive を持っている (fromTimelineAssignment の戻り値)。再変換不要。

UI: Optimistic + Revert

async function handleUpdate(updated: TimelineAssignment) {
  const prev = dbAssignments.find(a => a.id === updated.id);
  if (!prev) return;
  const next = fromTimelineAssignment(updated, prev);

  const snapshot = dbAssignments;        // 1. snapshot
  dbAssignments = ...;                   // 2. 即時反映

  try {
    const res = await fetch(`/api/assignments/${next.id}`, { method: 'PATCH', ... });
    if (!res.ok) throw new Error(...);
  } catch (e) {
    dbAssignments = snapshot;            // 3. revert
    toast.error('保存に失敗しました');     // 4. notify
  }
}

UI 通知は svelte-sonnertoast.error()<Toaster />+layout.svelte に 1 個配置。

楽観ロックは未実装 (last-write-wins)

Consequences

Positive

Negative

Neutral

Alternatives considered

Implementation notes

実装 PR の主な変更:

References