Skip to content
Merged
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
187 changes: 134 additions & 53 deletions CLAUDE.md

Large diffs are not rendered by default.

14 changes: 1 addition & 13 deletions assets/combobox.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,19 +6,7 @@
* What a row says and what picking one means stays with the box that built it: this only knows that a
* row was chosen and by which of the two ways.
*/
export function element(tag, className, text) {
const node = document.createElement(tag);

if (className) {
node.className = className;
}

if (undefined !== text) {
node.textContent = text;
}

return node;
}
import { element } from './dom.js';

export class Combobox {
/**
Expand Down
714 changes: 537 additions & 177 deletions assets/controllers/chart_controller.js

Large diffs are not rendered by default.

111 changes: 111 additions & 0 deletions assets/controllers/metric_picker_controller.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
import { Controller } from '@hotwired/stimulus';
import { Combobox } from '../combobox.js';
import { element } from '../dom.js';

/*
* The box above the chart that says what it is drawn as. It is the repository box next to it, over a
* list that is already in the page: a release was measured in fifty-four numbers, and nothing has to be
* asked of the server to know their names - so this box searches what it was rendered with.
*
* That is also why a row says more than a name here. Nobody knows what `Logical lines outside classes
* and functions` is before reading what it counts, so the menu carries the sentence the catalog holds
* for every metric, under the section phploc prints it in.
*
* The select behind it is the state, not the widget - see repository_picker_controller.js. What is
* picked shows up as a tab above the chart rather than as a chip in here: the metrics of a chart are
* what it can be read in, and that list is the tab row, not a second copy of it next to the field.
* So this box only ever adds - a tab is where a metric is switched to and taken out again.
*/
export default class extends Controller {
static targets = ['select', 'input', 'menu'];

connect() {
this.combobox = new Combobox(this.inputTarget, this.menuTarget, (index) => this.pick(index));
// turbo caches the page as it was left, so a box left open would come back open
this.combobox.close();
this.inputTarget.value = '';
}

disconnect() {
this.combobox.close();
}

/** The whole box is one field, so clicking it aims for the only thing in it. */
focus() {
this.inputTarget.focus();
}

/**
* Everything that was measured and is not drawn yet. What is typed is matched against the name, the
* section and the sentence explaining the metric - `static` finds the static methods and the calls
* on them, `branch` finds the complexities, which are the words their explanations are written in.
*/
query() {
const search = this.inputTarget.value.trim().toLowerCase();

this.options = [...this.selectTarget.options].filter(
(option) => !option.selected && this.haystack(option).includes(search),
);

this.combobox.show(
this.options.map((option) => {
const row = element('li', 'combobox__option');

row.append(
element('span', 'combobox__name', option.text),
element('span', 'combobox__meta', option.dataset.group),
element('span', 'combobox__description', option.dataset.about),
);

return row;
}),
this.nothing('' !== search),
);
}

haystack(option) {
return `${option.text} ${option.dataset.group} ${option.dataset.about} ${option.value}`.toLowerCase();
}

nothing(searching) {
return searching
? 'Nothing that was measured goes by that name.'
: 'Every number of the measurement is already in this chart.';
}

navigate(event) {
this.combobox.navigate(event);
}

close() {
this.combobox.close();
}

pick(index) {
const option = this.options[index];

if (!option) {
return;
}

option.selected = true;
option.setAttribute('selected', 'selected');
// the position of an option is the position of its tab, so a pick goes to the end of both
this.selectTarget.append(option);
// one pick is rarely the only one, so the box stays open on what is left of the measurement
this.inputTarget.value = '';
this.commit();
}

commit() {
this.selectTarget.dispatchEvent(new Event('change', { bubbles: true }));
}

/** What the select says, whoever wrote to it - the box only ever shows what is not in the chart. */
refresh() {
// the menu is what is not in the chart, so taking a metric out puts it back on
if (!this.menuTarget.hidden) {
this.query();
}
}
}
3 changes: 2 additions & 1 deletion assets/controllers/repository_picker_controller.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { Controller } from '@hotwired/stimulus';
import { Combobox, element } from '../combobox.js';
import { Combobox } from '../combobox.js';
import { element } from '../dom.js';

/*
* The box above the chart: which repositories it draws. It is the start page's combobox with more than
Expand Down
3 changes: 2 additions & 1 deletion assets/controllers/search_controller.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { Controller } from '@hotwired/stimulus';
import { Combobox, element } from '../combobox.js';
import { Combobox } from '../combobox.js';
import { element } from '../dom.js';

/*
* The search box on the start page. It asks the server what an input means - a repository the report
Expand Down
18 changes: 18 additions & 0 deletions assets/dom.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
/*
* One element, built. Everything the report renders in the browser - the rows of a combobox, the tabs
* of the release analysis, sixty-two numbers of a measurement - is built rather than written into a
* string, so this is the one line that would otherwise be written a hundred times.
*/
export function element(tag, className, text) {
const node = document.createElement(tag);

if (className) {
node.className = className;
}

if (undefined !== text) {
node.textContent = text;
}

return node;
}
158 changes: 158 additions & 0 deletions assets/metrics.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,158 @@
/*
* How the report writes a measured number down.
*
* The catalog itself - what a number is called, which section it is printed under, how far it is
* rounded, whether falling is an improvement - is rendered into the page by `MetricCatalog`, so none of
* it is repeated here. What is here is the other half: turning one of those numbers, or the difference
* between two of them, into the element it is read as.
*/
import { element } from './dom.js';

const ARROWS = { good: '↓', bad: '↑', flat: '→' };

/*
* The risk bands of `ComplexityLevel`, mirrored because the release analysis is built here in the
* browser rather than rendered. It is the one thing about a metric the catalog does not carry: four
* numbers, and the band above the last of them.
*/
const LEVELS = [
[10, 'simple', '1–10'],
[20, 'moderate', '11–20'],
[50, 'complex', '21–50'],
];
const UNTESTABLE = ['untestable', '> 50'];

const bandOf = (value) => LEVELS.find(([limit]) => value <= limit)?.slice(1) ?? UNTESTABLE;

export const level = (value) => bandOf(value)[0];

/**
* Where a complexity stands, said in words - the note under the figure it belongs to, since the dot in
* front of the figure carries the band as a colour but not as a name.
*/
export function band(measured) {
const [name, range] = bandOf(measured);

return element('span', `level level--${name}`, `${name} · ${range}`);
}

/**
* Everything the page was told about the numbers it may draw, by the slug they are addressed under.
*/
export function catalogFrom(json) {
return new Map(JSON.parse(json).map((metric) => [metric.slug, metric]));
}

const digits = (value, decimals) =>
value.toLocaleString('en-US', { minimumFractionDigits: decimals, maximumFractionDigits: decimals });

/**
* A measured number, written with the decimals its metric is read to - a count without any, an average
* with two. A release that does not carry the number at all is a dash rather than a zero, because
* those are different statements.
*/
export function format(metric, value) {
return null === value || undefined === value ? '—' : digits(value, metric.decimals);
}

const sign = (value) => (value > 0 ? '+' : value < 0 ? '−' : '±');

export function signed(metric, delta) {
return sign(delta) + digits(Math.abs(delta), metric.decimals);
}

/**
* What a part is of its whole, which is the percentage phploc prints behind half of its numbers.
*/
export function share(part, total) {
return total ? `${digits((Math.abs(part) / total) * 100, 1)}%` : undefined;
}

export function percent(value) {
return undefined === value || null === value ? undefined : `${digits(value, 1)}%`;
}

/**
* A measured value, carrying the dot of its risk band where the metric is one that is read against
* them - the complexities, and nothing else.
*/
export function value(metric, measured) {
if (!metric.level || null === measured || undefined === measured) {
return element('span', null, format(metric, measured));
}

return element('span', `level level--${level(measured)}`, format(metric, measured));
}

/**
* Which way a change goes, where the report has an opinion about it: complexity falling is an
* improvement and complexity rising is a regression, while a library that grew by twenty thousand lines
* did not thereby get better or worse - so a neutral metric has no direction at all, not a flat one.
*
* What counts as no movement is what the metric is written to: half of its last decimal.
*/
export function direction(metric, delta) {
if ('lower' !== metric.direction) {
return undefined;
}

return Math.abs(delta) < Math.pow(10, -metric.decimals) / 2 ? 'flat' : delta < 0 ? 'good' : 'bad';
}

/**
* A change, coloured only where the report has an opinion about the direction.
*/
export function change(metric, delta, label) {
if ('lower' !== metric.direction) {
const node = element('span', 'trend trend--chip trend--flat');

node.append(element('span', null, signed(metric, delta)));

if (label) {
node.append(element('span', 'trend__label', label));
}

return node;
}

const tone = direction(metric, delta);
const node = element('span', `trend trend--chip trend--${tone}`);

node.append(element('span', null, ARROWS[tone]), element('span', null, signed(metric, delta)));

if (label) {
node.append(element('span', 'trend__label', label));
}

return node;
}

export function row(label, measured, hint) {
const node = element('div', 'analysis__row');
const definition = element('dd', 'analysis__value');

if (hint) {
definition.append(element('span', 'analysis__share', hint));
}

definition.append(measured instanceof Node ? measured : element('span', null, String(measured)));
node.append(label instanceof Node ? label : element('dt', 'analysis__label', label), definition);

return node;
}

export function list(rows) {
const node = element('dl', 'analysis__list');

rows.filter(Boolean).forEach((entry) => node.append(entry));

return node;
}

export function group(title, rows) {
const node = element('div');

node.append(element('div', 'analysis__title', title), list(rows));

return node;
}
Loading