Skip to content
This repository was archived by the owner on Jul 15, 2026. It is now read-only.
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
70 changes: 70 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
# CLAUDE.md

This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.

## Commands

```bash
npm test # Run tests once (Vitest + JSDOM)
npm run test:watch # Run tests in watch mode
npm run test:coverage # Generate v8 coverage report
npm run check:syntax # Validate JS syntax via Acorn (extracts <script> from index.html)
npm run build:min # Minify HTML/CSS/JS into build/sp-dashboard/
npm run screenshot # Regenerate assets/ screenshots via Puppeteer
make build # Full plugin build → sp-dashboard.zip
make release-check # Verify prerequisites before releasing (clean state, tag, gh CLI)
make release # Tag, push, create GitHub release (requires clean git state + gh CLI)
make clean # Remove generated files
```

To run a single test: `npx vitest run --reporter=verbose tests/index.test.js -t "test name pattern"`

## Architecture

This is a **Super Productivity plugin** — a sandboxed iframe widget. All UI logic must live in `sp-dashboard/index.html` as a self-contained file (embedded CSS + JS, no external runtime dependencies).

### Two-file plugin model

- **`sp-dashboard/plugin.js`** — runs in the host app context. Registers an ACTION Redux hook with `PluginAPI.addEventListener`, then fires a `postMessage` to the iframe on every state change. This is the only bridge between the host app and the UI.
- **`sp-dashboard/index.html`** — runs in an isolated iframe. Receives `SP_STATE_CHANGED` messages and pulls fresh data via `PluginAPI.getTasks()` / `getArchivedTasks()` / `getAllProjects()`. All rendering, state, and logic lives here.

Available PluginAPI methods (beyond data fetching): `showSnack({ msg, ico })` for toast notifications, `getStorage()` / `setStorage(data)` for persistence (declared in manifest but currently unused).

### Data flow inside index.html

```
postMessage → loadData() → PluginAPI calls → cachedTasks / cachedProjects
→ processData(tasks, projects, dateRange) → metrics object
→ updateDashboardUI() (stat cards)
→ updateBarChart() (weekly time, CSS flex bars)
→ updatePieChart() (project breakdown, CSS conic-gradient)
→ renderTable() (detailed entries, sortable)
```

`processData()` is the core aggregation function. It deduplicates active + archived tasks (Map by ID, active takes precedence), filters by date range, and computes: time spent, completion counts, overdue/late flags, per-day breakdowns, and per-project summaries.

### Mock data fallback

If `PluginAPI` is unavailable (standalone file:// development), a 500ms timeout injects mock data so the full UI renders without the host app.

### Charts

No charting library. Bar chart uses CSS flexbox with `height` set as a percentage of max value; it automatically buckets data when the date range exceeds 30 days. Pie/donut chart uses a single `<div>` with `conic-gradient` computed from cumulative percentages.

### Theming

All colors are CSS custom properties (`--bg`, `--text-color`, `--c-primary`, etc.). Dark mode is toggled by `.dark-theme` on `<body>` — mirroring the host app's class injection.

### Build pipeline

`make build` runs: template substitution on `manifest.json.template` (injects VERSION/DESCRIPTION) → `scripts/minify.sh` (html-minifier-terser) → zip packaging. Version is the single source of truth in `package.json`.

## Testing

Tests live in `tests/index.test.js` and use Vitest with a JSDOM environment. The test harness sets `document.documentElement.innerHTML = html`, then executes the `<script>` block via `new Function()` — this means **any function you want to test must be explicitly assigned to `window`** inside the script (e.g. `window.processData = processData`). Mock `PluginAPI` is injected via `global.PluginAPI` before each test.

## Key constraints

- Keep `index.html` self-contained — no `import` statements, no external CDN links, no `require()`.
- All user-visible strings in the UI must be sanitized before insertion into the DOM (use `textContent`, not `innerHTML`, for any data-derived content).
- Plugin permissions are declared in `manifest.json.template`; `persistDataSynced` / `loadSyncedData` are declared but currently unused.
52 changes: 35 additions & 17 deletions sp-dashboard/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -265,6 +265,7 @@ <h1 class="title">Dashboard</h1>
<label for="date-preset">Period</label>
<select id="date-preset">
<option value="today" selected>Today</option>
<option value="this-week">This Week</option>
<option value="week">Past Week</option>
<option value="month">Past Month</option>
<option value="year">Past Year</option>
Expand Down Expand Up @@ -417,17 +418,19 @@ <h3 class="card-title">Project Breakdown</h3>
return `${hours}h ${minutes}m`;
};

const toLocalDate = d => `${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,'0')}-${String(d.getDate()).padStart(2,'0')}`;

const formatDateShort = (dateStr) => {
const d = new Date(dateStr + "T00:00:00");
return d.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' });
};

const getDatesInRange = (startDate, endDate) => {
const dates = [];
let currentDate = new Date(startDate);
const end = new Date(endDate);
let currentDate = new Date(startDate.split('T')[0] + "T00:00:00");
const end = new Date(endDate.split('T')[0] + "T00:00:00");
while (currentDate <= end) {
dates.push(new Date(currentDate).toISOString().split('T')[0]);
dates.push(toLocalDate(currentDate));
currentDate.setDate(currentDate.getDate() + 1);
}
return dates;
Expand Down Expand Up @@ -512,8 +515,8 @@ <h3 class="card-title">Project Breakdown</h3>
const todayObjInit = new Date();
const lastWeekObjInit = new Date();
lastWeekObjInit.setDate(todayObjInit.getDate() - 6);
document.getElementById('date-from').value = todayObjInit.toISOString().split('T')[0];
document.getElementById('date-to').value = todayObjInit.toISOString().split('T')[0];
document.getElementById('date-from').value = toLocalDate(todayObjInit);
document.getElementById('date-to').value = toLocalDate(todayObjInit);

const presetSelect = document.getElementById('date-preset');
const customContainer = document.getElementById('custom-date-container');
Expand Down Expand Up @@ -736,13 +739,18 @@ <h3 class="card-title">Project Breakdown</h3>
const getDueBounds = (task) => {
let dueStart = null;
if (task.dueDay) {
// parse original YYYY-MM-DD string – Date.parse yields UTC midnight
const parsed = Date.parse(task.dueDay);
// parse as local midnight so due-window aligns with the user's calendar day
Comment thread
ahanel13 marked this conversation as resolved.
const parsed = new Date(task.dueDay.split('T')[0] + "T00:00:00").getTime();
if (!isNaN(parsed)) {
dueStart = parsed; // no timezone shift
dueStart = parsed;
}
}
const dueEnd = dueStart !== null ? dueStart + 86400000 - 1 : null;
let dueEnd = null;
if (dueStart !== null) {
const d = new Date(dueStart);
d.setDate(d.getDate() + 1);
dueEnd = d.getTime() - 1;
}
return { dueStart, dueEnd };
};

Expand All @@ -756,16 +764,23 @@ <h3 class="card-title">Project Breakdown</h3>
dateToStr = document.getElementById('date-to').value;
} else {
const endObj = new Date();
const startObj = new Date();
if (preset === 'week') {
const startObj = new Date(endObj);
if (preset === 'this-week') {
Comment thread
ahanel13 marked this conversation as resolved.
const day = endObj.getDay(); // 0=Sun, 1=Mon, …, 6=Sat
const diff = day === 0 ? 6 : day - 1; // days back to most recent Monday
startObj.setDate(endObj.getDate() - diff);
} else if (preset === 'week') {
startObj.setDate(endObj.getDate() - 6);
} else if (preset === 'month') {
startObj.setMonth(endObj.getMonth() - 1);
if (startObj.getDate() !== endObj.getDate()) {
startObj.setDate(0); // rewind to last day of previous month
}
} else if (preset === 'year') {
startObj.setFullYear(endObj.getFullYear() - 1);
}
dateFromStr = startObj.toISOString().split('T')[0];
dateToStr = endObj.toISOString().split('T')[0];
dateFromStr = toLocalDate(startObj);
dateToStr = toLocalDate(endObj);
}
const dateRange = getDatesInRange(dateFromStr, dateToStr);
console.log("[sp-dashboard] computed date range", dateFromStr, dateToStr, dateRange);
Expand Down Expand Up @@ -845,7 +860,9 @@ <h3 class="card-title">Project Breakdown</h3>

dateRange.forEach((dateStr, index) => {
const dayStart = new Date(dateStr + "T00:00:00").getTime();
const dayEnd = dayStart + 86400000 - 1;
const dayEndObj = new Date(dayStart);
dayEndObj.setDate(dayEndObj.getDate() + 1);
const dayEnd = dayEndObj.getTime() - 1;

// Time Spent Logging
const spentOnDate = task.timeSpentOnDay && task.timeSpentOnDay[dateStr];
Expand Down Expand Up @@ -884,7 +901,7 @@ <h3 class="card-title">Project Breakdown</h3>

// if completed in window with no time, add entry (but skip if we'll show an overdue/late badge)
if (taskTimeInRange === 0 && task.isDone && task.doneOn) {
const doneDate = new Date(task.doneOn).toISOString().split('T')[0];
const doneDate = toLocalDate(new Date(task.doneOn));
if (dateRange.includes(doneDate) && !(isOverdue || isLate)) {
metrics.tableEntries.push({
date: doneDate,
Expand All @@ -901,7 +918,7 @@ <h3 class="card-title">Project Breakdown</h3>
// add a row for overdue or late tasks that had no time entries
if (taskTimeInRange === 0 && (isOverdue || isLate)) {
const badge = isLate ? 'Late' : 'Overdue';
const dateStr = dueStart ? new Date(dueStart).toISOString().split('T')[0] : '';
const dateStr = dueStart ? toLocalDate(new Date(dueStart)) : '';
metrics.tableEntries.push({
date: dateStr,
projectName: pName,
Expand All @@ -921,7 +938,7 @@ <h3 class="card-title">Project Breakdown</h3>
}

// count tasks that had activity OR were completed OR are due (in range)
const taskCompletedInRange = task.isDone && task.doneOn && dateRange.includes(new Date(task.doneOn).toISOString().split('T')[0]);
const taskCompletedInRange = task.isDone && task.doneOn && dateRange.includes(toLocalDate(new Date(task.doneOn)));
const taskDueInRange = task.dueDay && dateRange.includes(task.dueDay);
if (taskTimeInRange > 0 || taskCompletedInRange || taskDueInRange) {
metrics.totalTasks++;
Expand Down Expand Up @@ -981,6 +998,7 @@ <h3 class="card-title">Project Breakdown</h3>
window.formatTime = formatTime;
window.formatDateShort = formatDateShort;
window.getDatesInRange = getDatesInRange;
window.getDueBounds = getDueBounds;
window.switchTab = switchTab;
window.processData = processData;
window.updateBarChart = updateBarChart;
Expand Down
89 changes: 78 additions & 11 deletions tests/index.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ import { resolve } from 'path';
// file moved into the sp-dashboard subdirectory
const html = readFileSync(resolve(__dirname, '../sp-dashboard/index.html'), 'utf8');

const toLocalDate = (d) => `${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,'0')}-${String(d.getDate()).padStart(2,'0')}`;

describe('Date Range Reporter UI', () => {
let scriptContent;

Expand Down Expand Up @@ -41,6 +43,43 @@ describe('Date Range Reporter UI', () => {
const range = window.getDatesInRange('2026-02-20', '2026-02-22');
expect(range).toEqual(['2026-02-20', '2026-02-21', '2026-02-22']);
});

it('getDatesInRange should tolerate full ISO timestamps as inputs', () => {
const range = window.getDatesInRange('2026-02-20T10:00:00Z', '2026-02-22T10:00:00Z');
expect(range).toEqual(['2026-02-20', '2026-02-21', '2026-02-22']);
});

it('getDueBounds dueEnd should be end-of-day (DST-safe calculation)', () => {
const { dueStart, dueEnd } = window.getDueBounds({ dueDay: '2026-03-28' });
expect(dueStart).not.toBeNull();
expect(dueEnd).toBeGreaterThan(dueStart);
expect(dueEnd - dueStart).toBeGreaterThanOrEqual(82800000); // at least 23h
expect(dueEnd - dueStart).toBeLessThanOrEqual(90000000); // at most 25h
});

it('getDueBounds should handle a full ISO timestamp in dueDay', () => {
const { dueStart, dueEnd } = window.getDueBounds({ dueDay: '2026-02-20T10:00:00Z' });
expect(dueStart).not.toBeNull();
expect(dueEnd).not.toBeNull();
// dueStart should parse to 2026-02-20 local midnight
expect(toLocalDate(new Date(dueStart))).toBe('2026-02-20');
});

it('month preset should not roll over when today is the 31st', () => {
// March 31, 2026 at noon — without the fix, setMonth(Feb) on Mar 31 rolls to Mar 3
vi.useFakeTimers({ now: new Date('2026-03-31T12:00:00').getTime() });
const consoleSpy = vi.spyOn(console, 'log');
const presetSelect = document.getElementById('date-preset');
presetSelect.value = 'month';
presetSelect.dispatchEvent(new Event('change'));
window.processData([], []);
vi.useRealTimers();
const rangeLog = consoleSpy.mock.calls.find(args => String(args[0]).includes('computed date range'));
expect(rangeLog).toBeDefined();
expect(rangeLog[1]).toBe('2026-02-28'); // should be Feb 28, not Mar 3
expect(rangeLog[2]).toBe('2026-03-31');
consoleSpy.mockRestore();
});
});

describe('Dashboard State Updates', () => {
Expand All @@ -52,14 +91,14 @@ describe('Date Range Reporter UI', () => {
title: 'Task 1',
isDone: true,
doneOn: new Date().getTime(),
timeSpentOnDay: { [new Date().toISOString().split('T')[0]]: 7200000 } // 2h
timeSpentOnDay: { [toLocalDate(new Date())]: 7200000 } // 2h
},
{
id: 't2',
parentId: null,
title: 'Task 2',
isDone: false,
timeSpentOnDay: { [new Date().toISOString().split('T')[0]]: 3600000 } // 1h
timeSpentOnDay: { [toLocalDate(new Date())]: 3600000 } // 1h
}
];
const mockProjects = [{ id: 'p1', title: 'Test Project' }];
Expand All @@ -79,7 +118,7 @@ describe('Date Range Reporter UI', () => {

it('should honor dueDay provided initially', () => {
const now = Date.now();
const dueStr = new Date(now - 86400000).toISOString().split('T')[0];
const dueStr = toLocalDate(new Date(now - 86400000));
const task = {
id: 't-initial',
parentId: null,
Expand Down Expand Up @@ -112,7 +151,7 @@ describe('Date Range Reporter UI', () => {
expect(document.getElementById('stat-overdue').innerText).toBe('0');

// add dueDay yesterday and trigger again
task.dueDay = new Date(now - 86400000).toISOString().split('T')[0];
task.dueDay = toLocalDate(new Date(now - 86400000));
window.processData(tasks, []);
expect(document.getElementById('stat-overdue').innerText).toBe('1');
});
Expand All @@ -134,7 +173,7 @@ describe('Date Range Reporter UI', () => {
expect(document.getElementById('stat-late').innerText).toBe('0');

// now add dueDay equal to today
task.dueDay = new Date(now).toISOString().split('T')[0];
task.dueDay = toLocalDate(new Date(now));
window.processData(tasks, []);
expect(document.getElementById('stat-overdue').innerText).toBe('0');
expect(document.getElementById('stat-late').innerText).toBe('0');
Expand All @@ -149,7 +188,7 @@ describe('Date Range Reporter UI', () => {
title: 'Done Late',
isDone: true,
doneOn: now,
dueDay: due.toISOString().split('T')[0],
dueDay: toLocalDate(due),
timeSpentOnDay: {}
};
window.processData([task], []);
Expand Down Expand Up @@ -178,7 +217,7 @@ describe('Date Range Reporter UI', () => {

it('should not mark a task due today as late if completed same day', () => {
const now = Date.now();
const todayStr = new Date(now).toISOString().split('T')[0];
const todayStr = toLocalDate(new Date(now));
const task = {
id: 't-due-today',
parentId: null,
Expand Down Expand Up @@ -206,7 +245,7 @@ describe('Date Range Reporter UI', () => {
title: 'subtask done',
isDone: true,
doneOn: now,
dueDay: new Date(now).toISOString().split('T')[0],
dueDay: toLocalDate(new Date(now)),
timeSpentOnDay: {}
};
window.processData([sub], []);
Expand All @@ -215,7 +254,7 @@ describe('Date Range Reporter UI', () => {
});

it('should count tasks due today in totalTasks denominator even with no time logged', () => {
const todayStr = new Date().toISOString().split('T')[0];
const todayStr = toLocalDate(new Date());
const taskDueToday = {
id: 't-due-no-time',
parentId: null,
Expand All @@ -239,7 +278,7 @@ describe('Date Range Reporter UI', () => {
title: 'Done Task',
isDone: true,
doneOn: now,
dueDay: new Date(now).toISOString().split('T')[0],
dueDay: toLocalDate(new Date(now)),
timeSpentOnDay: {}
};
// Simulate what happens when pullDataFromSP combines activeTasks and archivedTasks
Expand Down Expand Up @@ -313,10 +352,38 @@ describe('Date Range Reporter UI', () => {
expect(barContainer.querySelectorAll('.bar-col').length).toBe(1);
});

it('this-week preset should include Monday through today and exclude last Sunday', () => {
const presetSelect = document.getElementById('date-preset');
presetSelect.value = 'this-week';
presetSelect.dispatchEvent(new Event('change'));

// Build a task logged on last Sunday (always before this week's Monday)
const now = new Date();
const dayOfWeek = now.getDay();
const daysToMonday = dayOfWeek === 0 ? 6 : dayOfWeek - 1;
const lastSunday = new Date(now);
lastSunday.setDate(now.getDate() - daysToMonday - 1);
const lastSundayStr = toLocalDate(lastSunday);
const todayStr = toLocalDate(now);

const taskThisWeek = { id: 'tw1', parentId: null, title: 'This Week Task', isDone: true, doneOn: now.getTime(), timeSpentOnDay: { [todayStr]: 3600000 } };
const taskLastWeek = { id: 'tw2', parentId: null, title: 'Last Week Task', isDone: true, doneOn: lastSunday.getTime(), timeSpentOnDay: { [lastSundayStr]: 3600000 } };

window.processData([taskThisWeek, taskLastWeek], []);

// Only this week's task time should be counted
expect(document.getElementById('stat-time').innerText).toBe('1h 0m');

// Bar chart should have at most 7 bars (Mon–today)
const barContainer = document.getElementById('bar-chart-container');
expect(barContainer.querySelectorAll('.bar-col').length).toBeLessThanOrEqual(7);
expect(barContainer.querySelectorAll('.bar-col').length).toBeGreaterThanOrEqual(1);
});

it('bar and pie charts should render for overdue and late types and details show badges', () => {
// prepare metrics with one overdue task and one late task
const now = Date.now();
const yesterdayStr = new Date(now - 86400000).toISOString().split('T')[0];
const yesterdayStr = toLocalDate(new Date(now - 86400000));
const overdueTask = { id:'t1', parentId:null, title:'Foo', isDone:false, dueDay:'2026-02-20', timeSpentOnDay:{'2026-02-20':0} };
const lateTask = { id:'t2', parentId:null, title:'Bar', isDone:true, doneOn: now, dueDay: yesterdayStr, timeSpentOnDay:{} };
window.processData([overdueTask, lateTask], []);
Expand Down