Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
180 changes: 180 additions & 0 deletions docs/superpowers/specs/2026-06-23-timelog-draft-mode-design.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,180 @@
# Timelog Draft Mode + No-op Guards

## Problem

GitLab GraphQL has no `timelogUpdate` mutation. Every edit of a timelog is
implemented as `timelogCreate` (new) + `timelogDelete` (old). Each create emits a
system note "added Xh", each delete "deleted Xh". So:

1. **No-op churn:** Saving an edit without changing anything still runs
create+delete, producing a spurious "added Xh / deleted Xh" pair with
identical value+time. The popover Save (`options.ts:1760`) has no guard at
all; inline duration edit only checks for empty; drag-move has no guard.
2. **Edit churn:** Dragging an entry, changing its duration, then dragging again
produces three create+delete pairs instead of one.

## Goals

- **No-op guards** on every instant-commit path so unchanged saves do nothing.
- **Draft mode** (toggle): stage drag/drop/add/edit/delete locally
(localStorage, survives refresh/restart), then **Commit** all at once with the
minimal number of mutations — one logical change = one create+delete at most,
regardless of how many intermediate edits were made.
- Commit must be **transactional-ish**: on partial failure, keep going and
surface a summary so nothing is silently lost.
- A **preview** before commit.
- Staged edits **visually distinct** in all views.

## Part A — No-op guards (draft OFF, instant mode)

On every instant-commit edit path, before running create+delete, compare the
resulting `{ timeSpent, spentAt, note }` against the original timelog. If all
three are equal, skip the mutation entirely (treat as a successful cancel).

Paths to guard:
- Popover Save — `options.ts:1760`
- Drag-move (calendar) — `options.ts:1526`, `1551`
- Inline duration/date/summary edit — `options.ts:805` (extend duration to
compare value, not just empty)

A shared helper `timelogUnchanged(original, next)` lives in the new draft module
and is reused by the diff engine.

## Part B — Draft mode

### Activation
A **toggle** in the dashboard toolbar. OFF (default) = current instant behavior
(plus Part A guards). ON = all edits stage locally. The toggle state and drafts
both persist in localStorage and survive refresh/restart. The toggle shows a
pending-count badge when there are uncommitted changes.

### State model — desired-state diff (not operation log)

New module `src/utils/timelogDrafts.ts`. Each draft tracks an entry's final
desired state plus a snapshot of its original:

```ts
interface DraftDesired {
issueGid: string;
issueIid: number;
issueTitle: string;
issueUrl: string;
projectName: string;
projectId: string;
timeSpent: number; // seconds
spentAt: string; // full ISO
note: string;
}

interface DraftEntry {
draftId: string; // local id (counter-based; no Math.random/Date)
originId: string | null; // gid of original Timelog; null = newly added
deleted: boolean; // original marked for removal
desired: DraftDesired; // ignored when deleted
original?: { // snapshot for diff + preview; absent for new
timeSpent: number;
spentAt: string;
note: string;
};
}

interface DraftStore {
enabled: boolean;
byOrigin: Record<string, DraftEntry>; // originId -> draft (modified/deleted)
added: DraftEntry[]; // originId === null
}
```

localStorage key is scoped per gitlab instance + user:
`gn-timelog-drafts:<gitlabUrl>:<username>`.

Rationale for desired-state over op-log: drag → edit → drag collapses to one diff
vs the original; add-then-delete of a brand-new entry drops the draft entirely
(zero mutations); a no-op is skipped for free.

### Edit routing

Every edit entry point checks `store.enabled`:
- ON → mutate the draft store and re-render. No network.
- OFF → existing instant path (with Part A guards).

Entry points: popover save, add popover, drag-move, inline edits, delete.

Operations on the store:
- **add(desired)** → push to `added`.
- **edit(target, patch)** → if target is an original, upsert into `byOrigin`
with merged desired + original snapshot; if target is an added draft, mutate it
in place. If an edit makes a `byOrigin` draft equal to its original, drop the
draft.
- **remove(target)** → if original, set `deleted` in `byOrigin`; if added draft,
splice it out (zero mutations).

### Rendering effective state

`applyDrafts(cachedTimelogs, store)` returns a list of
`TimelogDetail & { draftStatus?: 'new'|'modified'|'deleted' }`:
- original with no draft → unchanged
- `byOrigin` modified → replaced by desired, tagged `modified`
- `byOrigin` deleted → tagged `deleted` (kept in list for display, excluded from
time totals)
- `added` → appended, tagged `new`

Render functions (`renderWeek`, `renderCalendarWeek`, `renderCalendarMonth`)
consume this list and apply per-status styling. Deleted entries are faded +
struck through; new = dashed accent border + tag; modified = accent tint + tag.

### Commit

`buildPlan(store)` produces a list of logical changes, each with its API ops:

| Draft | Ops |
|-----------------|-----------------------------|
| new | create |
| original deleted| delete |
| original modified (differs) | create new, then delete old |
| unchanged | skipped |

Order is **create-then-delete** so a mid-failure leaves a recoverable duplicate,
never data loss.

Commit runs logical changes **sequentially**. Per change: on full success, clear
that draft; on any failure, keep the draft staged and record the error. If a
"create new" succeeds but "delete old" fails, flag it as a possible duplicate.
After the batch, refetch (silentRefresh) and show a **summary modal**:
succeeded / failed / possible-duplicate lists.

### Preview modal (on Commit click)

Lists every pending change grouped by issue, e.g.
`ADD 2h @ Mon 09:00`, `MOVE 1h Tue 09:00 → Wed 14:00`, `EDIT 2h → 3h`,
`DELETE 4h @ Tue`. Footer: `N changes → M API calls`. Confirm / Cancel.

### Toggle OFF with pending changes

Prompt with three choices: **Commit now** / **Discard drafts** / **Cancel**
(stay in draft mode).

## Files

- **New** `src/utils/timelogDrafts.ts` — store, persistence, `applyDrafts`,
`buildPlan`, `timelogUnchanged`, mutation helpers. Pure/DOM-free except
localStorage.
- `src/options.ts` — toggle UI, edit routing, render tagging, commit + preview +
summary modals, toggle-off guard, Part A guards.
- `src/options.html` / options CSS — toggle control, draft styling, modals.

## Constraints

- No `Math.random()` / `Date.now()` reliance in draft IDs that must survive
reload — use a persisted incrementing counter.
- Drafts referencing an `originId` no longer present after refetch (deleted
elsewhere) are flagged as conflicts in the preview; their delete will fail at
commit and be surfaced.

## Verification

No unit-test harness in repo. Verify via `npm run check`
(type-check + lint + format:check + build) plus manual exercise of: toggle,
stage add/edit/delete/drag, refresh-persistence, preview, commit, partial-fail
summary, toggle-off prompt, and that draft-OFF no-op saves produce no GitLab
mutation.
167 changes: 165 additions & 2 deletions src/options.html
Original file line number Diff line number Diff line change
Expand Up @@ -796,9 +796,9 @@
line-height: 1.4;
}
.timelog-action-btn:hover {
background: rgba(255,255,255,0.08);
background: rgba(255, 255, 255, 0.08);
color: var(--text-secondary);
border-color: rgba(255,255,255,0.1);
border-color: rgba(255, 255, 255, 0.1);
}
.timelog-field-display,
.timelog-summary-display {
Expand Down Expand Up @@ -962,6 +962,156 @@
color: var(--accent);
}

/* ── Draft mode ── */
.draft-controls {
display: flex;
align-items: center;
gap: 10px;
}
.draft-toggle {
display: flex;
align-items: center;
gap: 6px;
font-size: 12px;
font-weight: 600;
color: var(--text-secondary);
cursor: pointer;
user-select: none;
}
.draft-toggle input {
accent-color: var(--accent);
cursor: pointer;
}
.draft-commit-btn {
padding: 5px 14px;
font-size: 12px;
}
/* Tags shown on staged entries */
.gn-draft-tag {
display: inline-block;
font-size: 9px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.04em;
padding: 1px 5px;
border-radius: 4px;
margin-right: 4px;
vertical-align: middle;
}
.gn-draft-tag-new {
background: rgba(34, 197, 94, 0.2);
color: #22c55e;
}
.gn-draft-tag-modified {
background: rgba(245, 158, 11, 0.2);
color: #f59e0b;
}
.gn-draft-tag-deleted {
background: rgba(248, 113, 113, 0.2);
color: #f87171;
}
/* Calendar blocks + list rows by draft status */
.cal-block.gn-draft-new {
outline: 2px dashed #22c55e;
outline-offset: -2px;
}
.cal-block.gn-draft-modified {
outline: 2px dashed #f59e0b;
outline-offset: -2px;
}
.cal-block.gn-draft-deleted,
.timelog-row.gn-draft-deleted {
opacity: 0.45;
text-decoration: line-through;
}
.timelog-row.gn-draft-new {
background: rgba(34, 197, 94, 0.07);
}
.timelog-row.gn-draft-modified {
background: rgba(245, 158, 11, 0.07);
}
.cal-month-cell.gn-draft-day::after {
content: '';
position: absolute;
top: 4px;
right: 4px;
width: 6px;
height: 6px;
border-radius: 50%;
background: var(--accent);
}
.cal-month-cell {
position: relative;
}

/* ── Modal (commit preview / summary / confirm) ── */
.gn-modal-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.55);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
}
.gn-modal {
background: var(--bg-card, #1a1d2b);
border: 1px solid var(--border);
border-radius: 12px;
padding: 20px;
width: min(560px, 92vw);
max-height: 82vh;
overflow-y: auto;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.5);
}
.gn-modal-title {
font-size: 16px;
font-weight: 700;
margin-bottom: 12px;
}
.gn-modal-body {
font-size: 13px;
color: var(--text-secondary);
margin-bottom: 12px;
}
.gn-plan-list {
list-style: none;
margin: 0 0 12px;
padding: 0;
display: flex;
flex-direction: column;
gap: 6px;
}
.gn-plan-row {
font-size: 12px;
line-height: 1.5;
padding: 6px 10px;
border-radius: 6px;
background: rgba(255, 255, 255, 0.04);
}
.gn-plan-row .gn-tag-new {
color: #22c55e;
}
.gn-plan-row .gn-tag-mod {
color: #f59e0b;
}
.gn-plan-row .gn-tag-del {
color: #f87171;
}
.gn-plan-row .gn-tag-warn {
color: #f59e0b;
}
.gn-modal-foot {
font-size: 12px;
color: var(--text-muted, #aaa);
margin-bottom: 14px;
}
.gn-modal-actions {
display: flex;
justify-content: flex-end;
gap: 8px;
}

/* ── Calendar Grid ── */
.cal-week-total {
display: flex;
Expand Down Expand Up @@ -1888,6 +2038,19 @@
<button class="view-toggle-btn" data-view="week">Week</button>
<button class="view-toggle-btn" data-view="month">Month</button>
</div>
<div class="draft-controls">
<label class="draft-toggle" title="Stage edits locally and commit them together">
<input type="checkbox" id="draftToggle" />
<span>Draft mode</span>
</label>
<button
class="btn-primary draft-commit-btn"
id="draftCommitBtn"
style="display: none"
>
Commit <span id="draftCount"></span>
</button>
</div>
<div class="week-nav">
<button class="week-nav-btn" id="weekPrev" title="Previous week">
<svg width="14" height="14" viewBox="0 0 16 16" fill="none">
Expand Down
Loading
Loading