thatcher 1.0.85 → 1.0.87

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "thatcher",
3
- "version": "1.0.85",
3
+ "version": "1.0.87",
4
4
  "description": "A config-driven application framework for building data-intensive web apps without code.",
5
5
  "main": "src/index.js",
6
6
  "type": "module",
@@ -0,0 +1,58 @@
1
+ // Single source of truth for CRM demand-planning math, shared with any
2
+ // future forecast summary view -- the same small pure-computation module
3
+ // shape as contract-expiry.js and inventory-forecast.js. Forward-looking
4
+ // (open pipeline -> future revenue), the mirror image of
5
+ // inventory-forecast.js's backward-looking (movement history -> stockout).
6
+ const OPEN_STAGE_EXCLUSIONS = new Set(['won', 'lost']);
7
+
8
+ function monthBucketKey(dateSeconds) {
9
+ const d = new Date(dateSeconds * 1000);
10
+ return `${d.getUTCFullYear()}-${String(d.getUTCMonth() + 1).padStart(2, '0')}`;
11
+ }
12
+
13
+ // The stage field alone decides "still open" -- not the presence of a
14
+ // weighted_value or any date math, so a caller can't accidentally count a
15
+ // won/lost opportunity by omission.
16
+ function isOpenStage(opportunity) {
17
+ return !OPEN_STAGE_EXCLUSIONS.has(opportunity.stage);
18
+ }
19
+
20
+ // A record already carries weighted_value if it passed through
21
+ // busybase/store.js's list()/get() (which now computes formula fields on
22
+ // every read) -- reused directly rather than recomputed, so this module
23
+ // never drifts from the same formula the opportunity entity itself defines.
24
+ // Falls back to computing value*probability/100 independently only if the
25
+ // field is genuinely absent (e.g. a deployment whose opportunity entity
26
+ // doesn't define weighted_value), never silently treating a present-but-null
27
+ // value as "compute it myself" -- null still means the formula ran and
28
+ // legitimately produced nothing.
29
+ function weightedValueOf(opportunity) {
30
+ if (opportunity.weighted_value !== undefined) return opportunity.weighted_value ?? 0;
31
+ const value = Number(opportunity.value) || 0;
32
+ const probability = Number(opportunity.probability) || 0;
33
+ return (value * probability) / 100;
34
+ }
35
+
36
+ export function projectDemandByMonth(opportunities, nowSeconds = Math.floor(Date.now() / 1000)) {
37
+ const currentBucketKey = monthBucketKey(nowSeconds);
38
+ const buckets = new Map();
39
+
40
+ for (const opp of opportunities) {
41
+ if (!isOpenStage(opp)) continue;
42
+ if (opp.expected_close_date == null) continue;
43
+
44
+ const bucketKey = monthBucketKey(Number(opp.expected_close_date));
45
+ // A future bucket sorts >= the current month's key lexicographically
46
+ // (YYYY-MM strings compare correctly as dates); a past-due open
47
+ // opportunity's bucket key is strictly less than the current one and is
48
+ // excluded entirely rather than folded into the nearest future bucket --
49
+ // silently reassigning it would misrepresent when the demand was
50
+ // actually expected.
51
+ if (bucketKey < currentBucketKey) continue;
52
+
53
+ const weighted = weightedValueOf(opp);
54
+ buckets.set(bucketKey, (buckets.get(bucketKey) || 0) + weighted);
55
+ }
56
+
57
+ return [...buckets.entries()].sort((a, b) => a[0].localeCompare(b[0]));
58
+ }
@@ -0,0 +1,28 @@
1
+ // Single source of truth for resource-allocation load math, extracted from
2
+ // entity-validators.js's checkResourceCapacity so the over-allocation
3
+ // PREVENTION check and the resource-optimizer's SUGGESTION ranking compute a
4
+ // user's committed load identically -- two independently-maintained copies
5
+ // of the same overlap/sum formula would inevitably drift.
6
+ export const DEFAULT_WEEKLY_CAPACITY_HOURS = 40;
7
+
8
+ // Two date ranges overlap unless one entirely precedes the other -- the
9
+ // standard interval-intersection test, not a same-day/exact-match check.
10
+ export function rangesOverlap(startA, endA, startB, endB) {
11
+ return Number(startA) <= Number(endB) && Number(startB) <= Number(endA);
12
+ }
13
+
14
+ // Sum of allocated_hours_per_week across every existing allocation for
15
+ // userId whose date range overlaps [startDate, endDate], excluding
16
+ // excludeAllocationId (the record being updated, if any, so it doesn't
17
+ // double-count itself). Reads via list('resource_allocation', {user_id}),
18
+ // the same unscoped-internal lookup pattern every other entity-specific
19
+ // check this session (checkStockBalance/checkContractDateOrder) already
20
+ // uses -- no user context threaded through this layer.
21
+ export async function userCommittedHours(userId, startDate, endDate, excludeAllocationId = null) {
22
+ const { list } = await import('./busybase/store.js');
23
+ const existingAllocations = await list('resource_allocation', { user_id: userId });
24
+ const overlapping = existingAllocations.filter(a =>
25
+ a.id !== excludeAllocationId && rangesOverlap(startDate, endDate, a.start_date, a.end_date)
26
+ );
27
+ return overlapping.reduce((sum, a) => sum + (Number(a.allocated_hours_per_week) || 0), 0);
28
+ }
@@ -0,0 +1,20 @@
1
+ import { userCommittedHours, DEFAULT_WEEKLY_CAPACITY_HOURS } from './resource-capacity.js';
2
+
3
+ // Ranks a pool of REAL users (queried from the user list, never an arbitrary
4
+ // id range) by remaining weekly capacity in the given date range, using the
5
+ // exact same overlap-based load calculation checkResourceCapacity already
6
+ // enforces -- so a "best fit" suggestion and the prevention check it feeds
7
+ // into can never disagree about how loaded a candidate actually is.
8
+ // Excludes anyone already at or over capacity entirely (not just ranked
9
+ // last), since they are not a valid suggestion regardless of rank.
10
+ export async function suggestBestFitUsers(candidateUserIds, startDate, endDate, hoursNeeded, capacityHours = DEFAULT_WEEKLY_CAPACITY_HOURS) {
11
+ const results = [];
12
+ for (const userId of candidateUserIds) {
13
+ const committedHours = await userCommittedHours(userId, startDate, endDate);
14
+ const remainingCapacity = capacityHours - committedHours;
15
+ if (remainingCapacity < hoursNeeded) continue;
16
+ results.push({ user_id: userId, committed_hours: committedHours, remaining_capacity: remainingCapacity });
17
+ }
18
+ results.sort((a, b) => b.remaining_capacity - a.remaining_capacity);
19
+ return results;
20
+ }
@@ -303,19 +303,14 @@ async function checkCrossEntityRules(entityName, data, existingRecord) {
303
303
  return null;
304
304
  }
305
305
 
306
- const WEEKLY_CAPACITY_HOURS = 40;
307
-
308
- // Two date ranges overlap unless one entirely precedes the other -- the
309
- // standard interval-intersection test, not a same-day/exact-match check.
310
- function rangesOverlap(startA, endA, startB, endB) {
311
- return Number(startA) <= Number(endB) && Number(startB) <= Number(endA);
312
- }
313
-
314
306
  // A user's total weekly allocation across every project they're assigned to,
315
307
  // for any date range that overlaps the new/changed allocation, must not
316
308
  // exceed a configurable weekly capacity. Checked against EVERY OTHER
317
309
  // existing allocation for that user (excluding the record being updated, if
318
- // any) plus the new one -- never trusting a client-supplied total.
310
+ // any) plus the new one -- never trusting a client-supplied total. The
311
+ // overlap/load math itself lives in resource-capacity.js, shared with
312
+ // resource-optimizer.js's suggestion ranking so both compute a user's
313
+ // committed load identically.
319
314
  async function checkResourceCapacity(entityName, data, existingRecord) {
320
315
  if (entityName !== 'resource_allocation') return null;
321
316
  const userId = data.user_id !== undefined ? data.user_id : existingRecord?.user_id;
@@ -324,15 +319,11 @@ async function checkResourceCapacity(entityName, data, existingRecord) {
324
319
  const hours = Number(data.allocated_hours_per_week !== undefined ? data.allocated_hours_per_week : existingRecord?.allocated_hours_per_week);
325
320
  if (!userId || startDate == null || endDate == null || !Number.isFinite(hours)) return null;
326
321
 
327
- const { list } = await import('@/lib/busybase/store');
328
- const existingAllocations = await list('resource_allocation', { user_id: userId });
329
- const overlapping = existingAllocations.filter(a =>
330
- a.id !== existingRecord?.id && rangesOverlap(startDate, endDate, a.start_date, a.end_date)
331
- );
332
- const overlappingTotal = overlapping.reduce((sum, a) => sum + (Number(a.allocated_hours_per_week) || 0), 0);
322
+ const { userCommittedHours, DEFAULT_WEEKLY_CAPACITY_HOURS } = await import('@/lib/resource-capacity');
323
+ const overlappingTotal = await userCommittedHours(userId, startDate, endDate, existingRecord?.id);
333
324
  const resultingTotal = overlappingTotal + hours;
334
- if (resultingTotal > WEEKLY_CAPACITY_HOURS) {
335
- return `Over-allocated: this would bring the user's weekly total to ${resultingTotal}h against overlapping allocations, exceeding the ${WEEKLY_CAPACITY_HOURS}h capacity`;
325
+ if (resultingTotal > DEFAULT_WEEKLY_CAPACITY_HOURS) {
326
+ return `Over-allocated: this would bring the user's weekly total to ${resultingTotal}h against overlapping allocations, exceeding the ${DEFAULT_WEEKLY_CAPACITY_HOURS}h capacity`;
336
327
  }
337
328
  return null;
338
329
  }
@@ -151,6 +151,10 @@ export function createServer(options) {
151
151
  return await handleChangesSince(req, res, parts[1], parts[2], parts[4]);
152
152
  }
153
153
 
154
+ if (req.method === 'GET' && entity === 'resource-optimizer' && id === 'suggest') {
155
+ return await handleResourceOptimizerSuggest(req, res);
156
+ }
157
+
154
158
  if (req.method === 'GET' && entity === 'auth' && id === 'google' && !action) {
155
159
  return await handleOAuthGoogleStart(req, res);
156
160
  }
@@ -1589,6 +1593,46 @@ async function handlePresenceHeartbeat(req, res, entityName, id) {
1589
1593
  res.end(JSON.stringify({ ok: true }));
1590
1594
  }
1591
1595
 
1596
+ async function handleResourceOptimizerSuggest(req, res) {
1597
+ const user = await resolveRequestUser(req);
1598
+ if (!user) {
1599
+ res.writeHead(401, { 'Content-Type': 'application/json' });
1600
+ res.end(JSON.stringify({ error: 'Authentication required' }));
1601
+ return;
1602
+ }
1603
+
1604
+ const url = new URL(req.url, `http://${req.headers.host}`);
1605
+ const startDate = Number(url.searchParams.get('start_date'));
1606
+ const endDate = Number(url.searchParams.get('end_date'));
1607
+ const hoursNeeded = Number(url.searchParams.get('hours_needed'));
1608
+ if (!Number.isFinite(startDate) || !Number.isFinite(endDate) || !Number.isFinite(hoursNeeded)) {
1609
+ res.writeHead(400, { 'Content-Type': 'application/json' });
1610
+ res.end(JSON.stringify({ error: 'start_date, end_date, and hours_needed are required numeric query params' }));
1611
+ return;
1612
+ }
1613
+
1614
+ // Self-only unless partner/manager -- the SAME privilege boundary
1615
+ // resource_allocation's own checkTimeEntryOwnership-derived ownership
1616
+ // check already enforces on write. A non-privileged caller asking for
1617
+ // suggestions must not learn how loaded ANY other user is (that leaks
1618
+ // workload/capacity across the org), so their candidate pool is
1619
+ // themselves alone rather than filtered results from a broader query.
1620
+ const isPrivileged = ['partner', 'admin', 'manager'].includes(user.role);
1621
+ let candidateUserIds;
1622
+ if (isPrivileged) {
1623
+ const { list } = await import('../lib/busybase/store.js');
1624
+ const users = await list('user', {});
1625
+ candidateUserIds = users.map(u => u.id);
1626
+ } else {
1627
+ candidateUserIds = [user.id];
1628
+ }
1629
+
1630
+ const { suggestBestFitUsers } = await import('../lib/resource-optimizer.js');
1631
+ const suggestions = await suggestBestFitUsers(candidateUserIds, startDate, endDate, hoursNeeded);
1632
+ res.writeHead(200, { 'Content-Type': 'application/json' });
1633
+ res.end(JSON.stringify({ suggestions }));
1634
+ }
1635
+
1592
1636
  async function handlePresenceGet(req, res, entityName, id) {
1593
1637
  const user = await verifyRecordAccess(req, res, entityName, id);
1594
1638
  if (!user) return;
@@ -315,7 +315,10 @@ export function renderEntityForm(entityName, item, spec, user, isNew = false, re
315
315
  }
316
316
  if (f.type === 'ref' && refOptions[k]) {
317
317
  const opts = refOptions[k].map(o => `<option value="${esc(o.value)}" ${val===o.value?'selected':''}>${esc(o.label)}</option>`).join('')
318
- return `<div class="form-field">${lbl(k,f,f.required)}<select id="field-${k}" name="${k}" class="form-input" ${req}><option value="">Select ${esc(f.label||k)}...</option>${opts}</select></div>`
318
+ const suggestBtn = entityName === 'resource_allocation' && k === 'user_id'
319
+ ? `<button type="button" class="btn-ghost-clean" style="margin-top:4px" data-action="suggestBestFitUser">Suggest best-fit user</button>`
320
+ : ''
321
+ return `<div class="form-field">${lbl(k,f,f.required)}<select id="field-${k}" name="${k}" class="form-input" ${req}><option value="">Select ${esc(f.label||k)}...</option>${opts}</select>${suggestBtn}</div>`
319
322
  }
320
323
  if (f.type === 'textarea') return `<div class="form-field full">${lbl(k,f,f.required)}<textarea id="field-${k}" name="${k}" class="form-input" style="min-height:100px;resize:vertical" ${req} placeholder="Enter ${esc((f.label||k).toLowerCase())}">${esc(val)}</textarea></div>`
321
324
  if (f.type === 'bool') return `<div class="form-field"><label style="display:flex;align-items:center;gap:8px;cursor:pointer"><input type="checkbox" id="field-${k}" name="${k}" class="checkbox checkbox-primary" ${val?'checked':''}/><span class="form-label" style="margin:0">${esc(f.label||k)}</span></label></div>`
@@ -361,7 +364,10 @@ export function renderEntityForm(entityName, item, spec, user, isNew = false, re
361
364
  <div class="form-actions" style="grid-column:1/-1"><button type="submit" id="submit-btn" class="btn-primary-clean"><span class="btn-text">Save</span><span class="btn-loading-text" style="display:none">Saving...</span></button>
362
365
  <a href="/${entityName}${isNew ? '' : '/' + item?.id}" class="btn-ghost-clean">Cancel</a></div></form></div></div>`
363
366
  const script = `${TOAST_SCRIPT}const form=document.getElementById('entity-form');const sb=document.getElementById('submit-btn');form.addEventListener('submit',async(e)=>{e.preventDefault();sb.classList.add('btn-loading');sb.querySelector('.btn-text').style.display='none';sb.querySelector('.btn-loading-text').style.display='inline';sb.disabled=true;try{const fileInputs=[...form.querySelectorAll('input[type=file][data-attachment]')];for(const fi of fileInputs){const f=fi.files&&fi.files[0];if(!f)continue;const uf=new FormData();uf.append('file',f);const ures=await fetch('/api/upload',{method:'POST',body:uf});const ud=await ures.json();if(!ures.ok)throw new Error(ud.error||'File upload failed');const hidden=document.getElementById('field-'+fi.dataset.attachment+'-value');hidden.value=JSON.stringify(ud)}const fd=new FormData(form);const data={};for(const[k,v]of fd.entries()){if(k.endsWith('[]'))continue;data[k]=v}form.querySelectorAll('input[type=checkbox]:not([name$="[]"])').forEach(cb=>{data[cb.name]=cb.checked});form.querySelectorAll('[data-multiselect]').forEach(ms=>{const name=ms.dataset.multiselect;data[name]=[...ms.querySelectorAll('input[type=checkbox]:checked')].map(cb=>cb.value)});form.querySelectorAll('input[type=number]:not([data-currency])').forEach(inp=>{if(inp.name&&data[inp.name]!==undefined&&data[inp.name]!=='')data[inp.name]=Number(data[inp.name])});form.querySelectorAll('input[data-currency]').forEach(inp=>{const name=inp.dataset.currency;if(inp.value!=='')data[name]=Math.round(Number(inp.value)*100)});const url=${isNew}?'/api/${entityName}':'/api/${entityName}/${item?.id}';const method=${isNew}?'POST':'PUT';const res=await fetch(url,{method,headers:{'Content-Type':'application/json'},body:JSON.stringify(data)});const result=await res.json();if(res.ok){showToast('${isNew?'Created':'Updated'} successfully!','success');const ed=result.data||result;setTimeout(()=>{window.location='/${entityName}/'+(ed.id||'${item?.id}')},500)}else{showToast(result.message||result.error||'Save failed','error');sb.classList.remove('btn-loading');sb.querySelector('.btn-text').style.display='inline';sb.querySelector('.btn-loading-text').style.display='none';sb.disabled=false}}catch(err){showToast('Error: '+err.message,'error');sb.classList.remove('btn-loading');sb.querySelector('.btn-text').style.display='inline';sb.querySelector('.btn-loading-text').style.display='none';sb.disabled=false}})`
364
- return page(user, `${isNew ? 'Create' : 'Edit'} ${label}`, bc, content, [script])
367
+ const suggestScript = entityName === 'resource_allocation'
368
+ ? `window.suggestBestFitUser=async function(){const start=document.getElementById('field-start_date');const end=document.getElementById('field-end_date');const hours=document.getElementById('field-allocated_hours_per_week');if(!start.value||!end.value||!hours.value){showToast('Fill in start date, end date, and hours first','error');return}const startTs=Math.floor(new Date(start.value).getTime()/1000);const endTs=Math.floor(new Date(end.value).getTime()/1000);try{const res=await fetch('/api/resource-optimizer/suggest?start_date='+startTs+'&end_date='+endTs+'&hours_needed='+encodeURIComponent(hours.value));const data=await res.json();if(!res.ok){showToast(data.error||'Suggestion failed','error');return}if(!data.suggestions||!data.suggestions.length){showToast('No user has enough remaining capacity','warning');return}const top=data.suggestions[0];const select=document.getElementById('field-user_id');if(select){select.value=top.user_id;showToast('Suggested user selected ('+top.remaining_capacity+'h remaining capacity)','success')}}catch(err){showToast('Error: '+err.message,'error')}}`
369
+ : ''
370
+ return page(user, `${isNew ? 'Create' : 'Edit'} ${label}`, bc, content, [script, suggestScript])
365
371
  }
366
372
 
367
373
  export function renderSettings(user, config = {}) {
@@ -9,7 +9,7 @@ import { renderEngagementGrid } from '@/ui/engagement-grid-renderer.js';
9
9
  import { renderBoardView } from '@/ui/board-view-renderer.js';
10
10
  import { renderGridView } from '@/ui/grid-view-renderer.js';
11
11
  import { renderCalendarView, renderTimelineView } from '@/ui/calendar-view-renderer.js';
12
- import { renderCountByFieldReport, renderCountOverTimeReport, renderSumByFieldReport, renderRollupReport, renderInventoryForecastReport } from '@/ui/report-renderer.js';
12
+ import { renderCountByFieldReport, renderCountOverTimeReport, renderSumByFieldReport, renderRollupReport, renderInventoryForecastReport, renderDemandForecastReport } from '@/ui/report-renderer.js';
13
13
  import { renderClientProgress } from '@/ui/client-progress-renderer.js';
14
14
  import { renderLetterWorkflow } from '@/ui/letter-workflow-renderer.js';
15
15
  import { renderAdvancedSearch } from '@/ui/advanced-search-renderer.js';
@@ -392,6 +392,16 @@ export async function handlePage(pathname, req, res) {
392
392
  }
393
393
  return renderInventoryForecastReport(user, spec, forecastRows);
394
394
  }
395
+ if (report === 'demand-forecast' && entityName === 'opportunity') {
396
+ // items is already the {user}-scoped opportunity list fetched at the
397
+ // top of this route (same list(entityName,{},{user}) every other
398
+ // entity route uses) -- no new unscoped query, and each item already
399
+ // carries a computed weighted_value from list()'s own formula-field
400
+ // pass, reused directly rather than recomputed.
401
+ const { projectDemandByMonth } = await import('@/lib/demand-forecast.js');
402
+ const buckets = projectDemandByMonth(items);
403
+ return renderDemandForecastReport(user, spec, buckets, items.length);
404
+ }
395
405
  const view = params.get('view');
396
406
  if (view === 'board') return renderBoardView(user, entityName, spec, items);
397
407
  if (view === 'grid') return renderGridView(user, entityName, spec, items);
@@ -237,3 +237,23 @@ export function renderInventoryForecastReport(user, spec, forecastRows) {
237
237
  </table></div>`;
238
238
  return page(user, `Inventory Forecast | Thatcher`, null, content);
239
239
  }
240
+
241
+ // Reuses sumBarChart (already built for sum-by-field's currency-aware bar
242
+ // rendering) rather than inventing a third chart type -- a demand-by-month
243
+ // bucket is the exact same [label, numericValue] shape sum-by-field already
244
+ // renders, just currency-formatted since weighted_value derives from a
245
+ // currency field.
246
+ export function renderDemandForecastReport(user, spec, buckets, totalOpportunities) {
247
+ const label = getEntityLabel(spec, true) || 'Opportunities';
248
+ const valueFieldDef = spec.fields?.value || { type: 'currency' };
249
+ const totalProjected = buckets.reduce((sum, [, v]) => sum + v, 0);
250
+ const notice = !buckets.length
251
+ ? `<div class="report-notice">No open opportunities with a future expected close date</div>`
252
+ : '';
253
+ const content = `<div class="page-header">
254
+ <div><h1 class="page-title">${esc(label)}: Demand Forecast</h1><p class="page-subtitle">${totalOpportunities} total opportunities, ${esc(formatSumValue(totalProjected, valueFieldDef))} projected across ${buckets.length} future month${buckets.length === 1 ? '' : 's'}</p></div>
255
+ </div>
256
+ ${notice}
257
+ ${sumBarChart(buckets, valueFieldDef)}`;
258
+ return page(user, `Demand Forecast | Thatcher`, null, content);
259
+ }