diff --git a/docs/superpowers/specs/2026-06-23-timelog-draft-mode-design.md b/docs/superpowers/specs/2026-06-23-timelog-draft-mode-design.md new file mode 100644 index 0000000..3ba6a6b --- /dev/null +++ b/docs/superpowers/specs/2026-06-23-timelog-draft-mode-design.md @@ -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; // originId -> draft (modified/deleted) + added: DraftEntry[]; // originId === null +} +``` + +localStorage key is scoped per gitlab instance + user: +`gn-timelog-drafts::`. + +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. diff --git a/src/options.html b/src/options.html index 2708206..9db3f24 100644 --- a/src/options.html +++ b/src/options.html @@ -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 { @@ -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; @@ -1888,6 +2038,19 @@ +
+ + +
`; } else { - // Build a map of timelogs grouped by issue URL - const timelogsByIssue = new Map(); - for (const log of cachedTimelogs) { - const list = timelogsByIssue.get(log.issueUrl) || []; + // Build a map of timelogs grouped by issue (drafts overlaid). + const timelogsByIssue = new Map(); + for (const log of displayTimelogs) { + const list = timelogsByIssue.get(log.issueGid) || []; list.push(log); - timelogsByIssue.set(log.issueUrl, list); + timelogsByIssue.set(log.issueGid, list); } html += ``; @@ -672,7 +707,7 @@ function renderWeek(entries: WeeklyTimelog[], days: Date[], filterDate: string | `; // ── Sub-rows: individual timelogs for this issue ── - const issueLogs = timelogsByIssue.get(entry.issueUrl) || []; + const issueLogs = timelogsByIssue.get(entry.issueGid) || []; for (const log of issueLogs) { const logDateStr = getDateFromSpentAt(log.spentAt); const parts = logDateStr.split('-'); @@ -680,10 +715,15 @@ function renderWeek(entries: WeeklyTimelog[], days: Date[], filterDate: string | const dateLabel = formatDayDate(logDate); const inRange = !filterDate || logDateStr === filterDate; const dimClass = inRange ? '' : ' timelog-dim'; + const draftClass = log.draftStatus ? ` gn-draft-${log.draftStatus}` : ''; + const draftTag = log.draftStatus + ? `${log.draftStatus === 'new' ? 'new' : log.draftStatus === 'modified' ? 'edited' : 'del'}` + : ''; - html += ` + html += `
+ ${draftTag} ${log.note ? escapeHtml(log.note) : 'No description'} @@ -701,8 +741,7 @@ function renderWeek(entries: WeeklyTimelog[], days: Date[], filterDate: string | } // ── Add new timelog row ── - const firstLog = issueLogs[0]; - const issueGid = firstLog?.issueGid || ''; + const issueGid = entry.issueGid || issueLogs[0]?.issueGid || ''; if (issueGid) { html += `
@@ -727,7 +766,7 @@ function renderWeek(entries: WeeklyTimelog[], days: Date[], filterDate: string | } else { activeFilterDate = dateKey; } - renderWeek(cachedEntries, cachedDays, activeFilterDate); + renderCurrentView(); }); }); @@ -736,7 +775,7 @@ function renderWeek(entries: WeeklyTimelog[], days: Date[], filterDate: string | clearBtn.addEventListener('click', (e) => { e.stopPropagation(); activeFilterDate = null; - renderWeek(cachedEntries, cachedDays, null); + renderCurrentView(); }); } @@ -819,13 +858,14 @@ function renderWeek(entries: WeeklyTimelog[], days: Date[], filterDate: string | saveBtn.textContent = '...'; try { + // Preserve the original time-of-day; only the date may change here. const newDate = field === 'date' ? val : getDateFromSpentAt(log.spentAt); + const time = hhmmFromISO(log.spentAt); + const newSpentAt = time ? `${newDate}T${time}:00` : newDate; const newDuration = field === 'duration' ? val : formatDurationInput(log.timeSpent); const newNote = field === 'summary' ? val : log.note; - await createTimelog(log.issueGid, newDuration, newDate, newNote); - await deleteTimelog(log.id); - await loadWeek(); + await routeEdit(log, newDuration, newSpentAt, newNote); } catch (err: any) { alert(`Failed to save: ${err.message}`); cancel(); @@ -843,7 +883,7 @@ function renderWeek(entries: WeeklyTimelog[], days: Date[], filterDate: string | content.querySelectorAll('.timelog-summary-display').forEach((el) => { el.addEventListener('click', () => { const id = (el as HTMLElement).dataset.timelogId!; - const log = cachedTimelogs.find((t) => t.id === id); + const log = displayTimelogs.find((t) => t.id === id); if (log) startEdit(el as HTMLElement, log, 'summary'); }); }); @@ -851,7 +891,7 @@ function renderWeek(entries: WeeklyTimelog[], days: Date[], filterDate: string | content.querySelectorAll('.timelog-date-display').forEach((el) => { el.addEventListener('click', () => { const id = (el as HTMLElement).dataset.timelogId!; - const log = cachedTimelogs.find((t) => t.id === id); + const log = displayTimelogs.find((t) => t.id === id); if (log) startEdit(el as HTMLElement, log, 'date'); }); }); @@ -859,7 +899,7 @@ function renderWeek(entries: WeeklyTimelog[], days: Date[], filterDate: string | content.querySelectorAll('.timelog-duration-display').forEach((el) => { el.addEventListener('click', () => { const id = (el as HTMLElement).dataset.timelogId!; - const log = cachedTimelogs.find((t) => t.id === id); + const log = displayTimelogs.find((t) => t.id === id); if (log) startEdit(el as HTMLElement, log, 'duration'); }); }); @@ -869,12 +909,8 @@ function renderWeek(entries: WeeklyTimelog[], days: Date[], filterDate: string | btn.addEventListener('click', async (e) => { e.stopPropagation(); const id = (btn as HTMLElement).dataset.timelogId!; - const log = cachedTimelogs.find((t) => t.id === id); - if (!log) return; - const ok = await performMutation(async () => { - await createTimelog(log.issueGid, formatDurationInput(log.timeSpent), log.spentAt, log.note); - }); - if (ok) await loadWeek(); + const log = displayTimelogs.find((t) => t.id === id); + if (log) await routeDuplicate(log); }); }); @@ -882,20 +918,8 @@ function renderWeek(entries: WeeklyTimelog[], days: Date[], filterDate: string | btn.addEventListener('click', async (e) => { e.stopPropagation(); const id = (btn as HTMLElement).dataset.timelogId!; - const log = cachedTimelogs.find((t) => t.id === id); - if (!log || log.timeSpent < 120) return; // min 2 minutes to split - const firstHalf = Math.ceil(log.timeSpent / 2); - const secondHalf = log.timeSpent - firstHalf; - // Second entry starts after the first half - const startDate = new Date(log.spentAt); - const secondStart = new Date(startDate.getTime() + firstHalf * 1000); - const secondSpentAt = `${localDateStr(secondStart)}T${String(secondStart.getHours()).padStart(2, '0')}:${String(secondStart.getMinutes()).padStart(2, '0')}:00`; - const ok = await performMutation(async () => { - await createTimelog(log.issueGid, formatDurationInput(firstHalf), log.spentAt, log.note); - await createTimelog(log.issueGid, formatDurationInput(secondHalf), secondSpentAt, log.note); - await deleteTimelog(log.id); - }); - if (ok) await loadWeek(); + const log = displayTimelogs.find((t) => t.id === id); + if (log) await routeSplit(log); }); }); @@ -987,9 +1011,23 @@ function renderWeek(entries: WeeklyTimelog[], days: Date[], filterDate: string | durInput.disabled = true; const spentAt = timeVal ? `${date}T${timeVal}:00` : date; + const ref = displayTimelogs.find((t) => t.issueGid === issueGid); + const desired: DraftDesired = { + issueGid, + issueIid: ref?.issueIid ?? 0, + issueTitle: ref?.issueTitle ?? '', + issueUrl: ref?.issueUrl ?? '', + projectName: ref?.projectName ?? '', + projectId: ref?.projectId ?? '', + issueState: ref?.issueState ?? 'opened', + timeEstimate: ref?.timeEstimate ?? 0, + totalTimeSpent: ref?.totalTimeSpent ?? 0, + timeSpent: parseDurationToSeconds(duration), + spentAt, + note, + }; try { - await createTimelog(issueGid, duration, spentAt, note); - await loadWeek(); + await routeAdd(desired, duration); } catch (err: any) { alert(`Failed to add time log: ${err.message}`); cancel(); @@ -1017,7 +1055,7 @@ function escapeHtml(str: string): string { // ── Calendar Week View ── interface CalBlock { - log: TimelogDetail; + log: DisplayTimelog; startMinutes: number; endMinutes: number; top: number; @@ -1033,7 +1071,7 @@ function computeGridRange(_timelogs: TimelogDetail[]): { startHour: number; endH } function computeBlockPositions( - logs: TimelogDetail[], + logs: DisplayTimelog[], gridStartHour: number, gridEndHour: number ): CalBlock[] { @@ -1111,7 +1149,7 @@ function computeBlockPositions( return blocks; } -function renderCalendarWeek(days: Date[], timelogs: TimelogDetail[], entries: WeeklyTimelog[]) { +function renderCalendarWeek(days: Date[], timelogs: DisplayTimelog[], entries: WeeklyTimelog[]) { const content = $('weekContent'); // Compute dynamic grid range from data @@ -1119,7 +1157,7 @@ function renderCalendarWeek(days: Date[], timelogs: TimelogDetail[], entries: We const totalHeight = (gridEndHour - gridStartHour) * CAL_PX_PER_HOUR; // Group timelogs by date - const byDate = new Map(); + const byDate = new Map(); for (const log of timelogs) { const dateKey = getDateFromSpentAt(log.spentAt); const list = byDate.get(dateKey) || []; @@ -1146,7 +1184,10 @@ function renderCalendarWeek(days: Date[], timelogs: TimelogDetail[], entries: We const todayClass = isToday(d) ? ' cal-today' : ''; const weekendClass = !hideWeekends && i >= 5 ? ' cal-weekend' : ''; const dayLogs = byDate.get(dateKey) || []; - const dayTotal = dayLogs.reduce((sum, l) => sum + l.timeSpent, 0); + const dayTotal = dayLogs.reduce( + (sum, l) => sum + (l.draftStatus === 'deleted' ? 0 : l.timeSpent), + 0 + ); dayHeadersHtml += `
${DAY_NAMES[i]}
@@ -1159,11 +1200,16 @@ function renderCalendarWeek(days: Date[], timelogs: TimelogDetail[], entries: We let blocksHtml = ''; for (const block of blocks) { const color = getProjectColor(block.log.projectName); + const draftClass = block.log.draftStatus ? ` gn-draft-${block.log.draftStatus}` : ''; + const draftTag = block.log.draftStatus + ? `${block.log.draftStatus === 'new' ? 'new' : block.log.draftStatus === 'modified' ? 'edited' : 'del'}` + : ''; const tooltipText = `${block.log.issueTitle} — ${block.log.projectName}\n${formatDuration(block.log.timeSpent)}${block.log.note ? '\n' + block.log.note : ''}`; - blocksHtml += `
+ ${draftTag}
${formatDuration(block.log.timeSpent)}${block.height > 30 ? ` · ${escapeHtml(block.log.projectName)}` : ''}
${escapeHtml(block.log.issueTitle)}
${block.log.note && block.height > 40 ? `
${escapeHtml(block.log.note)}
` : ''} @@ -1201,7 +1247,10 @@ function renderCalendarWeek(days: Date[], timelogs: TimelogDetail[], entries: We }); // Week total - const weekTotal = timelogs.reduce((sum, l) => sum + l.timeSpent, 0); + const weekTotal = timelogs.reduce( + (sum, l) => sum + (l.draftStatus === 'deleted' ? 0 : l.timeSpent), + 0 + ); let html = `
@@ -1448,7 +1497,9 @@ function attachCalendarInteractions(container: HTMLElement, gridStartHour: numbe if (dragState.type === 'move') { const colRect = dragState.targetDayColumn.getBoundingClientRect(); - const newTop = snapToGrid(Math.max(0, ev.clientY - colRect.top - dragState.mouseOffsetInBlock)); + const newTop = snapToGrid( + Math.max(0, ev.clientY - colRect.top - dragState.mouseOffsetInBlock) + ); dragState.block.style.top = `${newTop}px`; dragState.block.classList.add('cal-dragging'); document.body.style.cursor = 'grabbing'; @@ -1497,7 +1548,7 @@ function attachCalendarInteractions(container: HTMLElement, gridStartHour: numbe justFinishedDrag = false; }, 0); - const log = cachedTimelogs.find((t) => t.id === state.logId); + const log = displayTimelogs.find((t) => t.id === state.logId); if (!log) return; if (state.type === 'move') { @@ -1508,60 +1559,19 @@ function attachCalendarInteractions(container: HTMLElement, gridStartHour: numbe const oldDate = getDateFromSpentAt(log.spentAt); const oldTime = parseTimeFromISO(log.spentAt); - const oldTimeStr = `${String(oldTime.hours).padStart(2, '0')}:${String(oldTime.minutes).padStart(2, '0')}`; + const oldTimeStr = `${pad2(oldTime.hours)}:${pad2(oldTime.minutes)}`; if (targetDate === oldDate && timeStr === oldTimeStr) return; - // Optimistic update: move the block in the DOM to the target column immediately - // and update cached data so re-render shows the block in its new position. - const oldSpentAt = log.spentAt; - log.spentAt = newSpentAt; + // Keep the dragged block opaque; routeEdit re-renders to its new spot. state.block.style.opacity = '1'; - - // If moved to a different day, relocate the DOM element - if (targetDate !== oldDate) { - state.targetDayColumn.appendChild(state.block); - } - - // Fire-and-forget: API call + background sync - performMutation(async () => { - await createTimelog( - log.issueGid, - formatDurationInput(log.timeSpent), - newSpentAt, - log.note - ); - await deleteTimelog(log.id); - }).then(async (ok) => { - if (!ok) { - // Revert optimistic update on failure - log.spentAt = oldSpentAt; - } - await silentRefresh(); - }); + await routeEdit(log, formatDurationInput(log.timeSpent), newSpentAt, log.note); } else { const newHeight = snapToGrid(parseFloat(state.block.style.height)); const newDuration = pxToDurationSeconds(newHeight); if (newDuration === log.timeSpent) return; - // Optimistic update: keep the resized height visible - const oldTimeSpent = log.timeSpent; - log.timeSpent = newDuration; state.block.style.opacity = '1'; - - performMutation(async () => { - await createTimelog( - log.issueGid, - formatDurationInput(newDuration), - log.spentAt, - log.note - ); - await deleteTimelog(log.id); - }).then(async (ok) => { - if (!ok) { - log.timeSpent = oldTimeSpent; - } - await silentRefresh(); - }); + await routeEdit(log, formatDurationInput(newDuration), log.spentAt, log.note); } }; @@ -1576,7 +1586,7 @@ function attachCalendarInteractions(container: HTMLElement, gridStartHour: numbe const e = ev as MouseEvent; e.stopPropagation(); const logId = (block as HTMLElement).dataset.timelogId!; - const log = cachedTimelogs.find((t) => t.id === logId); + const log = displayTimelogs.find((t) => t.id === logId); if (log) showEditPopover(e.clientX, e.clientY, log); }); }); @@ -1624,7 +1634,7 @@ function formatTimeFromISO(iso: string): string { return `${String(t.hours).padStart(2, '0')}:${String(t.minutes).padStart(2, '0')}`; } -function showEditPopover(x: number, y: number, log: TimelogDetail) { +function showEditPopover(x: number, y: number, log: DisplayTimelog) { closeAllPopovers(); const overlay = document.createElement('div'); @@ -1636,30 +1646,41 @@ function showEditPopover(x: number, y: number, log: TimelogDetail) { const stateLabel = log.issueState === 'closed' ? 'Closed' : 'Open'; const stateColor = log.issueState === 'closed' ? 'var(--red)' : 'var(--green, #22c55e)'; - const pctLogged = log.timeEstimate > 0 ? Math.round((log.totalTimeSpent / log.timeEstimate) * 100) : null; - const pctColor = pctLogged !== null ? (pctLogged > 100 ? 'var(--red)' : 'var(--green, #22c55e)') : 'var(--text-muted, #aaa)'; + const pctLogged = + log.timeEstimate > 0 ? Math.round((log.totalTimeSpent / log.timeEstimate) * 100) : null; + const pctColor = + pctLogged !== null + ? pctLogged > 100 + ? 'var(--red)' + : 'var(--green, #22c55e)' + : 'var(--text-muted, #aaa)'; // Collect all timelogs for the same issue, sorted by date const issueTimelogs = cachedTimelogs .filter((t) => t.issueGid === log.issueGid) .sort((a, b) => a.spentAt.localeCompare(b.spentAt)); - const issueLogsHtml = issueTimelogs.length > 1 - ? `
+ const issueLogsHtml = + issueTimelogs.length > 1 + ? `
- ${issueTimelogs.map((t) => { - const isCurrent = t.id === log.id; - const date = getDateFromSpentAt(t.spentAt); - const time = formatTimeFromISO(t.spentAt); - const noteSnippet = t.note ? ' — ' + escapeHtml(t.note.length > 30 ? t.note.slice(0, 30) + '…' : t.note) : ''; - return `
+ ${issueTimelogs + .map((t) => { + const isCurrent = t.id === log.id; + const date = getDateFromSpentAt(t.spentAt); + const time = formatTimeFromISO(t.spentAt); + const noteSnippet = t.note + ? ' — ' + escapeHtml(t.note.length > 30 ? t.note.slice(0, 30) + '…' : t.note) + : ''; + return `
${date.slice(5)} ${time} ${formatDuration(t.timeSpent)} ${noteSnippet}
`; - }).join('')} + }) + .join('')}
` - : ''; + : ''; const popover = document.createElement('div'); popover.className = 'cal-popover'; @@ -1724,37 +1745,18 @@ function showEditPopover(x: number, y: number, log: TimelogDetail) { popover.querySelector('#popDelete')!.addEventListener('click', async () => { if (!confirm('Delete this time log?')) return; closeAllPopovers(); - const ok = await performMutation(async () => { - await deleteTimelog(log.id); - }); - if (ok) await silentRefresh(); - else await silentRefresh(); + await routeDelete(log); }); popover.querySelector('#popDuplicate')!.addEventListener('click', async () => { closeAllPopovers(); - const ok = await performMutation(async () => { - await createTimelog(log.issueGid, formatDurationInput(log.timeSpent), log.spentAt, log.note); - }); - if (ok) await silentRefresh(); - else await silentRefresh(); + await routeDuplicate(log); }); popover.querySelector('#popSplit')!.addEventListener('click', async () => { if (log.timeSpent < 120) return; // min 2 minutes closeAllPopovers(); - const firstHalf = Math.ceil(log.timeSpent / 2); - const secondHalf = log.timeSpent - firstHalf; - const startDate = new Date(log.spentAt); - const secondStart = new Date(startDate.getTime() + firstHalf * 1000); - const secondSpentAt = `${localDateStr(secondStart)}T${String(secondStart.getHours()).padStart(2, '0')}:${String(secondStart.getMinutes()).padStart(2, '0')}:00`; - const ok = await performMutation(async () => { - await createTimelog(log.issueGid, formatDurationInput(firstHalf), log.spentAt, log.note); - await createTimelog(log.issueGid, formatDurationInput(secondHalf), secondSpentAt, log.note); - await deleteTimelog(log.id); - }); - if (ok) await silentRefresh(); - else await silentRefresh(); + await routeSplit(log); }); popover.querySelector('#popSave')!.addEventListener('click', async () => { @@ -1771,16 +1773,7 @@ function showEditPopover(x: number, y: number, log: TimelogDetail) { const spentAt = time ? `${date}T${time}:00` : date; closeAllPopovers(); - const ok = await performMutation(async () => { - await createTimelog(log.issueGid, duration, spentAt, note); - await deleteTimelog(log.id); - }); - if (ok) await silentRefresh(); - else { - saveBtn.disabled = false; - saveBtn.textContent = 'Save'; - await silentRefresh(); - } + await routeEdit(log, duration, spentAt, note); }); popover.querySelectorAll('input, textarea').forEach((el) => { @@ -1812,7 +1805,8 @@ async function searchAssignedIssues( gid: `gid://gitlab/Issue/${issue.id}`, title: issue.title, iid: issue.iid, - projectName: (issue.references?.full ?? '').split('#')[0].replace(/\/$/, '') || String(issue.project_id), + projectName: + (issue.references?.full ?? '').split('#')[0].replace(/\/$/, '') || String(issue.project_id), })); } catch { return []; @@ -1828,7 +1822,12 @@ function showAddPopover(x: number, y: number, date: string, time: string) { for (const log of [...cachedTimelogs].reverse()) { if (!seen.has(log.issueGid)) { seen.add(log.issueGid); - cachedIssues.push({ gid: log.issueGid, title: log.issueTitle, iid: log.issueIid, projectName: log.projectName }); + cachedIssues.push({ + gid: log.issueGid, + title: log.issueTitle, + iid: log.issueIid, + projectName: log.projectName, + }); } } @@ -1989,9 +1988,7 @@ function showAddPopover(x: number, y: number, date: string, time: string) { // If no explicit selection, try to match the typed text if (!issueGid) { const q = issueSearchInput.value.trim().toLowerCase(); - const match = allIssues.find( - (iss) => iss.title.toLowerCase() === q || `#${iss.iid}` === q - ); + const match = allIssues.find((iss) => iss.title.toLowerCase() === q || `#${iss.iid}` === q); if (match) { issueGid = match.gid; } else { @@ -2018,24 +2015,34 @@ function showAddPopover(x: number, y: number, date: string, time: string) { saveBtn.textContent = '...'; const spentAt = timeVal ? `${dateVal}T${timeVal}:00` : dateVal; + const issue = allIssues.find((i) => i.gid === issueGid); + const ref = displayTimelogs.find((t) => t.issueGid === issueGid); + const desired: DraftDesired = { + issueGid, + issueIid: issue?.iid ?? ref?.issueIid ?? 0, + issueTitle: issue?.title ?? ref?.issueTitle ?? '', + issueUrl: ref?.issueUrl ?? '', + projectName: issue?.projectName ?? ref?.projectName ?? '', + projectId: ref?.projectId ?? '', + issueState: ref?.issueState ?? 'opened', + timeEstimate: ref?.timeEstimate ?? 0, + totalTimeSpent: ref?.totalTimeSpent ?? 0, + timeSpent: parseDurationToSeconds(duration), + spentAt, + note, + }; closeAllPopovers(); - const ok = await performMutation(async () => { - await createTimelog(issueGid, duration, spentAt, note); - }); - if (ok) await silentRefresh(); - else { - saveBtn.disabled = false; - saveBtn.textContent = 'Save'; - await silentRefresh(); - } + await routeAdd(desired, duration); }); - popover.querySelectorAll('#popDuration, #popDate, #popTime, #popNote').forEach((input) => { - input.addEventListener('keydown', (ev) => { - if (ev.key === 'Escape') closeAllPopovers(); - if (ev.key === 'Enter') (popover.querySelector('#popSave') as HTMLElement).click(); + popover + .querySelectorAll('#popDuration, #popDate, #popTime, #popNote') + .forEach((input) => { + input.addEventListener('keydown', (ev) => { + if (ev.key === 'Escape') closeAllPopovers(); + if (ev.key === 'Enter') (popover.querySelector('#popSave') as HTMLElement).click(); + }); }); - }); issueSearchInput.focus(); } @@ -2109,7 +2116,7 @@ function getWeekOffsetForDate(date: Date): number { function renderCalendarMonth( days: Date[], targetMonth: number, - timelogs: TimelogDetail[], + timelogs: DisplayTimelog[], entries: WeeklyTimelog[] ) { const content = $('weekContent'); @@ -2117,8 +2124,11 @@ function renderCalendarMonth( // Group timelogs by date const dailyTotals = new Map(); const dailyProjects = new Map>(); + const draftDays = new Set(); for (const log of timelogs) { const dateKey = getDateFromSpentAt(log.spentAt); + if (log.draftStatus) draftDays.add(dateKey); + if (log.draftStatus === 'deleted') continue; // excluded from totals dailyTotals.set(dateKey, (dailyTotals.get(dateKey) || 0) + log.timeSpent); if (!dailyProjects.has(dateKey)) dailyProjects.set(dateKey, new Set()); dailyProjects.get(dateKey)!.add(log.projectName); @@ -2153,7 +2163,8 @@ function renderCalendarMonth( dotsHtml += `
`; } - html += `
+ const draftClass = draftDays.has(dateKey) ? ' gn-draft-day' : ''; + html += `
${d.getDate()}
${hours > 0 ? formatDuration(total) : ''}
${dotsHtml}
@@ -2183,7 +2194,7 @@ function renderCalendarMonth( } async function loadMonth() { - const { year, month, days, start, end } = getMonthDates(monthOffset); + const { year, month, start, end } = getMonthDates(monthOffset); const monthName = new Date(year, month, 1).toLocaleString('en-US', { month: 'long', year: 'numeric', @@ -2198,9 +2209,11 @@ async function loadMonth() { const result = await fetchWeekTimelogs(start, end); cachedEntries = result.entries; cachedTimelogs = result.timelogs; + rangeStartKey = localDateStr(start); + rangeEndKey = localDateStr(end); const monthTotal = result.timelogs.reduce((sum, l) => sum + l.timeSpent, 0); $('weekLabelTotal').textContent = formatDuration(monthTotal); - renderCalendarMonth(days, month, result.timelogs, result.entries); + renderCurrentView(); } catch (err: any) { content.innerHTML = `
${escapeHtml(err.message)}
`; } @@ -2233,23 +2246,22 @@ async function silentRefresh() { try { if (currentView === 'month') { - const { month, days, start, end } = getMonthDates(monthOffset); + const { start, end } = getMonthDates(monthOffset); const result = await fetchWeekTimelogs(start, end); cachedEntries = result.entries; cachedTimelogs = result.timelogs; - renderCalendarMonth(days, month, result.timelogs, result.entries); + rangeStartKey = localDateStr(start); + rangeEndKey = localDateStr(end); } else { const { start, end, days } = getWeekDates(weekOffset); cachedDays = days; const result = await fetchWeekTimelogs(start, end); cachedEntries = result.entries; cachedTimelogs = result.timelogs; - if (currentView === 'week') { - renderCalendarWeek(days, result.timelogs, result.entries); - } else { - renderWeek(cachedEntries, days, activeFilterDate); - } + rangeStartKey = localDateStr(start); + rangeEndKey = localDateStr(end); } + renderCurrentView(); } catch { // Silently ignore — user can manually refresh if needed } @@ -2275,6 +2287,389 @@ async function performMutation(fn: () => Promise): Promise { } } +// ── Draft-mode routing ── + +const pad2 = (n: number) => String(n).padStart(2, '0'); + +function hhmmFromISO(iso: string): string { + const i = iso.indexOf('T'); + return i === -1 ? '' : iso.slice(i + 1, i + 6); +} + +// True when a proposed edit leaves an instant-mode timelog unchanged — skip the +// create+delete entirely so no spurious "added/deleted" pair is produced. +function isNoOpEdit( + orig: TimelogDetail, + timeSpent: number, + spentAt: string, + note: string +): boolean { + return ( + orig.timeSpent === timeSpent && + getDateFromSpentAt(orig.spentAt) === getDateFromSpentAt(spentAt) && + hhmmFromISO(orig.spentAt) === hhmmFromISO(spentAt) && + (orig.note || '') === (note || '') + ); +} + +function displayToDesired(log: DisplayTimelog): DraftDesired { + return { + issueGid: log.issueGid, + issueIid: log.issueIid, + issueTitle: log.issueTitle, + issueUrl: log.issueUrl, + projectName: log.projectName, + projectId: log.projectId, + issueState: log.issueState, + timeEstimate: log.timeEstimate, + totalTimeSpent: log.totalTimeSpent, + timeSpent: log.timeSpent, + spentAt: log.spentAt, + note: log.note, + }; +} + +// Compute the effective (drafts overlaid) timelogs + breakdown for the current +// view. In instant mode this is just the fetched data. +function getDisplayData(): { timelogs: DisplayTimelog[]; entries: WeeklyTimelog[] } { + if (!drafts.isEnabled()) { + return { timelogs: cachedTimelogs as DisplayTimelog[], entries: cachedEntries }; + } + const eff = applyDrafts(cachedTimelogs, drafts.state); + const nonDeleted = eff.filter((e) => e.draftStatus !== 'deleted'); + const { entries } = aggregateTimelogs(nonDeleted, rangeStartKey, rangeEndKey); + return { timelogs: eff, entries }; +} + +// Re-render the current view from cache + drafts WITHOUT hitting the network. +function renderCurrentView(): void { + const { timelogs, entries } = getDisplayData(); + displayTimelogs = timelogs; + if (currentView === 'month') { + const { month, days } = getMonthDates(monthOffset); + renderCalendarMonth(days, month, timelogs, entries); + } else if (currentView === 'week') { + renderCalendarWeek(cachedDays, timelogs, entries); + } else { + renderWeek(entries, cachedDays, activeFilterDate); + } + updateDraftUI(); +} + +async function routeEdit( + log: DisplayTimelog, + durationStr: string, + spentAt: string, + note: string +): Promise { + const timeSpent = parseDurationToSeconds(durationStr); + if (drafts.isEnabled()) { + if (isDraftId(log.id)) { + drafts.editAdded(log.id, { timeSpent, spentAt, note }); + } else { + const orig = cachedTimelogs.find((t) => t.id === log.id); + if (orig) drafts.editOriginal(orig, { timeSpent, spentAt, note }); + } + renderCurrentView(); + return true; + } + const orig = cachedTimelogs.find((t) => t.id === log.id) || log; + if (isNoOpEdit(orig, timeSpent, spentAt, note)) return true; + const ok = await performMutation(async () => { + await createTimelog(log.issueGid, durationStr, spentAt, note); + await deleteTimelog(log.id); + }); + await silentRefresh(); + return ok; +} + +async function routeAdd(desired: DraftDesired, durationStr: string): Promise { + if (drafts.isEnabled()) { + drafts.addNew(desired); + renderCurrentView(); + return true; + } + const ok = await performMutation(async () => { + await createTimelog(desired.issueGid, durationStr, desired.spentAt, desired.note); + }); + await silentRefresh(); + return ok; +} + +async function routeDelete(log: DisplayTimelog): Promise { + if (drafts.isEnabled()) { + if (isDraftId(log.id)) drafts.deleteAdded(log.id); + else { + const orig = cachedTimelogs.find((t) => t.id === log.id); + if (orig) drafts.deleteOriginal(orig); + } + renderCurrentView(); + return true; + } + const ok = await performMutation(async () => { + await deleteTimelog(log.id); + }); + await silentRefresh(); + return ok; +} + +async function routeDuplicate(log: DisplayTimelog): Promise { + if (drafts.isEnabled()) { + drafts.addNew(displayToDesired(log)); + renderCurrentView(); + return true; + } + const ok = await performMutation(async () => { + await createTimelog(log.issueGid, formatDurationInput(log.timeSpent), log.spentAt, log.note); + }); + await silentRefresh(); + return ok; +} + +async function routeSplit(log: DisplayTimelog): Promise { + if (log.timeSpent < 120) return false; // min 2 minutes + const firstHalf = Math.ceil(log.timeSpent / 2); + const secondHalf = log.timeSpent - firstHalf; + const startDate = new Date(log.spentAt); + const secondStart = new Date(startDate.getTime() + firstHalf * 1000); + const secondSpentAt = `${localDateStr(secondStart)}T${pad2(secondStart.getHours())}:${pad2(secondStart.getMinutes())}:00`; + if (drafts.isEnabled()) { + const base = displayToDesired(log); + drafts.addNew({ ...base, timeSpent: firstHalf, spentAt: log.spentAt }); + drafts.addNew({ ...base, timeSpent: secondHalf, spentAt: secondSpentAt }); + if (isDraftId(log.id)) drafts.deleteAdded(log.id); + else { + const orig = cachedTimelogs.find((t) => t.id === log.id); + if (orig) drafts.deleteOriginal(orig); + } + renderCurrentView(); + return true; + } + const ok = await performMutation(async () => { + await createTimelog(log.issueGid, formatDurationInput(firstHalf), log.spentAt, log.note); + await createTimelog(log.issueGid, formatDurationInput(secondHalf), secondSpentAt, log.note); + await deleteTimelog(log.id); + }); + await silentRefresh(); + return ok; +} + +// ── Draft-mode UI: toggle, commit, preview, summary ── + +function initDraftControls(): void { + const toggle = document.getElementById('draftToggle') as HTMLInputElement | null; + const commitBtn = document.getElementById('draftCommitBtn'); + if (toggle) { + toggle.checked = drafts.isEnabled(); + toggle.addEventListener('change', async () => { + if (!toggle.checked && drafts.hasPending()) { + const choice = await confirmToggleOff(); + if (choice === 'cancel') { + toggle.checked = true; + return; + } + if (choice === 'commit') { + await showCommitPreview(); + // If the commit failed partway, stay in draft mode with the leftovers. + if (drafts.hasPending()) { + toggle.checked = true; + return; + } + } else if (choice === 'discard') { + drafts.discardAll(); + } + } + drafts.setEnabled(toggle.checked); + renderCurrentView(); + }); + } + if (commitBtn) commitBtn.addEventListener('click', () => showCommitPreview()); + updateDraftUI(); +} + +function updateDraftUI(): void { + const toggle = document.getElementById('draftToggle') as HTMLInputElement | null; + if (toggle) toggle.checked = drafts.isEnabled(); + const count = drafts.pendingCount(); + const commitBtn = document.getElementById('draftCommitBtn'); + if (commitBtn) { + commitBtn.style.display = drafts.isEnabled() && count > 0 ? '' : 'none'; + const c = document.getElementById('draftCount'); + if (c) c.textContent = count > 0 ? `(${count})` : ''; + } + document.body.classList.toggle('gn-draft-active', drafts.isEnabled()); +} + +// Generic centered modal. Returns the elements + a close fn. +function openModal(innerHtml: string): { + overlay: HTMLElement; + modal: HTMLElement; + close: () => void; +} { + const overlay = document.createElement('div'); + overlay.className = 'gn-modal-overlay'; + const modal = document.createElement('div'); + modal.className = 'gn-modal'; + modal.innerHTML = innerHtml; + overlay.appendChild(modal); + document.body.appendChild(overlay); + const close = () => overlay.remove(); + overlay.addEventListener('click', (e) => { + if (e.target === overlay) close(); + }); + return { overlay, modal, close }; +} + +function confirmToggleOff(): Promise<'commit' | 'discard' | 'cancel'> { + return new Promise((resolve) => { + const n = drafts.pendingCount(); + const { modal, close } = openModal(` +
Uncommitted changes
+
You have ${n} pending change${n === 1 ? '' : 's'} that have not been sent to GitLab.
+
+ + + +
+ `); + modal.querySelectorAll('[data-act]').forEach((b) => + b.addEventListener('click', () => { + close(); + resolve((b as HTMLElement).dataset.act as 'commit' | 'discard' | 'cancel'); + }) + ); + }); +} + +function describePlanItem(item: PlanItem): string { + const d = item.desired; + const when = `${getDateFromSpentAt(d.spentAt)} ${hhmmFromISO(d.spentAt) || ''}`.trim(); + const head = `#${d.issueIid} ${d.issueTitle}`; + if (item.kind === 'add') { + return `ADD ${formatDuration(d.timeSpent)} @ ${when} — ${escapeHtml(head)}`; + } + if (item.kind === 'delete') { + return `DELETE ${formatDuration(d.timeSpent)} @ ${when} — ${escapeHtml(head)}`; + } + // modify — show what changed + const o = item.original!; + const parts: string[] = []; + if (o.timeSpent !== d.timeSpent) + parts.push(`${formatDuration(o.timeSpent)} → ${formatDuration(d.timeSpent)}`); + const oWhen = `${getDateFromSpentAt(o.spentAt)} ${hhmmFromISO(o.spentAt)}`.trim(); + if (oWhen !== when) parts.push(`${oWhen} → ${when}`); + if ((o.note || '') !== (d.note || '')) parts.push(`note changed`); + return `EDIT ${parts.join(', ')} — ${escapeHtml(head)}`; +} + +async function showCommitPreview(): Promise { + const plan = buildPlan(drafts.state); + if (plan.length === 0) return; + const apiCalls = plan.reduce((n, p) => n + (p.kind === 'modify' ? 2 : 1), 0); + const rows = plan.map((p) => `
  • ${describePlanItem(p)}
  • `).join(''); + const { modal, close } = openModal(` +
    Commit changes
    +
      ${rows}
    +
    ${plan.length} change${plan.length === 1 ? '' : 's'} → ${apiCalls} API call${apiCalls === 1 ? '' : 's'}
    +
    + + +
    + `); + modal.querySelector('[data-act="cancel"]')!.addEventListener('click', close); + modal.querySelector('[data-act="confirm"]')!.addEventListener('click', async () => { + const btn = modal.querySelector('[data-act="confirm"]') as HTMLButtonElement; + btn.disabled = true; + btn.textContent = 'Committing…'; + const result = await commitDrafts(); + close(); + await silentRefresh(); + showCommitSummary(result); + }); +} + +interface CommitResult { + ok: number; + failed: { item: PlanItem; error: string }[]; + dupes: PlanItem[]; // created but old copy could not be deleted +} + +async function commitDrafts(): Promise { + const plan = buildPlan(drafts.state); + const result: CommitResult = { ok: 0, failed: [], dupes: [] }; + for (const item of plan) { + try { + if (item.kind === 'add') { + await createTimelog( + item.desired.issueGid, + formatDurationInput(item.desired.timeSpent), + item.desired.spentAt, + item.desired.note + ); + drafts.clear(item); + result.ok++; + } else if (item.kind === 'delete') { + await deleteTimelog(item.originId!); + drafts.clear(item); + result.ok++; + } else { + // modify: create the new entry first, then remove the old one. + await createTimelog( + item.desired.issueGid, + formatDurationInput(item.desired.timeSpent), + item.desired.spentAt, + item.desired.note + ); + try { + await deleteTimelog(item.originId!); + drafts.clear(item); + result.ok++; + } catch { + // New entry exists but the old one survives → duplicate. Clear the + // draft so a re-commit won't create yet another copy; flag it. + drafts.clear(item); + result.dupes.push(item); + } + } + } catch (err: any) { + result.failed.push({ item, error: err?.message || String(err) }); + } + } + return result; +} + +function showCommitSummary(r: CommitResult): void { + if (r.failed.length === 0 && r.dupes.length === 0) { + // Clean success — no modal needed. + return; + } + const dupeRows = r.dupes + .map( + (p) => + `
  • DUPLICATE ${escapeHtml( + `#${p.desired.issueIid} ${p.desired.issueTitle}` + )} — new entry created, old copy NOT removed. Delete the old one manually.
  • ` + ) + .join(''); + const failRows = r.failed + .map( + (f) => + `
  • FAILED ${escapeHtml( + `#${f.item.desired.issueIid} ${f.item.desired.issueTitle}` + )} — ${escapeHtml(f.error)} (still staged)
  • ` + ) + .join(''); + const { modal, close } = openModal(` +
    Commit summary
    +
    ${r.ok} change${r.ok === 1 ? '' : 's'} committed.
    +
      ${dupeRows}${failRows}
    +
    + +
    + `); + modal.querySelector('[data-act="ok"]')!.addEventListener('click', close); +} + async function loadWeek() { const { start, end, days } = getWeekDates(weekOffset); cachedDays = days; @@ -2289,13 +2684,11 @@ async function loadWeek() { const result = await fetchWeekTimelogs(start, end); cachedEntries = result.entries; cachedTimelogs = result.timelogs; + rangeStartKey = localDateStr(start); + rangeEndKey = localDateStr(end); const weekTotal = result.timelogs.reduce((sum, l) => sum + l.timeSpent, 0); $('weekLabelTotal').textContent = formatDuration(weekTotal); - if (currentView === 'week') { - renderCalendarWeek(days, result.timelogs, result.entries); - } else { - renderWeek(cachedEntries, days, null); - } + renderCurrentView(); } catch (err: any) { content.innerHTML = `
    ${escapeHtml(err.message)}
    `; } @@ -2753,6 +3146,10 @@ document.addEventListener('DOMContentLoaded', async () => { await loadSettings(); await detectGitlabUrl(); + // Draft mode: scope staged edits per gitlab instance + user. + drafts.init(`${gitlabUrl || 'default'}|${username || ''}`); + initDraftControls(); + // Load theme mode and custom colors currentThemeMode = await loadThemeMode(); applyThemeMode(currentThemeMode); diff --git a/src/utils/timelogDrafts.ts b/src/utils/timelogDrafts.ts new file mode 100644 index 0000000..0e3df7d --- /dev/null +++ b/src/utils/timelogDrafts.ts @@ -0,0 +1,335 @@ +/** + * Local "draft mode" for weekly-overview timelog edits. + * + * GitLab has no timelogUpdate mutation, so every edit is a create+delete pair. + * Draft mode stages all drag/add/edit/delete operations locally (localStorage) + * and collapses them to the minimal set of mutations at commit time: + * - a brand-new entry -> one create + * - a deleted original -> one delete + * - a modified original -> one create + one delete (the irreducible pair) + * - a no-op -> nothing + * + * The model is a *desired final state* per entry (not an operation log), so + * dragging an entry, changing its duration, then dragging it again collapses to + * a single diff against the original. Adding then deleting a new entry produces + * zero mutations. + * + * This module is DOM-free (except localStorage) and carries no formatting — the + * caller owns presentation. + */ + +export type DraftStatus = 'new' | 'modified' | 'deleted'; + +/** Minimal timelog shape the draft engine needs. Matches options.ts TimelogDetail. */ +export interface TimelogLike { + id: string; + issueIid: number; + issueTitle: string; + issueUrl: string; + issueGid: string; + projectName: string; + projectId: string; + note: string; + timeSpent: number; // seconds + spentAt: string; // full ISO datetime + issueState: string; + timeEstimate: number; // seconds + totalTimeSpent: number; // seconds +} + +/** The desired final state of an entry while staged. */ +export interface DraftDesired { + issueGid: string; + issueIid: number; + issueTitle: string; + issueUrl: string; + projectName: string; + projectId: string; + issueState: string; + timeEstimate: number; + totalTimeSpent: number; + timeSpent: number; // seconds + spentAt: string; + note: string; +} + +export interface DraftEntry { + draftId: string; // local id, e.g. "draft:3" + originId: string | null; // gid of original Timelog; null = newly added + deleted: boolean; // original marked for removal + desired: DraftDesired; // ignored when deleted + original?: { timeSpent: number; spentAt: string; note: string }; +} + +export interface DraftState { + enabled: boolean; + nextId: number; + byOrigin: Record; // originId -> draft (modified/deleted) + added: DraftEntry[]; // originId === null +} + +export interface PlanItem { + kind: 'add' | 'delete' | 'modify'; + draftId: string; + originId: string | null; + desired: DraftDesired; + original?: { timeSpent: number; spentAt: string; note: string }; +} + +const STORAGE_PREFIX = 'gn-timelog-drafts'; +const UNIT_SECONDS: Record = { + w: 5 * 8 * 3600, + d: 8 * 3600, + h: 3600, + m: 60, + s: 1, +}; + +/** Parse a GitLab-style duration ("1h30m", "2h", "45m", "1.5h", "1d") to seconds. */ +export function parseDurationToSeconds(input: string): number { + if (!input) return 0; + let total = 0; + let matched = false; + const re = /(\d+(?:\.\d+)?)\s*([wdhms])/gi; + let m: RegExpExecArray | null; + while ((m = re.exec(input)) !== null) { + matched = true; + total += parseFloat(m[1]) * UNIT_SECONDS[m[2].toLowerCase()]; + } + if (!matched) { + const n = parseFloat(input); + if (!isNaN(n)) total = n * 3600; // bare number = hours + } + return Math.round(total); +} + +export function isDraftId(id: string): boolean { + return id.startsWith('draft:'); +} + +/** Compare two ISO datetimes ignoring seconds and timezone suffix. */ +function sameInstant(a: string, b: string): boolean { + return normInstant(a) === normInstant(b); +} + +function normInstant(iso: string): string { + const date = iso.slice(0, 10); + const tIdx = iso.indexOf('T'); + if (tIdx === -1) return date; + return `${date}T${iso.slice(tIdx + 1, tIdx + 6)}`; // YYYY-MM-DDTHH:MM +} + +function detailToDesired(o: TimelogLike): DraftDesired { + return { + issueGid: o.issueGid, + issueIid: o.issueIid, + issueTitle: o.issueTitle, + issueUrl: o.issueUrl, + projectName: o.projectName, + projectId: o.projectId, + issueState: o.issueState, + timeEstimate: o.timeEstimate, + totalTimeSpent: o.totalTimeSpent, + timeSpent: o.timeSpent, + spentAt: o.spentAt, + note: o.note, + }; +} + +/** Field patch applied to a desired state. */ +export type DraftPatch = Partial>; + +export class DraftManager { + state: DraftState = { enabled: false, nextId: 1, byOrigin: {}, added: [] }; + private key = STORAGE_PREFIX; + + /** Bind to a localStorage key scoped per gitlab instance + user, and load. */ + init(scope: string): void { + this.key = `${STORAGE_PREFIX}:${scope}`; + const raw = localStorage.getItem(this.key); + if (!raw) return; + try { + const parsed = JSON.parse(raw); + this.state = { + enabled: !!parsed.enabled, + nextId: parsed.nextId || 1, + byOrigin: parsed.byOrigin || {}, + added: parsed.added || [], + }; + } catch { + // corrupt store — start fresh + } + } + + persist(): void { + localStorage.setItem(this.key, JSON.stringify(this.state)); + } + + isEnabled(): boolean { + return this.state.enabled; + } + + setEnabled(v: boolean): void { + this.state.enabled = v; + this.persist(); + } + + private genId(): string { + return `draft:${this.state.nextId++}`; + } + + private isModified(d: DraftEntry): boolean { + if (!d.original) return true; + return ( + d.original.timeSpent !== d.desired.timeSpent || + !sameInstant(d.original.spentAt, d.desired.spentAt) || + (d.original.note || '') !== (d.desired.note || '') + ); + } + + pendingCount(): number { + let n = this.state.added.length; + for (const id in this.state.byOrigin) { + const d = this.state.byOrigin[id]; + if (d.deleted || this.isModified(d)) n++; + } + return n; + } + + hasPending(): boolean { + return this.pendingCount() > 0; + } + + addNew(desired: DraftDesired): string { + const draftId = this.genId(); + this.state.added.push({ draftId, originId: null, deleted: false, desired }); + this.persist(); + return draftId; + } + + editAdded(draftId: string, patch: DraftPatch): void { + const d = this.state.added.find((a) => a.draftId === draftId); + if (!d) return; + Object.assign(d.desired, patch); + this.persist(); + } + + deleteAdded(draftId: string): void { + this.state.added = this.state.added.filter((a) => a.draftId !== draftId); + this.persist(); + } + + editOriginal(orig: TimelogLike, patch: DraftPatch): void { + let d = this.state.byOrigin[orig.id]; + if (!d) { + d = { + draftId: this.genId(), + originId: orig.id, + deleted: false, + desired: detailToDesired(orig), + original: { timeSpent: orig.timeSpent, spentAt: orig.spentAt, note: orig.note }, + }; + this.state.byOrigin[orig.id] = d; + } + d.deleted = false; + Object.assign(d.desired, patch); + // Reverted back to the original values -> drop the draft entirely. + if (!this.isModified(d)) delete this.state.byOrigin[orig.id]; + this.persist(); + } + + deleteOriginal(orig: TimelogLike): void { + const existing = this.state.byOrigin[orig.id]; + if (existing) { + existing.deleted = true; + } else { + this.state.byOrigin[orig.id] = { + draftId: this.genId(), + originId: orig.id, + deleted: true, + desired: detailToDesired(orig), + original: { timeSpent: orig.timeSpent, spentAt: orig.spentAt, note: orig.note }, + }; + } + this.persist(); + } + + /** Clear a single staged change after it commits successfully. */ + clear(item: PlanItem): void { + if (item.originId) delete this.state.byOrigin[item.originId]; + else this.state.added = this.state.added.filter((a) => a.draftId !== item.draftId); + this.persist(); + } + + discardAll(): void { + this.state.byOrigin = {}; + this.state.added = []; + this.persist(); + } +} + +/** + * Overlay drafts onto the fetched originals, tagging each result with its + * draftStatus. Deleted originals are kept (tagged) so callers can show them + * faded; callers exclude them from totals. + */ +export function applyDrafts( + originals: T[], + state: DraftState +): (T & { draftStatus?: DraftStatus })[] { + const out: (T & { draftStatus?: DraftStatus })[] = []; + for (const o of originals) { + const d = state.byOrigin[o.id]; + if (!d) { + out.push({ ...o }); + } else if (d.deleted) { + out.push({ ...o, draftStatus: 'deleted' }); + } else { + out.push({ + ...o, + timeSpent: d.desired.timeSpent, + spentAt: d.desired.spentAt, + note: d.desired.note, + draftStatus: 'modified', + }); + } + } + for (const a of state.added) { + out.push({ + id: a.draftId, + issueIid: a.desired.issueIid, + issueTitle: a.desired.issueTitle, + issueUrl: a.desired.issueUrl, + issueGid: a.desired.issueGid, + projectName: a.desired.projectName, + projectId: a.desired.projectId, + note: a.desired.note, + timeSpent: a.desired.timeSpent, + spentAt: a.desired.spentAt, + issueState: a.desired.issueState, + timeEstimate: a.desired.timeEstimate, + totalTimeSpent: a.desired.totalTimeSpent, + draftStatus: 'new', + } as T & { draftStatus?: DraftStatus }); + } + return out; +} + +/** Build the minimal mutation plan from the current draft state. */ +export function buildPlan(state: DraftState): PlanItem[] { + const items: PlanItem[] = []; + for (const a of state.added) { + items.push({ kind: 'add', draftId: a.draftId, originId: null, desired: a.desired }); + } + for (const id in state.byOrigin) { + const d = state.byOrigin[id]; + items.push({ + kind: d.deleted ? 'delete' : 'modify', + draftId: d.draftId, + originId: d.originId, + desired: d.desired, + original: d.original, + }); + } + return items; +}