Skip to content

Commit 983e0fa

Browse files
committed
Merge branch 'feature/improve-project-budget-views'
2 parents 569d674 + 06c7cdd commit 983e0fa

6 files changed

Lines changed: 231 additions & 3 deletions

File tree

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
11
*Unreleased*
2+
- 🗂️ New "Hide archived" checkbox on project budget pages to filter out archived tasks and mask totals
3+
- 🏷️ Non-billable badge now coexists with Harvest's Archived badge on the same row
24

35
*4.0.1* (2026-02-09)
46
- 📊 Capacity meter segments now show a tooltip on hover with category name and percentage

manifest.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
{
22
"manifest_version": 3,
33
"name": "Esti'mate",
4-
"version": "4.0.0",
4+
"version": "4.0.1",
55
"description": "Your Harvest estimate's best friend",
66
"author": "Antistatique",
77
"homepage_url": "https://esti-mate.antistatique.io/",
@@ -52,6 +52,7 @@
5252
"*://*.harvestapp.com/estimates/*/duplicate",
5353
"*://*.harvestapp.com/estimates/new",
5454
"*://*.harvestapp.com/estimates/*",
55+
"*://*.harvestapp.com/projects/*",
5556
"file:///Users/gde/Downloads/Estimate_Sandbox.html",
5657
"file:///Users/gde/Downloads/Estimate_New_Sandbox.html"
5758
],

src/content/App.js

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import PMTools from './components/PMTools.js';
55
import AirtableIntegration from './components/AirtableIntegration.js';
66
import SpellChecker from './components/SpellChecker.js';
77
import StickyTotals from './components/StickyTotals.js';
8+
import BillableBadge from './components/BillableBadge.js';
89

910
export default class App {
1011
constructor() {
@@ -13,16 +14,22 @@ export default class App {
1314
this.airtable = new AirtableIntegration();
1415
this.spellChecker = new SpellChecker();
1516
this.stickyTotals = new StickyTotals();
17+
this.billableBadge = new BillableBadge();
1618
this.settings = {};
1719
}
1820

1921
async init(appView) {
2022
try {
23+
if (appView === 'project') {
24+
this.billableBadge.init();
25+
return;
26+
}
27+
2128
await this.loadSettings();
2229
this.summary.init();
2330
// Always bind the spellchecker so the menu event works on any view
2431
this.spellChecker.init(this.settings, this);
25-
32+
2633
if (appView === 'edit' || appView === 'new') {
2734
this.stickyTotals.init();
2835
this.pmTools.init(this.settings, this);
Lines changed: 185 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,185 @@
1+
// File: src/content/components/BillableBadge.js
2+
3+
const BADGE_CLASS = 'esti-badge-nonbillable';
4+
const TOGGLE_CLASS = 'esti-archive-toggle';
5+
const HIDDEN_CLASS = 'esti-hidden';
6+
const MASKED_CLASS = 'esti-totals-masked';
7+
8+
export default class BillableBadge {
9+
constructor() {
10+
this.observer = null;
11+
this.projectId = null;
12+
this.billableMap = null;
13+
this.archivedSet = null;
14+
this.currentPeriod = null;
15+
this.hideArchived = false;
16+
this._updating = false;
17+
}
18+
19+
init() {
20+
const match = window.location.pathname.match(/^\/projects\/(\d+)$/);
21+
if (!match) return;
22+
this.projectId = match[1];
23+
24+
this.observer = new MutationObserver(() => this.onMutation());
25+
this.observer.observe(document.body, { childList: true, subtree: true });
26+
27+
// Apply immediately if rows are already present
28+
this.onMutation();
29+
}
30+
31+
async onMutation() {
32+
if (this._updating) return;
33+
this._updating = true;
34+
try {
35+
const rows = document.querySelectorAll('tr[data-task-id]');
36+
if (rows.length === 0) return;
37+
38+
// Detect current period from the dropdown
39+
const period = this.detectPeriod();
40+
41+
// Re-fetch if period changed or first run
42+
if (!this.billableMap || period !== this.currentPeriod) {
43+
this.currentPeriod = period;
44+
const result = await this.fetchTaskData(period);
45+
this.billableMap = result.billableMap;
46+
this.archivedSet = result.archivedSet;
47+
}
48+
49+
// Pause observer while we modify the DOM to avoid infinite loops
50+
this.observer.disconnect();
51+
this.applyBadges(rows);
52+
this.injectArchiveToggle();
53+
this.applyArchiveFilter();
54+
this.observer.observe(document.body, { childList: true, subtree: true });
55+
} finally {
56+
this._updating = false;
57+
}
58+
}
59+
60+
detectPeriod() {
61+
// Harvest's period dropdown stores value on a select or in the URL
62+
const select = document.querySelector('select[name="period"]');
63+
if (select) return select.value;
64+
65+
// Fallback: check URL params
66+
const params = new URLSearchParams(window.location.search);
67+
return params.get('period') || 'all_time';
68+
}
69+
70+
async fetchTaskData(period) {
71+
const billableMap = new Map();
72+
const archivedSet = new Set();
73+
try {
74+
const resp = await fetch(`/projects/${this.projectId}/analysis?period=${period}`, {
75+
credentials: 'same-origin',
76+
headers: { 'Accept': 'application/json' },
77+
});
78+
if (!resp.ok) return { billableMap, archivedSet };
79+
80+
const data = await resp.json();
81+
if (Array.isArray(data.tasks)) {
82+
for (const task of data.tasks) {
83+
const id = String(task.task_id);
84+
billableMap.set(id, task.billable);
85+
if (task.archived) archivedSet.add(id);
86+
}
87+
}
88+
} catch (e) {
89+
console.error('[Esti\'mate] Failed to fetch task data:', e);
90+
}
91+
return { billableMap, archivedSet };
92+
}
93+
94+
applyBadges(rows) {
95+
for (const row of rows) {
96+
const taskId = row.getAttribute('data-task-id');
97+
if (!taskId || !this.billableMap.has(taskId)) continue;
98+
99+
// Skip if badge already injected
100+
if (row.querySelector(`.${BADGE_CLASS}`)) continue;
101+
102+
const isBillable = this.billableMap.get(taskId);
103+
if (isBillable) continue;
104+
105+
const nameSpan = row.querySelector('td.col-name > span');
106+
if (!nameSpan) continue;
107+
108+
const badge = document.createElement('span');
109+
badge.className = `pds-badge ${BADGE_CLASS}`;
110+
badge.textContent = 'Non-billable';
111+
nameSpan.after(badge);
112+
}
113+
}
114+
115+
injectArchiveToggle() {
116+
if (document.querySelector(`.${TOGGLE_CLASS}`)) return;
117+
118+
const rightGroup = document.querySelector(
119+
'#project-tabs-timeframe .pds-flex-list > .pds-flex-list:last-child'
120+
);
121+
if (!rightGroup) return;
122+
123+
const wrapper = document.createElement('div');
124+
wrapper.className = `pds-checkbox ${TOGGLE_CLASS}`;
125+
126+
const input = document.createElement('input');
127+
input.type = 'checkbox';
128+
input.id = 'esti-hide-archived';
129+
input.checked = this.hideArchived;
130+
131+
const label = document.createElement('label');
132+
label.htmlFor = 'esti-hide-archived';
133+
label.textContent = 'Hide archived';
134+
135+
wrapper.append(input, label);
136+
rightGroup.prepend(wrapper);
137+
138+
input.addEventListener('change', () => {
139+
this.hideArchived = input.checked;
140+
this.applyArchiveFilter();
141+
});
142+
}
143+
144+
applyArchiveFilter() {
145+
if (!this.archivedSet) return;
146+
147+
const hiding = this.hideArchived;
148+
const rows = document.querySelectorAll('tr[data-task-id]');
149+
150+
for (const row of rows) {
151+
const taskId = row.getAttribute('data-task-id');
152+
if (hiding && this.archivedSet.has(taskId)) {
153+
row.classList.add(HIDDEN_CLASS);
154+
} else {
155+
row.classList.remove(HIDDEN_CLASS);
156+
}
157+
}
158+
159+
this.updateTotalsRow(hiding);
160+
}
161+
162+
updateTotalsRow(hiding) {
163+
// Harvest actively re-renders totals cells — never touch innerHTML.
164+
// Toggle a CSS class that visually masks numeric cells with "—".
165+
const totalsRows = document.querySelectorAll('tr.tbody-foot.js-totals');
166+
for (const row of totalsRows) {
167+
row.classList.toggle(MASKED_CLASS, hiding);
168+
}
169+
}
170+
171+
destroy() {
172+
if (this.observer) {
173+
this.observer.disconnect();
174+
this.observer = null;
175+
}
176+
document.querySelectorAll(`.${BADGE_CLASS}`).forEach((el) => el.remove());
177+
document.querySelectorAll(`.${TOGGLE_CLASS}`).forEach((el) => el.remove());
178+
document.querySelectorAll(`.${HIDDEN_CLASS}`).forEach((el) => el.classList.remove(HIDDEN_CLASS));
179+
document.querySelectorAll(`.${MASKED_CLASS}`).forEach((el) => el.classList.remove(MASKED_CLASS));
180+
this.billableMap = null;
181+
this.archivedSet = null;
182+
this.currentPeriod = null;
183+
this.hideArchived = false;
184+
}
185+
}

src/content/content.css

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -275,3 +275,28 @@
275275
#document-edit-line-items tfoot.is-stuck tr:not(:first-child) {
276276
display: none;
277277
}
278+
279+
/* Archive toggle on project budget header */
280+
.esti-archive-toggle {
281+
margin-right: 16px;
282+
}
283+
284+
tr.esti-hidden {
285+
display: none;
286+
}
287+
288+
/* Mask totals cells with "—" when archived rows are hidden (pure CSS, no innerHTML) */
289+
tr.esti-totals-masked td:not(.col-toggle):not(.col-name) > * {
290+
display: none;
291+
}
292+
293+
tr.esti-totals-masked td:not(.col-toggle):not(.col-name)::after {
294+
content: '—';
295+
}
296+
297+
/* Non-billable badge on project budget task rows */
298+
.esti-badge-nonbillable {
299+
background-color: #fef3c7;
300+
color: #92400e;
301+
margin-left: 6px;
302+
}

src/content/index.js

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,9 @@ ready(() => {
1717
let appView;
1818

1919
// Determine the current view and target element
20-
if (d.querySelector('.client-doc-notes')) {
20+
if (window.location.pathname.match(/^\/projects\/\d+$/)) {
21+
appView = 'project';
22+
} else if (d.querySelector('.client-doc-notes')) {
2123
targetQueryElement = '.client-doc-notes';
2224
appView = 'view';
2325
} else if (d.querySelector('#edit_estimate')) {
@@ -31,6 +33,12 @@ ready(() => {
3133
return false;
3234
}
3335

36+
// Project pages only need BillableBadge — skip estimate DOM injection
37+
if (appView === 'project') {
38+
app.init('project');
39+
return;
40+
}
41+
3442
// Add spell check dropdown ONLY in edit/new modes
3543
if (appView === 'edit' || appView === 'new') {
3644
const headerH1 = d.querySelector('h1');

0 commit comments

Comments
 (0)