Back to Body Journal
SOURCE / PINNED RELEASE

Made of little things.

Body Journal

Release
e8049f8da4e2…
Author-recorded commit
61c04bf0e8aa…
License
LICENSE
Author’s source reference
nostr://npub182jczunncwe0jn6frpqwq3e0qjws7yqqnc3auccqv9nte2dnd63scjm4rf/wss%3A%2F%2Fgit.napplet.soy%2F/n-6c4554844c7

Archive hash verified: 4369d778da046a8b…. The source-to-build association is the author’s claim; it has not been independently rebuilt.

src/group-controls.ts
import { escape as esc, type Concern } from './case.js';
import { GROUP_EXPLANATION_LIMIT, GROUP_NAME_LIMIT, MAX_GROUPS, groupName, type ConcernGroup } from './concern-groups.js';

export class GroupControls {
  private expanded = new Set<string>();
  constructor(private root: HTMLElement, private groups: ConcernGroup[], private concerns: Concern[], private ui: {
    changed: () => void; select: (id: number) => void; toast: (message: string) => void;
  }) {
    root.addEventListener('click', this.onClick);
    root.addEventListener('input', this.onInput);
    root.addEventListener('change', this.onChange);
  }
  get expandedIds(): string[] { return this.groups.filter(g => this.expanded.has(g.id)).map(g => g.id); }
  restore(ids: string[] = []): void { this.expanded = new Set(ids); this.refresh(); }
  refresh(): void {
    this.root.hidden = !this.concerns.length && !this.groups.length;
    this.root.innerHTML = `<div class="groups-heading"><h3>Groups</h3><button type="button" id="addGroup" class="text-button" ${this.groups.length >= MAX_GROUPS ? 'disabled' : ''}>+ Group</button></div><p class="groups-help">Connect concerns you think may be related. These are your suspected connections.</p>${this.groups.length >= MAX_GROUPS ? '<p class="field-hint">20 groups maximum. Edit or remove an existing group.</p>' : ''}<div class="group-list">${this.groups.map(g => this.card(g)).join('')}</div>`;
  }
  private card(g: ConcernGroup): string {
    const open = this.expanded.has(g.id);
    return `<section class="concern-group" data-group-id="${g.id}"><h4><button type="button" class="group-toggle" id="group-toggle-${g.id}" data-group-toggle="${g.id}" aria-expanded="${open}" aria-controls="group-panel-${g.id}"><span><strong>${esc(groupName(g))}</strong><small>${this.count(g)}</small></span><span aria-hidden="true">${open ? '−' : '+'}</span></button></h4><div class="group-editor" id="group-panel-${g.id}" role="region" aria-labelledby="group-toggle-${g.id}" ${open ? '' : 'hidden'}>
      <label class="field">Group name<input data-group-field="name" maxlength="${GROUP_NAME_LIMIT}" value="${esc(g.name)}" placeholder="For example, started after running" /></label>
      <label class="field">Why might these be related?<textarea data-group-field="explanation" maxlength="${GROUP_EXPLANATION_LIMIT}" rows="2" placeholder="What makes you think there is a connection? It’s okay to be unsure.">${esc(g.explanation)}</textarea></label>
      <fieldset class="group-members"><legend>Included concerns</legend>${this.concerns.length ? this.concerns.map((c, i) => `<div class="group-member"><label><input type="checkbox" data-group-member="${c.id}" ${g.concernIds.includes(c.id) ? 'checked' : ''} /><span>${i + 1}. ${esc(c.location || 'Unnamed concern')}<small>${c.localized ? esc(c.type) : 'General symptom'}</small></span></label><button type="button" data-group-concern="${c.id}" aria-label="Open concern ${i + 1}: ${esc(c.location)}">Open</button></div>`).join('') : '<p class="field-hint">Add a concern to link it here.</p>'}</fieldset>
      <p class="field-hint">A concern can belong to more than one group. Unchecking keeps the concern.</p><button type="button" class="text-button remove-group" data-group-remove="${g.id}">Remove group</button>
    </div></section>`;
  }
  private count(g: ConcernGroup): string { return `${g.concernIds.length} concern${g.concernIds.length === 1 ? '' : 's'}`; }
  private groupFor(target: Element): ConcernGroup | undefined { return this.groups.find(g => g.id === target.closest<HTMLElement>('[data-group-id]')?.dataset.groupId); }
  private updateHeading(g: ConcernGroup): void {
    const toggle = this.root.querySelector(`#group-toggle-${g.id}`)!;
    toggle.querySelector('strong')!.textContent = groupName(g);
    toggle.querySelector('small')!.textContent = this.count(g);
  }
  private onClick = (event: Event): void => {
    const button = (event.target as Element).closest<HTMLButtonElement>('button');
    if (!button || button.disabled) return;
    if (button.id === 'addGroup') {
      if (this.groups.length >= MAX_GROUPS) return;
      const id = `g-${Array.from(crypto.getRandomValues(new Uint8Array(16)), n => n.toString(16).padStart(2, '0')).join('')}`;
      this.groups.push({ id, name: '', explanation: '', concernIds: [] }); this.expanded.add(id); this.refresh();
      this.root.querySelector<HTMLInputElement>(`#group-panel-${id} input[data-group-field]`)?.focus();
    } else if (button.dataset.groupToggle) {
      const id = button.dataset.groupToggle;
      if (this.expanded.has(id)) this.expanded.delete(id); else this.expanded.add(id);
      this.refresh(); this.root.querySelector<HTMLButtonElement>(`#group-toggle-${id}`)?.focus({ preventScroll: true });
    } else if (button.dataset.groupRemove) {
      const id = button.dataset.groupRemove, index = this.groups.findIndex(g => g.id === id);
      if (index < 0) return;
      this.groups.splice(index, 1); this.expanded.delete(id); this.refresh();
      this.root.querySelector<HTMLButtonElement>('#addGroup')?.focus({ preventScroll: true });
      this.ui.toast('Group removed. Its concerns are kept.');
    } else if (button.dataset.groupConcern) this.ui.select(Number(button.dataset.groupConcern));
    else return;
    this.ui.changed();
  };
  private onInput = (event: Event): void => {
    const input = event.target as HTMLInputElement, g = this.groupFor(input), field = input.dataset.groupField;
    if (!g || (field !== 'name' && field !== 'explanation')) return;
    g[field] = input.value; this.updateHeading(g); this.ui.changed();
  };
  private onChange = (event: Event): void => {
    const input = event.target as HTMLInputElement, g = this.groupFor(input), id = Number(input.dataset.groupMember);
    if (!g || !this.concerns.some(c => c.id === id)) return;
    g.concernIds = this.concerns.filter(c => c.id === id ? input.checked : g.concernIds.includes(c.id)).map(c => c.id);
    this.updateHeading(g); this.ui.changed();
  };
  dispose(): void {
    this.root.removeEventListener('click', this.onClick); this.root.removeEventListener('input', this.onInput); this.root.removeEventListener('change', this.onChange);
  }
}

export function groupSummary(groups: ConcernGroup[], concerns: Concern[]): string {
  if (!groups.length) return '';
  return `<section class="report-groups"><h4>Patient-suspected relationships</h4><p>These connections reflect your understanding, not established causes or clinician conclusions.</p>${groups.map(g => `<div class="report-group"><h5>${esc(groupName(g))}</h5><ul>${g.concernIds.map(id => {
    const index = concerns.findIndex(c => c.id === id), c = concerns[index];
    return c ? `<li>Concern ${index + 1}: ${esc(c.location)}</li>` : '';
  }).join('')}</ul>${!g.concernIds.length ? '<p>No concerns linked yet.</p>' : ''}<p>${g.explanation ? esc(g.explanation) : 'No explanation provided.'}</p></div>`).join('')}</section>`;
}