From 1db708a928e31f776a656abddb81ab9ee7326ad0 Mon Sep 17 00:00:00 2001 From: Andreas Gassmann Date: Wed, 24 Jun 2026 11:04:25 +0200 Subject: [PATCH 01/20] feat: add default time to 09:30 --- src/features/editMode.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/features/editMode.ts b/src/features/editMode.ts index fa12433..441a010 100644 --- a/src/features/editMode.ts +++ b/src/features/editMode.ts @@ -353,7 +353,9 @@ export class EditModeFeature { `${String(now.getHours()).padStart(2, '0')}:${String(now.getMinutes()).padStart(2, '0')}` ); } else { - selectedTime = ''; + // Past-day presets have no meaningful "now" time — default to 09:30 + // so the log lands during work hours instead of at 00:00. + selectedTime = '09:30'; } timePicker.value = selectedTime; }); From eae73cdc577aec927c140102ca31b92f64f85a23 Mon Sep 17 00:00:00 2001 From: Andreas Gassmann Date: Wed, 24 Jun 2026 11:49:19 +0200 Subject: [PATCH 02/20] docs: spec for configurable work settings --- ...06-24-configurable-work-settings-design.md | 159 ++++++++++++++++++ 1 file changed, 159 insertions(+) create mode 100644 docs/superpowers/specs/2026-06-24-configurable-work-settings-design.md diff --git a/docs/superpowers/specs/2026-06-24-configurable-work-settings-design.md b/docs/superpowers/specs/2026-06-24-configurable-work-settings-design.md new file mode 100644 index 0000000..825b30c --- /dev/null +++ b/docs/superpowers/specs/2026-06-24-configurable-work-settings-design.md @@ -0,0 +1,159 @@ +# Configurable Work Settings — Design + +**Date:** 2026-06-24 +**Status:** Approved (design), pending implementation plan + +## Goal + +Promote seven currently-hardcoded constants to user-configurable settings, exposed in +a new "Work" tab on the options page. Defaults equal the current hardcoded values, so +existing users see **no behavior change** until they opt in. + +## Settings + +| # | Setting | Default | Drives | +|---|---------|---------|--------| +| 1 | `dayStartTime` | `"09:00"` | Default timelog time **and** calendar scroll position | +| 2 | `dailyTargetSeconds` | `30240` (8h 24m) | Board daily work target | +| 3 | `warningThreshold` | `0.8` | Estimate-spent "warning" status threshold | +| 4 | `weekendDays` | `[5, 6]` (Sat, Sun) | Which weekdays count as weekend | +| 5 | `timeIncrementMinutes` | `15` | Calendar drag snap **and** time-picker step | +| 6 | `hoursPerDay` | `8` | GitLab estimate unit conversion (`1d` = N hours) | +| 7 | `hoursPerWeek` | `40` | GitLab estimate unit conversion (`1w` = N hours) | + +### Resolved design decisions + +- **Day start merged.** Default timelog time (was `09:00`) and calendar scroll (was + `08:30`) collapse into one `dayStartTime`. The calendar scrolls to + `dayStartTime − 30 min` (preserves the prior 09:00 → 08:30 relationship). +- **Daily target stays separate** from notification `minHours`. `notificationSettings` + is left untouched. +- **Hours/day and hours/week are both exposed.** They affect how all estimate / spent + times render via `time.ts`. Matches GitLab's own configurable convention. +- **Weekend = set, hide = toggle.** The existing `hideWeekends` toggle is unchanged; it + now hides whatever days `weekendDays` defines. + +Out of scope (explicitly left hardcoded): debounce delays, DOM/alarm timeouts, render +math (min block height, grid range, header widths), 75% color thresholds, accuracy +ratios, effort tiers, due-date ranges, search limits. + +## Architecture + +### New module: `src/utils/workSettings.ts` + +Follows the existing `themeManager.ts` pattern (typed interface + `DEFAULT_*` + +async load / sync save) **plus** a synchronous in-memory cache, required because +`time.ts` functions are pure and synchronous. + +```ts +export interface WorkSettings { + dayStartTime: string; // "HH:MM" + dailyTargetSeconds: number; + warningThreshold: number; // 0..1 + weekendDays: number[]; // 0 = Mon … 6 = Sun + timeIncrementMinutes: number; + hoursPerDay: number; + hoursPerWeek: number; +} + +export const DEFAULT_WORK_SETTINGS: WorkSettings = { + dayStartTime: '09:00', + dailyTargetSeconds: 30240, + warningThreshold: 0.8, + weekendDays: [5, 6], + timeIncrementMinutes: 15, + hoursPerDay: 8, + hoursPerWeek: 40, +}; + +// async, merges stored partial over defaults (like loadCustomColors) +export async function loadWorkSettings(): Promise; +export function saveWorkSettings(s: WorkSettings): void; + +// sync cache for pure functions +export async function initWorkSettings(): Promise; // await once at startup; wires storage.onChanged +export function getWorkSettings(): WorkSettings; // returns cache, or DEFAULT_WORK_SETTINGS if not yet init +``` + +- Single storage key `workSettings` in `chrome.storage.sync`. +- `loadWorkSettings` merges `{ ...DEFAULT_WORK_SETTINGS, ...stored }` so partial / + future-versioned objects degrade safely. No migration needed (absent key → defaults + → current behavior). +- `initWorkSettings` populates the module cache and registers a + `chrome.storage.onChanged` listener that refreshes the cache on any `workSettings` + write. This makes options-page edits apply live (same UX as `themeManager`). +- `getWorkSettings` is the synchronous accessor. Contract: callers in pure/sync paths + rely on `initWorkSettings` having been awaited at startup; if not, they get + `DEFAULT_WORK_SETTINGS` (safe fallback, never throws). + +### Startup init + +`await initWorkSettings()` is added at the top of each entry point before feature code +runs: `background.ts`, the content-script entry (`content.ts`), `options.ts`, and +`popup.ts`. + +### Call-site changes + +| Setting | File:line (approx) | Change | +|---------|--------------------|--------| +| Day start (log) | `options.ts:414`, `options.ts:105/108` | use `getWorkSettings().dayStartTime` in place of literal `09:00` | +| Calendar scroll | `options.ts:1283` | scroll to `dayStartTime − 30 min` | +| Daily target | `boardSettings.ts:15` | `DAILY_TARGET_SECONDS` → `getWorkSettings().dailyTargetSeconds` | +| Warning % | `timeTracking.ts:125` | `0.8` → `getWorkSettings().warningThreshold` | +| Weekend days | `options.ts:461`, `options.ts:1186` | `=== 5 / >= 5` checks → `weekendDays.includes(d)` | +| Time increment | `options.ts:1446` (snap `900`s), `editMode.ts:251` (step `15`) | derive from `timeIncrementMinutes` (`× 60` for seconds) | +| Hours/day | `time.ts:22-23, 45-47` | `8` → `getWorkSettings().hoursPerDay` | +| Hours/week | `time.ts:20-21, 39-42` | `40` → `getWorkSettings().hoursPerWeek` | + +`time.ts` reads the cache synchronously via `getWorkSettings()` — no signature changes +to `parseTimeToHours` / `formatHours`, so the 20 call sites across `background.ts`, +`timeTracking.ts`, `columnSummary.ts`, `editMode.ts` are untouched. + +## UI — "Work" settings tab + +New tab in the options Settings card (`src/options.html`, `src/options.ts`), placed +after **Connection**: `data-settings-tab="work"`, label "Work". Reuses existing +`.notif-card` / `.form-input` / `.settings-tab` markup and styles. + +Controls: + +| Setting | Control | Notes | +|---------|---------|-------| +| Day start time | `` | | +| Daily work target | hours + minutes inputs → stored as seconds | | +| Estimate warning % | number input 50–100 → stored `/100` | | +| Weekend days | 7 day checkboxes (Mon–Sun) → `weekendDays` array | | +| Time increment | ` +
Default time for new logs; calendar scrolls here.
+ +
+ +
+ h + m +
+
+
+ + +
+
+ +
+
+
+ + +
+
+ + +
Affects how 1d estimates display.
+
+
+ + +
Affects how 1w estimates display.
+
+
+ + +
+ + +``` +(If `.form-hint` is not an existing class, reuse `.color-section-desc` instead — check with `grep -n "form-hint\|color-section-desc" src/options.html` and use whichever exists.) + +- [ ] **Step 3: Add the init function (options.ts)** + +Add import: +```ts +import { + loadWorkSettings, + saveWorkSettings, + DEFAULT_WORK_SETTINGS, + WorkSettings, +} from './utils/workSettings'; +``` +Add a new function (near `initNotificationSettings`): +```ts +const WS_DAY_LABELS = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun']; + +function readWorkForm(): WorkSettings { + const th = parseInt((document.getElementById('wsTargetH') as HTMLInputElement).value || '0', 10); + const tm = parseInt((document.getElementById('wsTargetM') as HTMLInputElement).value || '0', 10); + const weekendDays: number[] = []; + WS_DAY_LABELS.forEach((_, i) => { + const cb = document.getElementById(`wsWeekend-${i}`) as HTMLInputElement | null; + if (cb?.checked) weekendDays.push(i); + }); + return { + dayStartTime: (document.getElementById('wsDayStart') as HTMLInputElement).value || '09:00', + dailyTargetSeconds: th * 3600 + tm * 60, + warningThreshold: + parseInt((document.getElementById('wsWarn') as HTMLInputElement).value || '80', 10) / 100, + weekendDays, + timeIncrementMinutes: parseInt( + (document.getElementById('wsIncrement') as HTMLSelectElement).value, + 10 + ), + hoursPerDay: parseFloat((document.getElementById('wsHoursDay') as HTMLInputElement).value || '8'), + hoursPerWeek: parseFloat( + (document.getElementById('wsHoursWeek') as HTMLInputElement).value || '40' + ), + }; +} + +function populateWorkForm(s: WorkSettings): void { + (document.getElementById('wsDayStart') as HTMLInputElement).value = s.dayStartTime; + (document.getElementById('wsTargetH') as HTMLInputElement).value = String( + Math.floor(s.dailyTargetSeconds / 3600) + ); + (document.getElementById('wsTargetM') as HTMLInputElement).value = String( + Math.round((s.dailyTargetSeconds % 3600) / 60) + ); + (document.getElementById('wsWarn') as HTMLInputElement).value = String( + Math.round(s.warningThreshold * 100) + ); + (document.getElementById('wsIncrement') as HTMLSelectElement).value = String( + s.timeIncrementMinutes + ); + (document.getElementById('wsHoursDay') as HTMLInputElement).value = String(s.hoursPerDay); + (document.getElementById('wsHoursWeek') as HTMLInputElement).value = String(s.hoursPerWeek); + + const wrap = document.getElementById('wsWeekend')!; + wrap.innerHTML = WS_DAY_LABELS.map( + (label, i) => + `` + ).join(''); +} + +function initWorkSettingsForm(): void { + loadWorkSettings().then(populateWorkForm); + + const status = document.getElementById('wsSaveStatus'); + const onChange = () => { + saveWorkSettings(readWorkForm()); + if (status) { + status.textContent = 'Saved'; + setTimeout(() => (status.textContent = ''), 1500); + } + }; + + const panel = document.querySelector('[data-settings-tab-content="work"]'); + panel?.addEventListener('change', onChange); + + document.getElementById('wsResetBtn')?.addEventListener('click', () => { + populateWorkForm({ ...DEFAULT_WORK_SETTINGS }); + saveWorkSettings({ ...DEFAULT_WORK_SETTINGS }); + if (status) { + status.textContent = 'Reset'; + setTimeout(() => (status.textContent = ''), 1500); + } + }); +} +``` + +- [ ] **Step 4: Call the init in DOMContentLoaded** + +In the `DOMContentLoaded` async handler (after `initNotificationSettings()` is called), add: +```ts + initWorkSettingsForm(); +``` + +- [ ] **Step 5: Verify build + lint** + +Run: `npm run check` +Expected: type-check, lint, format:check, build all pass. (Run `npm run format` first if format:check fails.) + +- [ ] **Step 6: Commit** + +```bash +git add src/options.html src/options.ts +git commit -m "feat: Work settings tab in options UI" +``` + +--- + +## Task 11: Full verification + +**Files:** none (verification only) + +- [ ] **Step 1: Run the whole suite + checks** + +Run: `npm test && npm run check` +Expected: all tests pass; type-check, lint, format:check, build all pass. + +- [ ] **Step 2: Manual smoke test (load unpacked extension)** + +Run: `npm run build`, then load `dist/` as an unpacked extension in Chrome. Verify: +- Fresh profile (no `workSettings` key): every behavior matches today (default log time 09:00, calendar scrolls to 08:30, weekend = Sat/Sun, 15-min snap, `1d`=8h display, warning at 80%, board daily target 8h24m). +- Open options → **Work** tab. Change each field; confirm: + - Day start → new logs default to it; calendar scrolls to it −30 min. + - Daily target → board target reflects new value. + - Warning % → a card crossing the new threshold turns "warning". + - Weekend days → weekend styling moves to the selected days (and `hideWeekends` hides the new set). + - Time increment → calendar drag snaps to it; edit-mode picker steps by it. + - Hours/day & hours/week → `1d`/`1w` estimate displays reflow. +- Reload the options page: all values persist. +- **Reset to Defaults**: all fields return to defaults and persist. + +- [ ] **Step 3: Final commit (only if Step 2 surfaced fixes)** + +```bash +git add -A +git commit -m "fix: work settings verification follow-ups" +``` + +--- + +## Self-Review Notes + +- **Spec coverage:** all 7 settings have tasks (3,5,6,7,8,9 wire them; 10 exposes them); module + cache (Task 2), init (Task 4), test infra (Task 1), verification (Task 11). ✓ +- **No `notificationSettings` change** — honored (Task 4 only adds init; daily target kept separate). ✓ +- **Type consistency:** `WorkSettings` field names identical across Tasks 2/3/10; `getWorkSettings()`/`loadWorkSettings()`/`saveWorkSettings()` used consistently. ✓ +- **Weekday convention** Mon=0…Sun=6 consistent in Tasks 2, 8, 10. ✓ +- **Open assumption to confirm during execution:** Task 8 Step 3 — there may be a `visibleDays` filter elsewhere in options.ts that also encodes `>= 5`; the grep step catches it. From b7f2bb1355db95092915a13c103c4faabbcb161d Mon Sep 17 00:00:00 2001 From: Andreas Gassmann Date: Wed, 24 Jun 2026 13:00:14 +0200 Subject: [PATCH 04/20] feat: nagging notifications --- src/background.ts | 111 +++++++++++++++++++++++++++++++++++++++++++++- src/options.html | 52 ++++++++++++++++++++++ src/options.ts | 45 ++++++++++++++++++- 3 files changed, 205 insertions(+), 3 deletions(-) diff --git a/src/background.ts b/src/background.ts index abe60c5..2d93a60 100644 --- a/src/background.ts +++ b/src/background.ts @@ -15,6 +15,13 @@ interface NotificationSettings { time: string; // "HH:MM" minHours: number; // threshold for current day }; + nagging: { + enabled: boolean; + startTime: string; // "HH:MM" - window start + endTime: string; // "HH:MM" - window end + intervalHours: number; // how often to nag within the window + targetHours: number; // full-day target used for pro-rating + }; } const DEFAULT_NOTIFICATION_SETTINGS: NotificationSettings = { @@ -29,10 +36,18 @@ const DEFAULT_NOTIFICATION_SETTINGS: NotificationSettings = { time: '17:00', minHours: 8, }, + nagging: { + enabled: false, + startTime: '10:00', + endTime: '16:00', + intervalHours: 2, + targetHours: 8, + }, }; const ALARM_START_OF_DAY = 'gitlab-ninja-start-of-day'; const ALARM_END_OF_DAY = 'gitlab-ninja-end-of-day'; +const ALARM_NAGGING = 'gitlab-ninja-nagging'; function localDateStr(d: Date): string { const y = d.getFullYear(); @@ -44,7 +59,19 @@ function localDateStr(d: Date): string { async function getNotificationSettings(): Promise { return new Promise((resolve) => { chrome.storage.sync.get('notificationSettings', (result) => { - resolve(result.notificationSettings || DEFAULT_NOTIFICATION_SETTINGS); + const stored = result.notificationSettings; + if (!stored) { + resolve(DEFAULT_NOTIFICATION_SETTINGS); + return; + } + // Merge defaults so settings saved before a field existed (e.g. nagging) stay valid. + resolve({ + ...DEFAULT_NOTIFICATION_SETTINGS, + ...stored, + startOfDay: { ...DEFAULT_NOTIFICATION_SETTINGS.startOfDay, ...stored.startOfDay }, + endOfDay: { ...DEFAULT_NOTIFICATION_SETTINGS.endOfDay, ...stored.endOfDay }, + nagging: { ...DEFAULT_NOTIFICATION_SETTINGS.nagging, ...stored.nagging }, + }); }); }); } @@ -129,6 +156,11 @@ function getPreviousWorkday(date: Date): Date { return prev; } +function timeStrToMinutes(timeStr: string): number { + const [hours, minutes] = timeStr.split(':').map(Number); + return hours * 60 + minutes; +} + function getNextAlarmTime(timeStr: string): Date { const [hours, minutes] = timeStr.split(':').map(Number); const now = new Date(); @@ -146,6 +178,7 @@ async function scheduleAlarms(): Promise { // Clear existing alarms await chrome.alarms.clear(ALARM_START_OF_DAY); await chrome.alarms.clear(ALARM_END_OF_DAY); + await chrome.alarms.clear(ALARM_NAGGING); const settings = await getNotificationSettings(); if (!settings.enabled) return; @@ -165,6 +198,40 @@ async function scheduleAlarms(): Promise { periodInMinutes: 24 * 60, // repeat daily }); } + + if (settings.nagging.enabled) { + // Repeat on the interval; fires outside the window are filtered in handleNaggingAlarm. + const interval = Math.max(0.25, settings.nagging.intervalHours); + const nextTime = getNextNagTime(settings.nagging.startTime, settings.nagging.endTime, interval); + chrome.alarms.create(ALARM_NAGGING, { + when: nextTime.getTime(), + periodInMinutes: interval * 60, + }); + } +} + +// Soonest interval-aligned slot inside today's window, else the window start tomorrow. +function getNextNagTime(startTime: string, endTime: string, intervalHours: number): Date { + const now = new Date(); + const nowMin = now.getHours() * 60 + now.getMinutes(); + const startMin = timeStrToMinutes(startTime); + const endMin = timeStrToMinutes(endTime); + const intervalMin = intervalHours * 60; + + const at = (minutes: number, dayOffset: number): Date => { + const d = new Date(now.getFullYear(), now.getMonth(), now.getDate(), 0, 0, 0, 0); + d.setDate(d.getDate() + dayOffset); + d.setMinutes(minutes); + return d; + }; + + if (nowMin < startMin) return at(startMin, 0); + if (nowMin <= endMin) { + const slotsElapsed = Math.ceil((nowMin - startMin) / intervalMin); + const nextSlot = startMin + slotsElapsed * intervalMin; + if (nextSlot <= endMin) return at(nextSlot, 0); + } + return at(startMin, 1); } async function handleStartOfDayAlarm(): Promise { @@ -219,12 +286,49 @@ async function handleEndOfDayAlarm(): Promise { } } +async function handleNaggingAlarm(): Promise { + const now = new Date(); + // Only fire on weekdays + if (!isWeekday(now)) return; + + const settings = await getNotificationSettings(); + if (!settings.enabled || !settings.nagging.enabled) return; + + const nowMin = now.getHours() * 60 + now.getMinutes(); + const startMin = timeStrToMinutes(settings.nagging.startTime); + const endMin = timeStrToMinutes(settings.nagging.endTime); + // Outside today's window — alarm interval doesn't align to the window, so skip. + if (nowMin < startMin || nowMin > endMin) return; + + const totalSeconds = await fetchDayTimeSpent(now); + + // Pro-rate the daily target by how far we are through the window. + const span = Math.max(1, endMin - startMin); + const fraction = Math.min(1, Math.max(0, (nowMin - startMin) / span)); + const expectedSeconds = settings.nagging.targetHours * 3600 * fraction; + + if (totalSeconds < expectedSeconds) { + const logged = formatHours(totalSeconds); + const expected = formatHours(expectedSeconds); + + chrome.notifications.create(`nagging-${Date.now()}`, { + type: 'basic', + iconUrl: 'icons/icon-128.png', + title: 'GitLab Ninja - Time Log Reminder', + message: `You've logged ${logged} so far — about ${expected} expected by now. Keep logging your time!`, + priority: 1, + }); + } +} + // Listen for alarms chrome.alarms.onAlarm.addListener((alarm) => { if (alarm.name === ALARM_START_OF_DAY) { handleStartOfDayAlarm(); } else if (alarm.name === ALARM_END_OF_DAY) { handleEndOfDayAlarm(); + } else if (alarm.name === ALARM_NAGGING) { + handleNaggingAlarm(); } }); @@ -236,7 +340,10 @@ const DYNAMIC_INJECTED_SCRIPT_ID = 'gitlab-ninja-custom-domain-injected'; // Serialize calls to avoid duplicate registration races let registerPromise: Promise = Promise.resolve(); function registerCustomDomainScript(): Promise { - registerPromise = registerPromise.then(registerCustomDomainScriptImpl, registerCustomDomainScriptImpl); + registerPromise = registerPromise.then( + registerCustomDomainScriptImpl, + registerCustomDomainScriptImpl + ); return registerPromise; } diff --git a/src/options.html b/src/options.html index 50990c4..888a8f5 100644 --- a/src/options.html +++ b/src/options.html @@ -2273,6 +2273,58 @@ +
+
+ +
+
Keep nagging me
+
+ Reminds you on a set interval during the day whenever you're behind the + pro-rated pace toward your daily target. +
+
+
+
+
+ + +
+
+ + +
+
+
+
+ + +
+
+ + +
+
+
+
` - ).join(''); + const renderRow = (items: typeof presets) => + items + .map((p) => ``) + .join(''); el.innerHTML = `
${renderRow(row1)}
${renderRow(row2)}
`; From bd0ffa79e321f7ab9150d2cd3a2201d1d9a8c94f Mon Sep 17 00:00:00 2001 From: Andreas Gassmann Date: Wed, 24 Jun 2026 13:25:15 +0200 Subject: [PATCH 10/20] feat: board daily target from work settings --- src/features/boardSettings.ts | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/src/features/boardSettings.ts b/src/features/boardSettings.ts index d127596..4c195aa 100644 --- a/src/features/boardSettings.ts +++ b/src/features/boardSettings.ts @@ -4,6 +4,7 @@ */ import { debugLog } from '../utils/debug'; +import { getWorkSettings } from '../utils/workSettings'; export type SettingsChangeCallback = (settings: BoardSettingsState) => void; @@ -12,7 +13,6 @@ export interface BoardSettingsState { } const STORAGE_KEY = 'gitlab-ninja-board-settings'; -const DAILY_TARGET_SECONDS = 30240; // 8h 24m export class BoardSettingsFeature { private container: HTMLElement | null = null; @@ -147,18 +147,18 @@ export class BoardSettingsFeature { const h = Math.floor(totalSeconds / 3600); const m = Math.floor((totalSeconds % 3600) / 60); - const targetH = Math.floor(DAILY_TARGET_SECONDS / 3600); - const targetM = Math.floor((DAILY_TARGET_SECONDS % 3600) / 60); + const targetH = Math.floor(getWorkSettings().dailyTargetSeconds / 3600); + const targetM = Math.floor((getWorkSettings().dailyTargetSeconds % 3600) / 60); value.textContent = `${h}h ${m}m / ${targetH}h ${targetM}m`; - const pct = Math.min((totalSeconds / DAILY_TARGET_SECONDS) * 100, 100); + const pct = Math.min((totalSeconds / getWorkSettings().dailyTargetSeconds) * 100, 100); fill.style.width = `${pct}%`; // Color based on progress bar.classList.remove('gn-bar-green', 'gn-bar-indigo', 'gn-bar-red'); - if (totalSeconds > DAILY_TARGET_SECONDS) { + if (totalSeconds > getWorkSettings().dailyTargetSeconds) { bar.classList.add('gn-bar-red'); - } else if (totalSeconds >= DAILY_TARGET_SECONDS * 0.95) { + } else if (totalSeconds >= getWorkSettings().dailyTargetSeconds * 0.95) { bar.classList.add('gn-bar-indigo'); } else { bar.classList.add('gn-bar-green'); From 9b0618cd8ad04d8a03ea640701011c9d00bbf851 Mon Sep 17 00:00:00 2001 From: Andreas Gassmann Date: Wed, 24 Jun 2026 13:27:25 +0200 Subject: [PATCH 11/20] feat: estimate warning threshold from work settings --- src/features/timeTracking.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/features/timeTracking.ts b/src/features/timeTracking.ts index c0a5132..d7fab6b 100644 --- a/src/features/timeTracking.ts +++ b/src/features/timeTracking.ts @@ -6,6 +6,7 @@ import { TimeInfo } from '../types'; import { parseTimeToHours, formatHours } from '../utils/time'; import { extractIssueCacheKey, getCachedTimeTracking } from '../utils/api'; +import { getWorkSettings } from '../utils/workSettings'; /** Status states for card color coding */ type CardStatus = 'unestimated' | 'ready' | 'active' | 'warning' | 'over'; @@ -122,7 +123,8 @@ export class TimeTrackingFeature { if (t.estimate === 0) return 'unestimated'; if (t.spent === 0) return 'ready'; if (t.spent > t.estimate) return 'over'; - if (t.spent < t.estimate && t.spent / t.estimate > 0.8) return 'warning'; + if (t.spent < t.estimate && t.spent / t.estimate > getWorkSettings().warningThreshold) + return 'warning'; return 'active'; } From bd5154c9cd13aa05606cc3fe644c1a6a0a475154 Mon Sep 17 00:00:00 2001 From: Andreas Gassmann Date: Wed, 24 Jun 2026 13:29:23 +0200 Subject: [PATCH 12/20] feat: day start time + calendar scroll from work settings --- src/options.ts | 19 ++++++++++++------- 1 file changed, 12 insertions(+), 7 deletions(-) diff --git a/src/options.ts b/src/options.ts index 0e05610..342e014 100644 --- a/src/options.ts +++ b/src/options.ts @@ -22,7 +22,7 @@ import { PlanItem, } from './utils/timelogDrafts'; import { isConnectionError, renderConnectionError } from './utils/connectionError'; -import { initWorkSettings } from './utils/workSettings'; +import { getWorkSettings, initWorkSettings } from './utils/workSettings'; interface WeeklyTimelog { issueIid: number; @@ -103,10 +103,11 @@ function getDateFromSpentAt(spentAt: string): string { } function parseTimeFromISO(iso: string): { hours: number; minutes: number } { - if (!iso.includes('T')) return { hours: 9, minutes: 0 }; + const [dh, dm] = getWorkSettings().dayStartTime.split(':').map((n) => parseInt(n, 10)); + if (!iso.includes('T')) return { hours: dh, minutes: dm }; const timePart = iso.split('T')[1]; const match = timePart.match(/^(\d{2}):(\d{2})/); - if (!match) return { hours: 9, minutes: 0 }; + if (!match) return { hours: dh, minutes: dm }; return { hours: parseInt(match[1], 10), minutes: parseInt(match[2], 10) }; } @@ -441,8 +442,10 @@ async function createTimelog( note: string ): Promise { if (!gitlabUrl || !apiToken) throw new Error('Not configured'); - // Default to 09:00 when no time component is provided - const fullSpentAt = spentAt.includes('T') ? spentAt : `${spentAt}T09:00:00`; + // Default to day-start time when no time component is provided + const fullSpentAt = spentAt.includes('T') + ? spentAt + : `${spentAt}T${getWorkSettings().dayStartTime}:00`; const escapedNote = note.replace(/\\/g, '\\\\').replace(/"/g, '\\"').replace(/\n/g, '\\n'); const mutation = `mutation { timelogCreate(input: { @@ -1308,10 +1311,12 @@ function renderCalendarWeek(days: Date[], timelogs: DisplayTimelog[], entries: W content.innerHTML = html; - // Scroll to 8:30 by default on initial render + // Scroll so the day-start (minus 30 min for context) is at the top on first render const calBody = content.querySelector('.cal-body'); if (calBody && calBody.scrollTop === 0) { - calBody.scrollTop = (8.5 - gridStartHour) * CAL_PX_PER_HOUR; + const [sh, sm] = getWorkSettings().dayStartTime.split(':').map((n) => parseInt(n, 10)); + const scrollHour = sh + sm / 60 - 0.5; + calBody.scrollTop = (scrollHour - gridStartHour) * CAL_PX_PER_HOUR; } // Floating indicators for entries scrolled out of view (above/below) From 19f33bbec4e00d42384e666833464da5dfaa129d Mon Sep 17 00:00:00 2001 From: Andreas Gassmann Date: Wed, 24 Jun 2026 13:32:19 +0200 Subject: [PATCH 13/20] feat: weekend days from work settings --- src/options.ts | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/src/options.ts b/src/options.ts index 342e014..2e43b52 100644 --- a/src/options.ts +++ b/src/options.ts @@ -492,7 +492,7 @@ function renderWeek(entries: WeeklyTimelog[], days: Date[], filterDate: string | const dateKey = localDateStr(d); const todayClass = isToday(d) ? ' today' : ''; const activeClass = filterDate === dateKey ? ' active' : ''; - const weekendClass = i >= 5 ? ' weekend' : ''; + const weekendClass = getWorkSettings().weekendDays.includes(i) ? ' weekend' : ''; const hrs = dailyTotals[i]; const zeroClass = hrs === 0 ? ' zero' : ''; html += ` @@ -1207,8 +1207,9 @@ function renderCalendarWeek(days: Date[], timelogs: DisplayTimelog[], entries: W timeLabelsHtml += `
${label}
`; } - const visibleDays = hideWeekends ? days.slice(0, 5) : days; - const dayCols = hideWeekends ? 5 : 7; + const weekendDays = getWorkSettings().weekendDays; + const visibleDays = hideWeekends ? days.filter((_, i) => !weekendDays.includes(i)) : days; + const dayCols = visibleDays.length; // Day headers & columns let dayHeadersHtml = '
'; @@ -1217,7 +1218,7 @@ function renderCalendarWeek(days: Date[], timelogs: DisplayTimelog[], entries: W visibleDays.forEach((d, i) => { const dateKey = localDateStr(d); const todayClass = isToday(d) ? ' cal-today' : ''; - const weekendClass = !hideWeekends && i >= 5 ? ' cal-weekend' : ''; + const weekendClass = !hideWeekends && weekendDays.includes(i) ? ' cal-weekend' : ''; const dayLogs = byDate.get(dateKey) || []; const dayTotal = dayLogs.reduce( (sum, l) => sum + (l.draftStatus === 'deleted' ? 0 : l.timeSpent), From 9fe4eac602d51369bf259e943b9c963cfe9309db Mon Sep 17 00:00:00 2001 From: Andreas Gassmann Date: Wed, 24 Jun 2026 13:34:48 +0200 Subject: [PATCH 14/20] feat: time increment (snap + picker step) from work settings --- src/features/editMode.ts | 3 ++- src/options.ts | 5 +++-- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/src/features/editMode.ts b/src/features/editMode.ts index 441a010..dfcffeb 100644 --- a/src/features/editMode.ts +++ b/src/features/editMode.ts @@ -18,6 +18,7 @@ import { import { extractProjectPath, setTimeEstimate, addTimeSpent, formatDate, fetchTimelogs, Timelog } from '../utils/gitlabApi'; import { formatHours } from '../utils/time'; import { ESTIMATE_PRESETS, SPENT_PRESETS } from '../utils/constants'; +import { getWorkSettings } from '../utils/workSettings'; const DAY_ABBR = ['Su', 'Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa']; @@ -248,7 +249,7 @@ export class EditModeFeature { // 15-minute time options for the dropdown (00:00 … 23:45) let timeOptionsHtml = ''; for (let h = 0; h < 24; h++) { - for (let m = 0; m < 60; m += 15) { + for (let m = 0; m < 60; m += getWorkSettings().timeIncrementMinutes) { const v = `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}`; timeOptionsHtml += ``; } diff --git a/src/options.ts b/src/options.ts index 2e43b52..c1f66e0 100644 --- a/src/options.ts +++ b/src/options.ts @@ -1465,7 +1465,7 @@ function renderBreakdownSections( // ── Calendar Interactions (drag, resize, click) ── function attachCalendarInteractions(container: HTMLElement, gridStartHour: number) { - const snapPx = CAL_PX_PER_HOUR / 4; // 15-min snap + const snapPx = (CAL_PX_PER_HOUR * getWorkSettings().timeIncrementMinutes) / 60; function snapToGrid(value: number): number { return Math.round(value / snapPx) * snapPx; @@ -1479,8 +1479,9 @@ function attachCalendarInteractions(container: HTMLElement, gridStartHour: numbe } function pxToDurationSeconds(px: number): number { + const snap = getWorkSettings().timeIncrementMinutes * 60; const raw = Math.round((px / CAL_PX_PER_HOUR) * 3600); - return Math.max(900, Math.round(raw / 900) * 900); // min 15min, snap 15min + return Math.max(snap, Math.round(raw / snap) * snap); // snap to time increment } let dragState: { From 00da8cf71d44eaa79516f459f6d611a3061c3078 Mon Sep 17 00:00:00 2001 From: Andreas Gassmann Date: Wed, 24 Jun 2026 13:35:59 +0200 Subject: [PATCH 15/20] docs: fix stale time-increment comment --- src/features/editMode.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/features/editMode.ts b/src/features/editMode.ts index dfcffeb..cca9819 100644 --- a/src/features/editMode.ts +++ b/src/features/editMode.ts @@ -246,7 +246,7 @@ export class EditModeFeature { ); let selectedTime = nowTime; - // 15-minute time options for the dropdown (00:00 … 23:45) + // Time options for the dropdown, stepped by the configured time increment let timeOptionsHtml = ''; for (let h = 0; h < 24; h++) { for (let m = 0; m < 60; m += getWorkSettings().timeIncrementMinutes) { From 930b8dc3aa94a812be04883b6cc117126707968b Mon Sep 17 00:00:00 2001 From: Andreas Gassmann Date: Wed, 24 Jun 2026 13:38:23 +0200 Subject: [PATCH 16/20] feat: Work settings tab in options UI --- src/options.html | 49 +++++++++++++++++++++++++++ src/options.ts | 88 +++++++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 136 insertions(+), 1 deletion(-) diff --git a/src/options.html b/src/options.html index 888a8f5..07b9c98 100644 --- a/src/options.html +++ b/src/options.html @@ -2120,6 +2120,7 @@
+ @@ -2186,6 +2187,54 @@
+
+
+
+ + +
Default time for new logs; calendar scrolls here.
+
+
+ +
+ h + m +
+
+
+ + +
+
+ +
+
+
+ + +
+
+ + +
Affects how 1d estimates display.
+
+
+ + +
Affects how 1w estimates display.
+
+
+ + +
+
+
+
{ + const cb = document.getElementById(`wsWeekend-${i}`) as HTMLInputElement | null; + if (cb?.checked) weekendDays.push(i); + }); + return { + dayStartTime: (document.getElementById('wsDayStart') as HTMLInputElement).value || '09:00', + dailyTargetSeconds: th * 3600 + tm * 60, + warningThreshold: + parseInt((document.getElementById('wsWarn') as HTMLInputElement).value || '80', 10) / 100, + weekendDays, + timeIncrementMinutes: parseInt( + (document.getElementById('wsIncrement') as HTMLSelectElement).value, + 10 + ), + hoursPerDay: parseFloat((document.getElementById('wsHoursDay') as HTMLInputElement).value || '8'), + hoursPerWeek: parseFloat( + (document.getElementById('wsHoursWeek') as HTMLInputElement).value || '40' + ), + }; +} + +function populateWorkForm(s: WorkSettings): void { + (document.getElementById('wsDayStart') as HTMLInputElement).value = s.dayStartTime; + (document.getElementById('wsTargetH') as HTMLInputElement).value = String( + Math.floor(s.dailyTargetSeconds / 3600) + ); + (document.getElementById('wsTargetM') as HTMLInputElement).value = String( + Math.round((s.dailyTargetSeconds % 3600) / 60) + ); + (document.getElementById('wsWarn') as HTMLInputElement).value = String( + Math.round(s.warningThreshold * 100) + ); + (document.getElementById('wsIncrement') as HTMLSelectElement).value = String( + s.timeIncrementMinutes + ); + (document.getElementById('wsHoursDay') as HTMLInputElement).value = String(s.hoursPerDay); + (document.getElementById('wsHoursWeek') as HTMLInputElement).value = String(s.hoursPerWeek); + + const wrap = document.getElementById('wsWeekend')!; + wrap.innerHTML = WS_DAY_LABELS.map( + (label, i) => + `` + ).join(''); +} + +function initWorkSettingsForm(): void { + loadWorkSettings().then(populateWorkForm); + + const status = document.getElementById('wsSaveStatus'); + const onChange = () => { + saveWorkSettings(readWorkForm()); + if (status) { + status.textContent = 'Saved'; + setTimeout(() => (status.textContent = ''), 1500); + } + }; + + const panel = document.querySelector('[data-settings-tab-content="work"]'); + panel?.addEventListener('change', onChange); + + document.getElementById('wsResetBtn')?.addEventListener('click', () => { + populateWorkForm({ ...DEFAULT_WORK_SETTINGS }); + saveWorkSettings({ ...DEFAULT_WORK_SETTINGS }); + if (status) { + status.textContent = 'Reset'; + setTimeout(() => (status.textContent = ''), 1500); + } + }); +} + function initNotificationSettings(): void { loadNotificationSettings().then(populateNotificationForm); @@ -3268,6 +3353,7 @@ document.addEventListener('DOMContentLoaded', async () => { initSettingsTabs(); initNotificationSettings(); + initWorkSettingsForm(); renderThemeModeSelector(); renderPresetRow(); renderStatusColorPickers(); From 526f3104117d6899ff3025d92dc4ab0590ac7d9b Mon Sep 17 00:00:00 2001 From: Andreas Gassmann Date: Wed, 24 Jun 2026 13:46:17 +0200 Subject: [PATCH 17/20] fix: format work-settings code and couple picker rounding to increment --- src/features/editMode.ts | 11 +++++---- src/options.html | 53 +++++++++++++++++++++++++++++++++++----- src/options.ts | 12 ++++++--- 3 files changed, 62 insertions(+), 14 deletions(-) diff --git a/src/features/editMode.ts b/src/features/editMode.ts index cca9819..46feb28 100644 --- a/src/features/editMode.ts +++ b/src/features/editMode.ts @@ -235,13 +235,14 @@ export class EditModeFeature { ): void { let selectedSpent: string | null = null; let selectedDate = formatDate(new Date()); - // Round to the nearest 15 minutes — the time picker only offers quarter-hour slots. - const roundToQuarter = (hhmm: string): string => { + // Round to the nearest time increment so the value matches a dropdown option. + const inc = getWorkSettings().timeIncrementMinutes; + const roundToIncrement = (hhmm: string): string => { const [h, m] = hhmm.split(':').map(Number); - const total = (h * 60 + Math.round(m / 15) * 15) % (24 * 60); + const total = (h * 60 + Math.round(m / inc) * inc) % (24 * 60); return `${String(Math.floor(total / 60)).padStart(2, '0')}:${String(total % 60).padStart(2, '0')}`; }; - const nowTime = roundToQuarter( + const nowTime = roundToIncrement( `${String(new Date().getHours()).padStart(2, '0')}:${String(new Date().getMinutes()).padStart(2, '0')}` ); let selectedTime = nowTime; @@ -350,7 +351,7 @@ export class EditModeFeature { datePicker.value = selectedDate; if (btn.dataset.setTime === 'true') { const now = new Date(); - selectedTime = roundToQuarter( + selectedTime = roundToIncrement( `${String(now.getHours()).padStart(2, '0')}:${String(now.getMinutes()).padStart(2, '0')}` ); } else { diff --git a/src/options.html b/src/options.html index 07b9c98..ae17faf 100644 --- a/src/options.html +++ b/src/options.html @@ -2197,13 +2197,38 @@
- h - m + + h + + m
- - + +
@@ -2220,12 +2245,28 @@
- +
Affects how 1d estimates display.
- +
Affects how 1w estimates display.
diff --git a/src/options.ts b/src/options.ts index d03c84b..ba008fe 100644 --- a/src/options.ts +++ b/src/options.ts @@ -110,7 +110,9 @@ function getDateFromSpentAt(spentAt: string): string { } function parseTimeFromISO(iso: string): { hours: number; minutes: number } { - const [dh, dm] = getWorkSettings().dayStartTime.split(':').map((n) => parseInt(n, 10)); + const [dh, dm] = getWorkSettings() + .dayStartTime.split(':') + .map((n) => parseInt(n, 10)); if (!iso.includes('T')) return { hours: dh, minutes: dm }; const timePart = iso.split('T')[1]; const match = timePart.match(/^(\d{2}):(\d{2})/); @@ -1322,7 +1324,9 @@ function renderCalendarWeek(days: Date[], timelogs: DisplayTimelog[], entries: W // Scroll so the day-start (minus 30 min for context) is at the top on first render const calBody = content.querySelector('.cal-body'); if (calBody && calBody.scrollTop === 0) { - const [sh, sm] = getWorkSettings().dayStartTime.split(':').map((n) => parseInt(n, 10)); + const [sh, sm] = getWorkSettings() + .dayStartTime.split(':') + .map((n) => parseInt(n, 10)); const scrollHour = sh + sm / 60 - 0.5; calBody.scrollTop = (scrollHour - gridStartHour) * CAL_PX_PER_HOUR; } @@ -3202,7 +3206,9 @@ function readWorkForm(): WorkSettings { (document.getElementById('wsIncrement') as HTMLSelectElement).value, 10 ), - hoursPerDay: parseFloat((document.getElementById('wsHoursDay') as HTMLInputElement).value || '8'), + hoursPerDay: parseFloat( + (document.getElementById('wsHoursDay') as HTMLInputElement).value || '8' + ), hoursPerWeek: parseFloat( (document.getElementById('wsHoursWeek') as HTMLInputElement).value || '40' ), From d97b32dfe529e277c6759d80582bf878d6f064f0 Mon Sep 17 00:00:00 2001 From: Andreas Gassmann Date: Wed, 24 Jun 2026 15:34:21 +0200 Subject: [PATCH 18/20] feat: add "draft reset" --- src/options.html | 94 ++++++++++++++++++++++++++------------ src/options.ts | 72 +++++++++++++++++++++++++++-- src/utils/timelogDrafts.ts | 7 +++ 3 files changed, 140 insertions(+), 33 deletions(-) diff --git a/src/options.html b/src/options.html index ae17faf..aa2e4fe 100644 --- a/src/options.html +++ b/src/options.html @@ -800,6 +800,11 @@ color: var(--text-secondary); border-color: rgba(255, 255, 255, 0.1); } + .timelog-revert-btn:hover { + background: var(--accent-dim); + color: var(--accent); + border-color: var(--accent); + } .timelog-field-display, .timelog-summary-display { cursor: pointer; @@ -1598,6 +1603,31 @@ padding: 18px; margin-bottom: 14px; } + .settings-card-title { + font-family: 'Outfit', sans-serif; + font-size: 13px; + font-weight: 700; + color: var(--text-primary); + letter-spacing: -0.2px; + padding-bottom: 12px; + margin-bottom: 16px; + border-bottom: 1px solid var(--border); + } + .form-group { + margin-bottom: 18px; + } + .form-group:last-child { + margin-bottom: 0; + } + .settings-footer { + display: flex; + align-items: center; + justify-content: flex-end; + gap: 12px; + margin-top: 20px; + padding-top: 16px; + border-top: 1px solid var(--border); + } /* ── Color Pickers ── */ .color-section { @@ -2189,13 +2219,14 @@
+
Logging & schedule
-
Default time for new logs; calendar scrolls here.
+
Default time for new logs; the calendar scrolls here.
- +
m
-
-
- - +
How much you aim to log each working day.
+
Skipped by daily targets and reminders.
@@ -2242,6 +2262,28 @@ +
Step size when adjusting a duration.
+
+
+ +
+
Estimate display
+
+ +
+ + % of estimate spent +
+
+ Cards flag once logged time passes this share of the estimate. +
@@ -2269,10 +2311,11 @@ />
Affects how 1w estimates display.
-
- - -
+
+ +
@@ -2469,17 +2512,8 @@
-
- +
diff --git a/src/options.ts b/src/options.ts index ba008fe..16ff682 100644 --- a/src/options.ts +++ b/src/options.ts @@ -772,6 +772,7 @@ function renderWeek(entries: WeeklyTimelog[], days: Date[], filterDate: string | + ${log.draftStatus ? `` : ''} @@ -967,6 +968,14 @@ function renderWeek(entries: WeeklyTimelog[], days: Date[], filterDate: string | }); }); + content.querySelectorAll('.timelog-revert-btn').forEach((btn) => { + btn.addEventListener('click', (e) => { + e.stopPropagation(); + const id = (btn as HTMLElement).dataset.timelogId!; + routeRevert(id); + }); + }); + // ── Add new timelog ── content.querySelectorAll('.timelog-add-btn').forEach((btn) => { btn.addEventListener('click', () => { @@ -1772,6 +1781,7 @@ function showEditPopover(x: number, y: number, log: DisplayTimelog) { + ${log.draftStatus ? `` : ''} @@ -1809,6 +1819,11 @@ function showEditPopover(x: number, y: number, log: DisplayTimelog) { await routeSplit(log); }); + popover.querySelector('#popRevert')?.addEventListener('click', () => { + closeAllPopovers(); + routeRevert(log.id); + }); + popover.querySelector('#popSave')!.addEventListener('click', async () => { const duration = (popover.querySelector('#popDuration') as HTMLInputElement).value.trim(); const date = (popover.querySelector('#popDate') as HTMLInputElement).value; @@ -2471,6 +2486,15 @@ async function routeDelete(log: DisplayTimelog): Promise { return ok; } +// Drop a single staged draft change, restoring the row to its fetched state +// (or removing it entirely if it was a newly-added draft). Draft mode only. +function routeRevert(id: string): void { + if (!drafts.isEnabled()) return; + if (isDraftId(id)) drafts.deleteAdded(id); + else drafts.revertOriginal(id); + renderCurrentView(); +} + async function routeDuplicate(log: DisplayTimelog): Promise { if (drafts.isEnabled()) { drafts.addNew(displayToDesired(log)); @@ -2578,6 +2602,35 @@ function openModal(innerHtml: string): { return { overlay, modal, close }; } +// Generic yes/no confirmation. Resolves true if the user confirms. +function confirmAction(opts: { + title: string; + body: string; + confirmLabel: string; + danger?: boolean; +}): Promise { + return new Promise((resolve) => { + const confirmStyle = opts.danger ? ' style="background:var(--red);color:#fff"' : ''; + const { modal, close } = openModal(` +
${escapeHtml(opts.title)}
+
${escapeHtml(opts.body)}
+
+ + +
+ `); + let done = false; + const finish = (val: boolean) => { + if (done) return; + done = true; + close(); + resolve(val); + }; + modal.querySelector('[data-act="cancel"]')!.addEventListener('click', () => finish(false)); + modal.querySelector('[data-act="confirm"]')!.addEventListener('click', () => finish(true)); + }); +} + function confirmToggleOff(): Promise<'commit' | 'discard' | 'cancel'> { return new Promise((resolve) => { const n = drafts.pendingCount(); @@ -3256,7 +3309,14 @@ function initWorkSettingsForm(): void { const panel = document.querySelector('[data-settings-tab-content="work"]'); panel?.addEventListener('change', onChange); - document.getElementById('wsResetBtn')?.addEventListener('click', () => { + document.getElementById('wsResetBtn')?.addEventListener('click', async () => { + const ok = await confirmAction({ + title: 'Reset work settings?', + body: 'This restores every setting on this tab to its default. Your time logs are not affected.', + confirmLabel: 'Reset to defaults', + danger: true, + }); + if (!ok) return; populateWorkForm({ ...DEFAULT_WORK_SETTINGS }); saveWorkSettings({ ...DEFAULT_WORK_SETTINGS }); if (status) { @@ -3366,8 +3426,14 @@ document.addEventListener('DOMContentLoaded', async () => { renderProjectColors(); renderColorPreview(); - $('resetColorsBtn').addEventListener('click', () => { - if (!confirm('Reset all colors to defaults?')) return; + $('resetColorsBtn').addEventListener('click', async () => { + const ok = await confirmAction({ + title: 'Reset all colors?', + body: 'This restores every theme, status, and project color to its default.', + confirmLabel: 'Reset all', + danger: true, + }); + if (!ok) return; currentColors = { ...DEFAULT_COLORS, projectPalette: [...DEFAULT_COLORS.projectPalette], diff --git a/src/utils/timelogDrafts.ts b/src/utils/timelogDrafts.ts index 0e3df7d..ec585f8 100644 --- a/src/utils/timelogDrafts.ts +++ b/src/utils/timelogDrafts.ts @@ -261,6 +261,13 @@ export class DraftManager { this.persist(); } + /** Drop a single staged change against an original, restoring it to its + * fetched state (works for both 'modified' and 'deleted' drafts). */ + revertOriginal(originId: string): void { + delete this.state.byOrigin[originId]; + this.persist(); + } + discardAll(): void { this.state.byOrigin = {}; this.state.added = []; From e7701734def9eb28d3ba36fe828d8171feef7bdd Mon Sep 17 00:00:00 2001 From: Andreas Gassmann Date: Wed, 24 Jun 2026 16:34:49 +0200 Subject: [PATCH 19/20] feat: draft new ticket --- src/popup.html | 23 +++++++++++++++++- src/popup.ts | 66 ++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 88 insertions(+), 1 deletion(-) diff --git a/src/popup.html b/src/popup.html index 711acd5..bfea615 100644 --- a/src/popup.html +++ b/src/popup.html @@ -409,6 +409,22 @@ box-shadow: none; transform: none; } + .ticket-clear-btn { + padding: 11px 14px; + background: var(--bg-hover); + color: var(--text-muted); + border: none; + border-radius: var(--radius); + font-family: 'Outfit', sans-serif; + font-size: 13px; + font-weight: 600; + cursor: pointer; + transition: all 0.2s; + } + .ticket-clear-btn:hover { + color: var(--text-primary); + background: var(--bg-deep); + } .status-msg { margin-top: 12px; @@ -1526,7 +1542,12 @@ placeholder="e.g. Implemented feature X" /> - +
+ + +
diff --git a/src/popup.ts b/src/popup.ts index 715159f..5a07631 100644 --- a/src/popup.ts +++ b/src/popup.ts @@ -624,6 +624,7 @@ async function createTicket() { ($('ticketEstimate') as HTMLInputElement).value = ''; ($('ticketSpent') as HTMLInputElement).value = ''; ($('ticketSummary') as HTMLInputElement).value = ''; + clearTicketDraft(); btn.textContent = 'Create Ticket'; updateSubmitButton(); } catch (err: any) { @@ -810,6 +811,54 @@ async function saveNotes(items: string[]): Promise { }); } +// ── New-ticket draft persistence ── + +interface TicketDraft { + title: string; + description: string; + estimate: string; + spent: string; + summary: string; +} + +const TICKET_DRAFT_FIELDS: Record = { + title: 'ticketTitle', + description: 'ticketDesc', + estimate: 'ticketEstimate', + spent: 'ticketSpent', + summary: 'ticketSummary', +}; + +function loadTicketDraft(): Promise { + return new Promise((resolve) => { + chrome.storage.local.get(['newTicketDraft'], (result) => { + const d = result.newTicketDraft || {}; + resolve({ + title: d.title || '', + description: d.description || '', + estimate: d.estimate || '', + spent: d.spent || '', + summary: d.summary || '', + }); + }); + }); +} + +function saveTicketDraft(): void { + const draft: TicketDraft = { + title: ($('ticketTitle') as HTMLInputElement).value, + description: ($('ticketDesc') as HTMLTextAreaElement).value, + estimate: ($('ticketEstimate') as HTMLInputElement).value, + spent: ($('ticketSpent') as HTMLInputElement).value, + summary: ($('ticketSummary') as HTMLInputElement).value, + }; + chrome.storage.local.set({ newTicketDraft: draft }); +} + +function clearTicketDraft(): void { + chrome.storage.local.remove('newTicketDraft'); +} + function updateNotesBadge() { const badge = $('notesBadge'); badge.textContent = notes.length > 0 ? String(notes.length) : ''; @@ -1485,9 +1534,26 @@ document.addEventListener('DOMContentLoaded', async () => { }); }); + // Restore cached new-ticket draft and keep it synced on every edit. + const ticketDraft = await loadTicketDraft(); + (Object.keys(TICKET_DRAFT_FIELDS) as (keyof TicketDraft)[]).forEach((field) => { + const el = $(TICKET_DRAFT_FIELDS[field]) as HTMLInputElement | HTMLTextAreaElement; + el.value = ticketDraft[field]; + el.addEventListener('input', saveTicketDraft); + }); + updateSubmitButton(); + $('ticketTitle').addEventListener('input', updateSubmitButton); $('submitBtn').addEventListener('click', createTicket); + $('ticketClearBtn').addEventListener('click', () => { + (Object.values(TICKET_DRAFT_FIELDS) as string[]).forEach((id) => { + ($(id) as HTMLInputElement | HTMLTextAreaElement).value = ''; + }); + clearTicketDraft(); + updateSubmitButton(); + }); + $('todayRefresh').addEventListener('click', () => { loadToday(selectedDayDate || undefined); loadWeekMini(); From 2da814a73247b36364ba1ec5e1ba6f66b201f066 Mon Sep 17 00:00:00 2001 From: Andreas Gassmann Date: Wed, 24 Jun 2026 16:38:22 +0200 Subject: [PATCH 20/20] feat: remove docs --- .../2026-06-24-configurable-work-settings.md | 925 ------------------ .../2026-06-23-timelog-draft-mode-design.md | 180 ---- ...06-24-configurable-work-settings-design.md | 159 --- 3 files changed, 1264 deletions(-) delete mode 100644 docs/superpowers/plans/2026-06-24-configurable-work-settings.md delete mode 100644 docs/superpowers/specs/2026-06-23-timelog-draft-mode-design.md delete mode 100644 docs/superpowers/specs/2026-06-24-configurable-work-settings-design.md diff --git a/docs/superpowers/plans/2026-06-24-configurable-work-settings.md b/docs/superpowers/plans/2026-06-24-configurable-work-settings.md deleted file mode 100644 index bb2bf67..0000000 --- a/docs/superpowers/plans/2026-06-24-configurable-work-settings.md +++ /dev/null @@ -1,925 +0,0 @@ -# Configurable Work Settings Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Promote seven hardcoded constants to user-configurable settings exposed in a new "Work" tab on the options page, with defaults equal to current values (zero behavior change until opt-in). - -**Architecture:** A new `src/utils/workSettings.ts` module owns a typed settings object stored under one `chrome.storage.sync` key, following the existing `themeManager.ts` pattern (typed interface + `DEFAULT_*` + async load / sync save) plus a synchronous in-memory cache so the pure functions in `time.ts` stay synchronous. Each entry point awaits `initWorkSettings()` at startup; a `storage.onChanged` listener keeps the cache live. - -**Tech Stack:** TypeScript, webpack, Chrome extension (MV3) APIs (`chrome.storage.sync`, `chrome.storage.onChanged`), Vitest (added in Task 1). - -## Global Constraints - -- Defaults MUST equal current hardcoded values exactly: `dayStartTime="09:00"`, `dailyTargetSeconds=30240`, `warningThreshold=0.8`, `weekendDays=[5,6]`, `timeIncrementMinutes=15`, `hoursPerDay=8`, `hoursPerWeek=40`. -- Weekday index convention is **Mon=0 … Sun=6** (matches existing `DAY_NAMES`). Weekend default `[5,6]` = Sat, Sun. -- Storage key: `workSettings` in `chrome.storage.sync`. Do NOT touch `notificationSettings`. -- `time.ts` public signatures (`parseTimeToHours`, `formatHours`) MUST NOT change — 20 call sites depend on them. -- `getWorkSettings()` must never throw; before `initWorkSettings()` resolves it returns `DEFAULT_WORK_SETTINGS`. -- Every task ends green on `npm run check` (type-check + lint + format:check + build). -- No `Co-Authored-By` / AI attribution in commits. - ---- - -## File Structure - -- **Create** `src/utils/workSettings.ts` — settings type, defaults, load/save, sync cache, init, onChanged wiring. -- **Create** `vitest.config.ts`, `src/test/chromeMock.ts` — test infra (Task 1). -- **Create** `src/utils/workSettings.test.ts`, `src/utils/time.test.ts` — unit tests. -- **Modify** `src/utils/time.ts` — read `hoursPerDay`/`hoursPerWeek` from cache. -- **Modify** `src/background.ts`, `src/content.ts`, `src/options.ts`, `src/popup.ts` — `await initWorkSettings()` at startup. -- **Modify** `src/features/boardSettings.ts` — daily target from settings. -- **Modify** `src/features/timeTracking.ts` — warning threshold from settings. -- **Modify** `src/features/editMode.ts` — time-picker step from settings. -- **Modify** `src/options.ts` — day start, calendar scroll, weekend days, calendar snap. -- **Modify** `src/options.html` + `src/options.ts` — new "Work" settings tab UI. - ---- - -## Task 1: Test infrastructure (Vitest + chrome mock) - -**Files:** -- Modify: `package.json` (add devDeps + `test` script) -- Create: `vitest.config.ts` -- Create: `src/test/chromeMock.ts` - -**Interfaces:** -- Produces: `installChromeMock(): { store: Record }` — installs a fake `globalThis.chrome.storage.sync`/`onChanged`; returns the backing store for assertions/seed. `resetChromeMock()` clears it. - -- [ ] **Step 1: Add Vitest dev dependencies** - -Run: -```bash -npm install -D vitest@^2 jsdom@^25 -``` - -- [ ] **Step 2: Add test script to package.json** - -In `package.json` `"scripts"`, add: -```json -"test": "vitest run", -"test:watch": "vitest" -``` - -- [ ] **Step 3: Create `vitest.config.ts`** - -```ts -import { defineConfig } from 'vitest/config'; - -export default defineConfig({ - test: { - environment: 'node', - include: ['src/**/*.test.ts'], - }, -}); -``` - -- [ ] **Step 4: Create `src/test/chromeMock.ts`** - -```ts -type Listener = (changes: Record, area: string) => void; - -let store: Record = {}; -let listeners: Listener[] = []; - -export function installChromeMock(): { store: Record } { - store = {}; - listeners = []; - const sync = { - get(keys: any, cb: (items: Record) => void) { - const key = typeof keys === 'string' ? keys : Array.isArray(keys) ? keys[0] : undefined; - cb(key === undefined ? { ...store } : { [key]: store[key] }); - }, - set(items: Record, cb?: () => void) { - const changes: Record = {}; - for (const k of Object.keys(items)) { - changes[k] = { oldValue: store[k], newValue: items[k] }; - store[k] = items[k]; - } - listeners.forEach((l) => l(changes, 'sync')); - cb?.(); - }, - }; - (globalThis as any).chrome = { - storage: { - sync, - onChanged: { - addListener: (l: Listener) => listeners.push(l), - removeListener: (l: Listener) => { - listeners = listeners.filter((x) => x !== l); - }, - }, - }, - }; - return { store }; -} - -export function resetChromeMock(): void { - store = {}; - listeners = []; -} -``` - -- [ ] **Step 5: Verify the test runner starts** - -Run: `npm test` -Expected: exit 0 with "No test files found" (or a passing run once tests exist). If it errors on config, fix before continuing. - -- [ ] **Step 6: Commit** - -```bash -git add package.json package-lock.json vitest.config.ts src/test/chromeMock.ts -git commit -m "test: add Vitest with chrome.storage mock" -``` - ---- - -## Task 2: `workSettings` module - -**Files:** -- Create: `src/utils/workSettings.ts` -- Test: `src/utils/workSettings.test.ts` - -**Interfaces:** -- Produces: - - `interface WorkSettings { dayStartTime: string; dailyTargetSeconds: number; warningThreshold: number; weekendDays: number[]; timeIncrementMinutes: number; hoursPerDay: number; hoursPerWeek: number; }` - - `const DEFAULT_WORK_SETTINGS: WorkSettings` - - `loadWorkSettings(): Promise` - - `saveWorkSettings(s: WorkSettings): void` - - `initWorkSettings(): Promise` - - `getWorkSettings(): WorkSettings` - -- [ ] **Step 1: Write failing tests** - -Create `src/utils/workSettings.test.ts`: -```ts -import { describe, it, expect, beforeEach } from 'vitest'; -import { installChromeMock } from '../test/chromeMock'; -import { - DEFAULT_WORK_SETTINGS, - loadWorkSettings, - saveWorkSettings, - initWorkSettings, - getWorkSettings, -} from './workSettings'; - -describe('workSettings', () => { - beforeEach(() => installChromeMock()); - - it('getWorkSettings returns defaults before init', () => { - expect(getWorkSettings()).toEqual(DEFAULT_WORK_SETTINGS); - }); - - it('loadWorkSettings returns defaults when nothing stored', async () => { - expect(await loadWorkSettings()).toEqual(DEFAULT_WORK_SETTINGS); - }); - - it('loadWorkSettings merges a partial stored object over defaults', async () => { - saveWorkSettings({ ...DEFAULT_WORK_SETTINGS, hoursPerDay: 7 }); - const loaded = await loadWorkSettings(); - expect(loaded.hoursPerDay).toBe(7); - expect(loaded.hoursPerWeek).toBe(40); - }); - - it('init populates the sync cache', async () => { - saveWorkSettings({ ...DEFAULT_WORK_SETTINGS, warningThreshold: 0.5 }); - await initWorkSettings(); - expect(getWorkSettings().warningThreshold).toBe(0.5); - }); - - it('cache updates live when storage changes', async () => { - await initWorkSettings(); - saveWorkSettings({ ...DEFAULT_WORK_SETTINGS, timeIncrementMinutes: 30 }); - expect(getWorkSettings().timeIncrementMinutes).toBe(30); - }); -}); -``` - -- [ ] **Step 2: Run tests, verify they fail** - -Run: `npm test -- src/utils/workSettings.test.ts` -Expected: FAIL — cannot find module `./workSettings`. - -- [ ] **Step 3: Implement the module** - -Create `src/utils/workSettings.ts`: -```ts -/** - * Work settings — user-configurable time-tracking defaults. - * Follows the themeManager pattern (typed interface + defaults + async load / - * sync save) plus a synchronous in-memory cache, because time.ts is pure & sync. - */ - -export interface WorkSettings { - dayStartTime: string; // "HH:MM" — default timelog time + calendar scroll anchor - dailyTargetSeconds: number; // board daily work target - warningThreshold: number; // 0..1 — estimate-spent "warning" status - weekendDays: number[]; // 0 = Mon … 6 = Sun - timeIncrementMinutes: number; // calendar snap + time-picker step - hoursPerDay: number; // estimate unit conversion (1d) - hoursPerWeek: number; // estimate unit conversion (1w) -} - -export const DEFAULT_WORK_SETTINGS: WorkSettings = { - dayStartTime: '09:00', - dailyTargetSeconds: 30240, - warningThreshold: 0.8, - weekendDays: [5, 6], - timeIncrementMinutes: 15, - hoursPerDay: 8, - hoursPerWeek: 40, -}; - -const STORAGE_KEY = 'workSettings'; - -let cache: WorkSettings = { ...DEFAULT_WORK_SETTINGS }; -let listenerWired = false; - -export function loadWorkSettings(): Promise { - return new Promise((resolve) => { - chrome.storage.sync.get(STORAGE_KEY, (result) => { - resolve({ ...DEFAULT_WORK_SETTINGS, ...(result[STORAGE_KEY] || {}) }); - }); - }); -} - -export function saveWorkSettings(settings: WorkSettings): void { - cache = { ...settings }; - chrome.storage.sync.set({ [STORAGE_KEY]: settings }); -} - -export async function initWorkSettings(): Promise { - cache = await loadWorkSettings(); - if (!listenerWired) { - chrome.storage.onChanged.addListener((changes, area) => { - if (area === 'sync' && changes[STORAGE_KEY]) { - cache = { ...DEFAULT_WORK_SETTINGS, ...(changes[STORAGE_KEY].newValue || {}) }; - } - }); - listenerWired = true; - } -} - -export function getWorkSettings(): WorkSettings { - return cache; -} -``` - -- [ ] **Step 4: Run tests, verify they pass** - -Run: `npm test -- src/utils/workSettings.test.ts` -Expected: PASS (5 tests). - -- [ ] **Step 5: Commit** - -```bash -git add src/utils/workSettings.ts src/utils/workSettings.test.ts -git commit -m "feat: add workSettings module with sync cache" -``` - ---- - -## Task 3: Wire `time.ts` to `hoursPerDay` / `hoursPerWeek` - -**Files:** -- Modify: `src/utils/time.ts` -- Test: `src/utils/time.test.ts` - -**Interfaces:** -- Consumes: `getWorkSettings()` from Task 2. -- Produces: unchanged signatures `parseTimeToHours(timeStr): number`, `formatHours(hours): number`. - -- [ ] **Step 1: Write failing tests** - -Create `src/utils/time.test.ts`: -```ts -import { describe, it, expect, beforeEach } from 'vitest'; -import { installChromeMock } from '../test/chromeMock'; -import { DEFAULT_WORK_SETTINGS, saveWorkSettings, initWorkSettings } from './workSettings'; -import { parseTimeToHours, formatHours } from './time'; - -describe('time conversions honor workSettings', () => { - beforeEach(() => installChromeMock()); - - it('uses default 8h/day, 40h/week', async () => { - await initWorkSettings(); - expect(parseTimeToHours('1d')).toBe(8); - expect(parseTimeToHours('1w')).toBe(40); - expect(formatHours(8)).toBe('1d'); - expect(formatHours(40)).toBe('1w'); - }); - - it('honors custom 6h/day, 30h/week', async () => { - saveWorkSettings({ ...DEFAULT_WORK_SETTINGS, hoursPerDay: 6, hoursPerWeek: 30 }); - await initWorkSettings(); - expect(parseTimeToHours('1d')).toBe(6); - expect(parseTimeToHours('1w')).toBe(30); - expect(formatHours(6)).toBe('1d'); - expect(formatHours(30)).toBe('1w'); - }); -}); -``` - -- [ ] **Step 2: Run tests, verify they fail** - -Run: `npm test -- src/utils/time.test.ts` -Expected: FAIL — custom-values case returns 8/40 (still hardcoded). - -- [ ] **Step 3: Update `time.ts`** - -Add the import at the top (after the existing `TimeUnit` import): -```ts -import { getWorkSettings } from './workSettings'; -``` - -Replace `parseTimeToHours` body's switch with cache-driven conversions: -```ts - const { hoursPerDay, hoursPerWeek } = getWorkSettings(); - switch (unit) { - case 'w': - return value * hoursPerWeek; - case 'd': - return value * hoursPerDay; - case 'h': - return value; - case 'm': - return value / 60; - default: - return 0; - } -``` - -Replace the magic numbers in `formatHours`: -```ts -export function formatHours(hours: number): string { - if (hours === 0) return '0h'; - const { hoursPerDay, hoursPerWeek } = getWorkSettings(); - - if (hours >= hoursPerWeek) { - const weeks = Math.floor(hours / hoursPerWeek); - const remainingHours = hours % hoursPerWeek; - return remainingHours > 0 ? `${weeks}w ${remainingHours}h` : `${weeks}w`; - } - - if (hours >= hoursPerDay) { - const days = Math.floor(hours / hoursPerDay); - const remainingHours = hours % hoursPerDay; - return remainingHours > 0 ? `${days}d ${remainingHours}h` : `${days}d`; - } - - return `${hours}h`; -} -``` - -- [ ] **Step 4: Run tests, verify they pass** - -Run: `npm test -- src/utils/time.test.ts` -Expected: PASS (2 tests). - -- [ ] **Step 5: Commit** - -```bash -git add src/utils/time.ts src/utils/time.test.ts -git commit -m "feat: drive time.ts conversions from work settings" -``` - ---- - -## Task 4: Initialize the cache at every entry point - -**Files:** -- Modify: `src/options.ts:3162` (DOMContentLoaded handler) -- Modify: `src/popup.ts:1341` (DOMContentLoaded handler) -- Modify: `src/content.ts` (top-level init) -- Modify: `src/background.ts` (alarm handler that formats hours) - -**Interfaces:** -- Consumes: `initWorkSettings()` from Task 2. - -- [ ] **Step 1: options.ts** - -Add import near the other util imports: -```ts -import { initWorkSettings } from './utils/workSettings'; -``` -Inside the `document.addEventListener('DOMContentLoaded', async () => {` body (line ~3162), as the FIRST awaited call: -```ts - await initWorkSettings(); -``` - -- [ ] **Step 2: popup.ts** - -Add import: -```ts -import { initWorkSettings } from './utils/workSettings'; -``` -Inside the `DOMContentLoaded` async handler (line ~1341), first line of the body: -```ts - await initWorkSettings(); -``` - -- [ ] **Step 3: content.ts** - -Add import alongside the themeManager imports: -```ts -import { initWorkSettings } from './utils/workSettings'; -``` -Add a top-level init (near the existing `loadCustomColors().then(...)` at line ~52) so the cache warms before features run: -```ts -initWorkSettings(); -``` -Then, inside the main feature-init `DOMContentLoaded` handler (line ~310), make the callback `async` and await before feature work: -```ts - document.addEventListener('DOMContentLoaded', async () => { - await initWorkSettings(); - debugLog('GitLab Ninja: DOMContentLoaded fired'); - // ...existing body... -``` -(The top-level call + the awaited call share the wired listener; the second `initWorkSettings()` just re-reads — cheap and guarantees ordering.) - -- [ ] **Step 4: background.ts** - -Add import: -```ts -import { initWorkSettings } from './utils/workSettings'; -``` -In the alarm/notification handler that calls `formatHours`/`parseTimeToHours`, `await initWorkSettings();` before computing hours (service workers restart, so warm the cache per handler invocation). - -- [ ] **Step 5: Verify build + types** - -Run: `npm run type-check && npm run build` -Expected: no errors. - -- [ ] **Step 6: Commit** - -```bash -git add src/options.ts src/popup.ts src/content.ts src/background.ts -git commit -m "feat: initialize work settings cache at entry points" -``` - ---- - -## Task 5: Daily target from settings (`boardSettings.ts`) - -**Files:** -- Modify: `src/features/boardSettings.ts:15` and its usage - -- [ ] **Step 1: Find the usage** - -Run: `grep -n "DAILY_TARGET_SECONDS" src/features/boardSettings.ts` -Expected: the `const` at line 15 plus one or more usages. - -- [ ] **Step 2: Replace the constant with a settings read** - -Add import at top: -```ts -import { getWorkSettings } from '../utils/workSettings'; -``` -Delete the line: -```ts -const DAILY_TARGET_SECONDS = 30240; // 8h 24m -``` -At each usage site, replace `DAILY_TARGET_SECONDS` with `getWorkSettings().dailyTargetSeconds`. - -- [ ] **Step 3: Verify build** - -Run: `npm run type-check && npm run build` -Expected: no errors (no remaining reference to `DAILY_TARGET_SECONDS`). - -- [ ] **Step 4: Commit** - -```bash -git add src/features/boardSettings.ts -git commit -m "feat: board daily target from work settings" -``` - ---- - -## Task 6: Warning threshold from settings (`timeTracking.ts`) - -**Files:** -- Modify: `src/features/timeTracking.ts:125` - -- [ ] **Step 1: Add import** - -```ts -import { getWorkSettings } from '../utils/workSettings'; -``` - -- [ ] **Step 2: Replace the literal** - -At line 125, change: -```ts - if (t.spent < t.estimate && t.spent / t.estimate > 0.8) return 'warning'; -``` -to: -```ts - if (t.spent < t.estimate && t.spent / t.estimate > getWorkSettings().warningThreshold) - return 'warning'; -``` - -- [ ] **Step 3: Verify build** - -Run: `npm run type-check && npm run build` -Expected: no errors. - -- [ ] **Step 4: Commit** - -```bash -git add src/features/timeTracking.ts -git commit -m "feat: estimate warning threshold from work settings" -``` - ---- - -## Task 7: Day start time + calendar scroll (`options.ts`) - -**Files:** -- Modify: `src/options.ts:104-110` (`parseTimeFromISO`), `:414` (`createTimelog`), `:1280-1284` (scroll) - -- [ ] **Step 1: Ensure the import exists** - -`getWorkSettings` should be imported in options.ts (add if absent): -```ts -import { getWorkSettings, initWorkSettings } from './utils/workSettings'; -``` - -- [ ] **Step 2: Default timelog time (line ~414)** - -Replace: -```ts - const fullSpentAt = spentAt.includes('T') ? spentAt : `${spentAt}T09:00:00`; -``` -with: -```ts - const fullSpentAt = spentAt.includes('T') - ? spentAt - : `${spentAt}T${getWorkSettings().dayStartTime}:00`; -``` - -- [ ] **Step 3: ISO fallback time (lines ~105, ~108)** - -Add a helper at the top of `parseTimeFromISO`: -```ts -function parseTimeFromISO(iso: string): { hours: number; minutes: number } { - const [dh, dm] = getWorkSettings().dayStartTime.split(':').map((n) => parseInt(n, 10)); - if (!iso.includes('T')) return { hours: dh, minutes: dm }; - const timePart = iso.split('T')[1]; - const match = timePart.match(/^(\d{2}):(\d{2})/); - if (!match) return { hours: dh, minutes: dm }; - return { hours: parseInt(match[1], 10), minutes: parseInt(match[2], 10) }; -} -``` - -- [ ] **Step 4: Calendar scroll = dayStart − 30 min (line ~1280-1284)** - -Replace: -```ts - // Scroll to 8:30 by default on initial render - const calBody = content.querySelector('.cal-body'); - if (calBody && calBody.scrollTop === 0) { - calBody.scrollTop = (8.5 - gridStartHour) * CAL_PX_PER_HOUR; - } -``` -with: -```ts - // Scroll so the day-start (minus 30 min for context) is at the top on first render - const calBody = content.querySelector('.cal-body'); - if (calBody && calBody.scrollTop === 0) { - const [sh, sm] = getWorkSettings().dayStartTime.split(':').map((n) => parseInt(n, 10)); - const scrollHour = sh + sm / 60 - 0.5; - calBody.scrollTop = (scrollHour - gridStartHour) * CAL_PX_PER_HOUR; - } -``` - -- [ ] **Step 5: Verify build** - -Run: `npm run type-check && npm run build` -Expected: no errors. Default `09:00` → scroll anchor `8.5` (unchanged from before). - -- [ ] **Step 6: Commit** - -```bash -git add src/options.ts -git commit -m "feat: day start time + calendar scroll from work settings" -``` - ---- - -## Task 8: Weekend days from settings (`options.ts`) - -**Files:** -- Modify: `src/options.ts:461`, `src/options.ts:1186` - -- [ ] **Step 1: Weekly overview weekend class (line ~461)** - -Replace: -```ts - const weekendClass = i >= 5 ? ' weekend' : ''; -``` -with: -```ts - const weekendClass = getWorkSettings().weekendDays.includes(i) ? ' weekend' : ''; -``` - -- [ ] **Step 2: Calendar weekend class (line ~1186)** - -Replace: -```ts - const weekendClass = !hideWeekends && i >= 5 ? ' cal-weekend' : ''; -``` -with: -```ts - const weekendClass = - !hideWeekends && getWorkSettings().weekendDays.includes(i) ? ' cal-weekend' : ''; -``` - -- [ ] **Step 3: Check for other weekend assumptions** - -Run: `grep -n "hideWeekends\|>= 5\|weekend" src/options.ts` -For any remaining `i >= 5` / `>= 5` that filters out weekend days when `hideWeekends` is on (e.g. a `visibleDays` filter), replace the predicate with `!getWorkSettings().weekendDays.includes(i)`. Show the change for each match before editing. - -- [ ] **Step 4: Verify build** - -Run: `npm run type-check && npm run build` -Expected: no errors. - -- [ ] **Step 5: Commit** - -```bash -git add src/options.ts -git commit -m "feat: weekend days from work settings" -``` - ---- - -## Task 9: Time increment — snap + picker step - -**Files:** -- Modify: `src/options.ts:1444-1447` (`pxToDurationSeconds`) -- Modify: `src/features/editMode.ts:250-251` (picker loop) - -- [ ] **Step 1: Calendar drag snap (options.ts ~1444)** - -Replace: -```ts - function pxToDurationSeconds(px: number): number { - const raw = Math.round((px / CAL_PX_PER_HOUR) * 3600); - return Math.max(900, Math.round(raw / 900) * 900); // min 15min, snap 15min - } -``` -with: -```ts - function pxToDurationSeconds(px: number): number { - const snap = getWorkSettings().timeIncrementMinutes * 60; - const raw = Math.round((px / CAL_PX_PER_HOUR) * 3600); - return Math.max(snap, Math.round(raw / snap) * snap); // snap to time increment - } -``` - -- [ ] **Step 2: Time-picker step (editMode.ts ~250)** - -Add import at top of `editMode.ts`: -```ts -import { getWorkSettings } from '../utils/workSettings'; -``` -Replace the inner loop step: -```ts - for (let h = 0; h < 24; h++) { - for (let m = 0; m < 60; m += getWorkSettings().timeIncrementMinutes) { -``` - -- [ ] **Step 3: Verify build** - -Run: `npm run type-check && npm run build` -Expected: no errors. - -- [ ] **Step 4: Commit** - -```bash -git add src/options.ts src/features/editMode.ts -git commit -m "feat: time increment (snap + picker step) from work settings" -``` - ---- - -## Task 10: "Work" settings tab UI - -**Files:** -- Modify: `src/options.html` (tab button + tab content) -- Modify: `src/options.ts` (populate form, auto-save, reset) - -**Interfaces:** -- Consumes: `loadWorkSettings`, `saveWorkSettings`, `DEFAULT_WORK_SETTINGS` from Task 2. - -- [ ] **Step 1: Add the tab button (options.html ~2122)** - -After the Connection tab button, add: -```html - -``` - -- [ ] **Step 2: Add the tab content panel (options.html, after the connection panel ends ~2188)** - -```html -
-
-
- - -
Default time for new logs; calendar scrolls here.
-
-
- -
- h - m -
-
-
- - -
-
- -
-
-
- - -
-
- - -
Affects how 1d estimates display.
-
-
- - -
Affects how 1w estimates display.
-
-
- - -
-
-
-``` -(If `.form-hint` is not an existing class, reuse `.color-section-desc` instead — check with `grep -n "form-hint\|color-section-desc" src/options.html` and use whichever exists.) - -- [ ] **Step 3: Add the init function (options.ts)** - -Add import: -```ts -import { - loadWorkSettings, - saveWorkSettings, - DEFAULT_WORK_SETTINGS, - WorkSettings, -} from './utils/workSettings'; -``` -Add a new function (near `initNotificationSettings`): -```ts -const WS_DAY_LABELS = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun']; - -function readWorkForm(): WorkSettings { - const th = parseInt((document.getElementById('wsTargetH') as HTMLInputElement).value || '0', 10); - const tm = parseInt((document.getElementById('wsTargetM') as HTMLInputElement).value || '0', 10); - const weekendDays: number[] = []; - WS_DAY_LABELS.forEach((_, i) => { - const cb = document.getElementById(`wsWeekend-${i}`) as HTMLInputElement | null; - if (cb?.checked) weekendDays.push(i); - }); - return { - dayStartTime: (document.getElementById('wsDayStart') as HTMLInputElement).value || '09:00', - dailyTargetSeconds: th * 3600 + tm * 60, - warningThreshold: - parseInt((document.getElementById('wsWarn') as HTMLInputElement).value || '80', 10) / 100, - weekendDays, - timeIncrementMinutes: parseInt( - (document.getElementById('wsIncrement') as HTMLSelectElement).value, - 10 - ), - hoursPerDay: parseFloat((document.getElementById('wsHoursDay') as HTMLInputElement).value || '8'), - hoursPerWeek: parseFloat( - (document.getElementById('wsHoursWeek') as HTMLInputElement).value || '40' - ), - }; -} - -function populateWorkForm(s: WorkSettings): void { - (document.getElementById('wsDayStart') as HTMLInputElement).value = s.dayStartTime; - (document.getElementById('wsTargetH') as HTMLInputElement).value = String( - Math.floor(s.dailyTargetSeconds / 3600) - ); - (document.getElementById('wsTargetM') as HTMLInputElement).value = String( - Math.round((s.dailyTargetSeconds % 3600) / 60) - ); - (document.getElementById('wsWarn') as HTMLInputElement).value = String( - Math.round(s.warningThreshold * 100) - ); - (document.getElementById('wsIncrement') as HTMLSelectElement).value = String( - s.timeIncrementMinutes - ); - (document.getElementById('wsHoursDay') as HTMLInputElement).value = String(s.hoursPerDay); - (document.getElementById('wsHoursWeek') as HTMLInputElement).value = String(s.hoursPerWeek); - - const wrap = document.getElementById('wsWeekend')!; - wrap.innerHTML = WS_DAY_LABELS.map( - (label, i) => - `` - ).join(''); -} - -function initWorkSettingsForm(): void { - loadWorkSettings().then(populateWorkForm); - - const status = document.getElementById('wsSaveStatus'); - const onChange = () => { - saveWorkSettings(readWorkForm()); - if (status) { - status.textContent = 'Saved'; - setTimeout(() => (status.textContent = ''), 1500); - } - }; - - const panel = document.querySelector('[data-settings-tab-content="work"]'); - panel?.addEventListener('change', onChange); - - document.getElementById('wsResetBtn')?.addEventListener('click', () => { - populateWorkForm({ ...DEFAULT_WORK_SETTINGS }); - saveWorkSettings({ ...DEFAULT_WORK_SETTINGS }); - if (status) { - status.textContent = 'Reset'; - setTimeout(() => (status.textContent = ''), 1500); - } - }); -} -``` - -- [ ] **Step 4: Call the init in DOMContentLoaded** - -In the `DOMContentLoaded` async handler (after `initNotificationSettings()` is called), add: -```ts - initWorkSettingsForm(); -``` - -- [ ] **Step 5: Verify build + lint** - -Run: `npm run check` -Expected: type-check, lint, format:check, build all pass. (Run `npm run format` first if format:check fails.) - -- [ ] **Step 6: Commit** - -```bash -git add src/options.html src/options.ts -git commit -m "feat: Work settings tab in options UI" -``` - ---- - -## Task 11: Full verification - -**Files:** none (verification only) - -- [ ] **Step 1: Run the whole suite + checks** - -Run: `npm test && npm run check` -Expected: all tests pass; type-check, lint, format:check, build all pass. - -- [ ] **Step 2: Manual smoke test (load unpacked extension)** - -Run: `npm run build`, then load `dist/` as an unpacked extension in Chrome. Verify: -- Fresh profile (no `workSettings` key): every behavior matches today (default log time 09:00, calendar scrolls to 08:30, weekend = Sat/Sun, 15-min snap, `1d`=8h display, warning at 80%, board daily target 8h24m). -- Open options → **Work** tab. Change each field; confirm: - - Day start → new logs default to it; calendar scrolls to it −30 min. - - Daily target → board target reflects new value. - - Warning % → a card crossing the new threshold turns "warning". - - Weekend days → weekend styling moves to the selected days (and `hideWeekends` hides the new set). - - Time increment → calendar drag snaps to it; edit-mode picker steps by it. - - Hours/day & hours/week → `1d`/`1w` estimate displays reflow. -- Reload the options page: all values persist. -- **Reset to Defaults**: all fields return to defaults and persist. - -- [ ] **Step 3: Final commit (only if Step 2 surfaced fixes)** - -```bash -git add -A -git commit -m "fix: work settings verification follow-ups" -``` - ---- - -## Self-Review Notes - -- **Spec coverage:** all 7 settings have tasks (3,5,6,7,8,9 wire them; 10 exposes them); module + cache (Task 2), init (Task 4), test infra (Task 1), verification (Task 11). ✓ -- **No `notificationSettings` change** — honored (Task 4 only adds init; daily target kept separate). ✓ -- **Type consistency:** `WorkSettings` field names identical across Tasks 2/3/10; `getWorkSettings()`/`loadWorkSettings()`/`saveWorkSettings()` used consistently. ✓ -- **Weekday convention** Mon=0…Sun=6 consistent in Tasks 2, 8, 10. ✓ -- **Open assumption to confirm during execution:** Task 8 Step 3 — there may be a `visibleDays` filter elsewhere in options.ts that also encodes `>= 5`; the grep step catches it. 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 deleted file mode 100644 index 3ba6a6b..0000000 --- a/docs/superpowers/specs/2026-06-23-timelog-draft-mode-design.md +++ /dev/null @@ -1,180 +0,0 @@ -# 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/docs/superpowers/specs/2026-06-24-configurable-work-settings-design.md b/docs/superpowers/specs/2026-06-24-configurable-work-settings-design.md deleted file mode 100644 index 825b30c..0000000 --- a/docs/superpowers/specs/2026-06-24-configurable-work-settings-design.md +++ /dev/null @@ -1,159 +0,0 @@ -# Configurable Work Settings — Design - -**Date:** 2026-06-24 -**Status:** Approved (design), pending implementation plan - -## Goal - -Promote seven currently-hardcoded constants to user-configurable settings, exposed in -a new "Work" tab on the options page. Defaults equal the current hardcoded values, so -existing users see **no behavior change** until they opt in. - -## Settings - -| # | Setting | Default | Drives | -|---|---------|---------|--------| -| 1 | `dayStartTime` | `"09:00"` | Default timelog time **and** calendar scroll position | -| 2 | `dailyTargetSeconds` | `30240` (8h 24m) | Board daily work target | -| 3 | `warningThreshold` | `0.8` | Estimate-spent "warning" status threshold | -| 4 | `weekendDays` | `[5, 6]` (Sat, Sun) | Which weekdays count as weekend | -| 5 | `timeIncrementMinutes` | `15` | Calendar drag snap **and** time-picker step | -| 6 | `hoursPerDay` | `8` | GitLab estimate unit conversion (`1d` = N hours) | -| 7 | `hoursPerWeek` | `40` | GitLab estimate unit conversion (`1w` = N hours) | - -### Resolved design decisions - -- **Day start merged.** Default timelog time (was `09:00`) and calendar scroll (was - `08:30`) collapse into one `dayStartTime`. The calendar scrolls to - `dayStartTime − 30 min` (preserves the prior 09:00 → 08:30 relationship). -- **Daily target stays separate** from notification `minHours`. `notificationSettings` - is left untouched. -- **Hours/day and hours/week are both exposed.** They affect how all estimate / spent - times render via `time.ts`. Matches GitLab's own configurable convention. -- **Weekend = set, hide = toggle.** The existing `hideWeekends` toggle is unchanged; it - now hides whatever days `weekendDays` defines. - -Out of scope (explicitly left hardcoded): debounce delays, DOM/alarm timeouts, render -math (min block height, grid range, header widths), 75% color thresholds, accuracy -ratios, effort tiers, due-date ranges, search limits. - -## Architecture - -### New module: `src/utils/workSettings.ts` - -Follows the existing `themeManager.ts` pattern (typed interface + `DEFAULT_*` + -async load / sync save) **plus** a synchronous in-memory cache, required because -`time.ts` functions are pure and synchronous. - -```ts -export interface WorkSettings { - dayStartTime: string; // "HH:MM" - dailyTargetSeconds: number; - warningThreshold: number; // 0..1 - weekendDays: number[]; // 0 = Mon … 6 = Sun - timeIncrementMinutes: number; - hoursPerDay: number; - hoursPerWeek: number; -} - -export const DEFAULT_WORK_SETTINGS: WorkSettings = { - dayStartTime: '09:00', - dailyTargetSeconds: 30240, - warningThreshold: 0.8, - weekendDays: [5, 6], - timeIncrementMinutes: 15, - hoursPerDay: 8, - hoursPerWeek: 40, -}; - -// async, merges stored partial over defaults (like loadCustomColors) -export async function loadWorkSettings(): Promise; -export function saveWorkSettings(s: WorkSettings): void; - -// sync cache for pure functions -export async function initWorkSettings(): Promise; // await once at startup; wires storage.onChanged -export function getWorkSettings(): WorkSettings; // returns cache, or DEFAULT_WORK_SETTINGS if not yet init -``` - -- Single storage key `workSettings` in `chrome.storage.sync`. -- `loadWorkSettings` merges `{ ...DEFAULT_WORK_SETTINGS, ...stored }` so partial / - future-versioned objects degrade safely. No migration needed (absent key → defaults - → current behavior). -- `initWorkSettings` populates the module cache and registers a - `chrome.storage.onChanged` listener that refreshes the cache on any `workSettings` - write. This makes options-page edits apply live (same UX as `themeManager`). -- `getWorkSettings` is the synchronous accessor. Contract: callers in pure/sync paths - rely on `initWorkSettings` having been awaited at startup; if not, they get - `DEFAULT_WORK_SETTINGS` (safe fallback, never throws). - -### Startup init - -`await initWorkSettings()` is added at the top of each entry point before feature code -runs: `background.ts`, the content-script entry (`content.ts`), `options.ts`, and -`popup.ts`. - -### Call-site changes - -| Setting | File:line (approx) | Change | -|---------|--------------------|--------| -| Day start (log) | `options.ts:414`, `options.ts:105/108` | use `getWorkSettings().dayStartTime` in place of literal `09:00` | -| Calendar scroll | `options.ts:1283` | scroll to `dayStartTime − 30 min` | -| Daily target | `boardSettings.ts:15` | `DAILY_TARGET_SECONDS` → `getWorkSettings().dailyTargetSeconds` | -| Warning % | `timeTracking.ts:125` | `0.8` → `getWorkSettings().warningThreshold` | -| Weekend days | `options.ts:461`, `options.ts:1186` | `=== 5 / >= 5` checks → `weekendDays.includes(d)` | -| Time increment | `options.ts:1446` (snap `900`s), `editMode.ts:251` (step `15`) | derive from `timeIncrementMinutes` (`× 60` for seconds) | -| Hours/day | `time.ts:22-23, 45-47` | `8` → `getWorkSettings().hoursPerDay` | -| Hours/week | `time.ts:20-21, 39-42` | `40` → `getWorkSettings().hoursPerWeek` | - -`time.ts` reads the cache synchronously via `getWorkSettings()` — no signature changes -to `parseTimeToHours` / `formatHours`, so the 20 call sites across `background.ts`, -`timeTracking.ts`, `columnSummary.ts`, `editMode.ts` are untouched. - -## UI — "Work" settings tab - -New tab in the options Settings card (`src/options.html`, `src/options.ts`), placed -after **Connection**: `data-settings-tab="work"`, label "Work". Reuses existing -`.notif-card` / `.form-input` / `.settings-tab` markup and styles. - -Controls: - -| Setting | Control | Notes | -|---------|---------|-------| -| Day start time | `` | | -| Daily work target | hours + minutes inputs → stored as seconds | | -| Estimate warning % | number input 50–100 → stored `/100` | | -| Weekend days | 7 day checkboxes (Mon–Sun) → `weekendDays` array | | -| Time increment | `