refactor(ui): share scannable metric layout

This commit is contained in:
Luiz Gustavo 2026-07-15 20:18:42 -03:00
parent 8b381c463c
commit c3ff030c7d
9 changed files with 278 additions and 141 deletions

View file

@ -445,6 +445,18 @@ var DomHelpers = class {
row.append(labelEl, valueEl);
return row;
}
/**
* Creates a compact label/value metric for scannable list rows.
*/
static createMetric(label, value) {
const metric = this.createElement("div", "leif-metric");
const labelEl = this.createElement("span", "leif-metric-label");
labelEl.textContent = label;
const valueEl = this.createElement("span", "leif-metric-value");
valueEl.textContent = value;
metric.append(labelEl, valueEl);
return metric;
}
/**
* Creates a table with headers and rows.
* @param headers - Column headers
@ -2702,30 +2714,29 @@ var ContestsTab = class {
})
);
}
const header = DomHelpers.createElement("div", "leif-contest-card-header");
const titleGroup = DomHelpers.createElement("div", "leif-contest-card-title-group");
const title = DomHelpers.createElement("strong", "leif-contest-card-title");
title.textContent = contest.name;
const status = DomHelpers.createElement("span", isActive ? "leif-status-active" : "leif-status-inactive");
status.textContent = isActive ? "Estudando agora" : "Guardado";
titleGroup.append(title, status);
header.append(titleGroup, actions);
const notes = DomHelpers.createParagraph(contest.wall.notes?.trim() || "Sem notas ainda.");
notes.classList.add("leif-contest-notes");
const meta = DomHelpers.createElement("div", "leif-contest-meta");
meta.append(this.renderMetaChip("ID", contest.id));
meta.append(DomHelpers.createMetric("ID", contest.id));
if (contest.examPlan?.examDate) {
meta.append(this.renderMetaChip("Prova", contest.examPlan.examDate));
meta.append(DomHelpers.createMetric("Prova", contest.examPlan.examDate));
}
if (contest.examPlan?.board) {
meta.append(this.renderMetaChip("Banca", contest.examPlan.board));
meta.append(DomHelpers.createMetric("Banca", contest.examPlan.board));
}
if (contest.examPlan?.weeklyStudyHours !== void 0) {
meta.append(this.renderMetaChip("Carga", `${contest.examPlan.weeklyStudyHours} h/semana`));
meta.append(DomHelpers.createMetric("Carga", `${contest.examPlan.weeklyStudyHours} h/semana`));
}
if (contest.examPlan?.weeklyQuestionGoal !== void 0) {
meta.append(this.renderMetaChip("Meta", `${contest.examPlan.weeklyQuestionGoal} quest\xF5es/semana`));
meta.append(DomHelpers.createMetric("Meta", `${contest.examPlan.weeklyQuestionGoal} quest\xF5es/semana`));
}
card.append(header, notes, meta);
card.append(titleGroup, meta, actions, notes);
return card;
}
renderEditableCard(contest, data) {
@ -2832,15 +2843,6 @@ var ContestsTab = class {
);
return form;
}
renderMetaChip(label, value) {
const chip = DomHelpers.createElement("span", "leif-next-activity-chip");
const labelEl = DomHelpers.createElement("span", "leif-next-activity-chip-label");
labelEl.textContent = `${label}:`;
const valueEl = DomHelpers.createElement("span", "leif-next-activity-chip-value");
valueEl.textContent = value;
chip.append(labelEl, valueEl);
return chip;
}
readOptionalNumber(value) {
if (!value.trim()) {
return void 0;
@ -2946,8 +2948,8 @@ var CycleTab = class {
);
const meta = DomHelpers.createElement("div", "leif-cycle-card-meta");
meta.append(
this.renderCycleMetric("Tempo", `${subject.plannedStudyMinutes} min`),
this.renderCycleMetric("Etapa", subject.currentStage ?? "\u2014")
DomHelpers.createMetric("Tempo", `${subject.plannedStudyMinutes} min`),
DomHelpers.createMetric("Etapa", subject.currentStage ?? "\u2014")
);
card.append(titleGroup, meta, actions);
return card;
@ -3083,15 +3085,6 @@ var CycleTab = class {
chip.append(labelEl, valueEl);
return chip;
}
renderCycleMetric(label, value) {
const metric = DomHelpers.createElement("div", "leif-cycle-metric");
const labelEl = DomHelpers.createElement("span", "leif-cycle-metric-label");
labelEl.textContent = label;
const valueEl = DomHelpers.createElement("span", "leif-cycle-metric-value");
valueEl.textContent = value;
metric.append(labelEl, valueEl);
return metric;
}
async moveSubject(subjects, sourceIndex, targetIndex, activeContestId) {
try {
if (!activeContestId) {
@ -3341,13 +3334,7 @@ var DashboardTab = class {
return panel;
}
renderActivityMeta(label, value) {
const item = DomHelpers.createElement("span", "leif-next-activity-chip");
const labelEl = DomHelpers.createElement("span", "leif-next-activity-chip-label");
labelEl.textContent = `${label}:`;
const valueEl = DomHelpers.createElement("span", "leif-next-activity-chip-value");
valueEl.textContent = value;
item.append(labelEl, valueEl);
return item;
return DomHelpers.createMetric(label, value);
}
formatDaysUntilExam(examDate) {
const now = /* @__PURE__ */ new Date();
@ -5057,17 +5044,26 @@ var WallTab = class {
} else {
const subjectMap = new Map(data.subjects.map((s) => [s.id, s.name]));
const itemMap = new Map(data.studyItems.map((item) => [item.id, item.title]));
card.appendChild(
DomHelpers.createTable(
["Mat\xE9ria", "Peso", "Pontua\xE7\xE3o", "Itens alvo"],
activeContest.wall.subjectSnapshots.map((snapshot) => [
subjectMap.get(snapshot.subjectId) ?? snapshot.subjectId,
snapshot.weight !== void 0 ? String(snapshot.weight) : "\u2014",
snapshot.score !== void 0 ? String(snapshot.score) : "\u2014",
const list = DomHelpers.createElement("div", "leif-wall-snapshot-list");
activeContest.wall.subjectSnapshots.forEach((snapshot) => {
const row = DomHelpers.createElement("section", "leif-wall-snapshot-card");
const titleGroup = DomHelpers.createElement("div", "leif-wall-snapshot-title-group");
const title = DomHelpers.createElement("strong", "leif-wall-snapshot-title");
title.textContent = subjectMap.get(snapshot.subjectId) ?? snapshot.subjectId;
titleGroup.appendChild(title);
const metrics = DomHelpers.createElement("div", "leif-wall-snapshot-meta");
metrics.append(
DomHelpers.createMetric("Peso", snapshot.weight !== void 0 ? String(snapshot.weight) : "\u2014"),
DomHelpers.createMetric("Pontua\xE7\xE3o", snapshot.score !== void 0 ? String(snapshot.score) : "\u2014"),
DomHelpers.createMetric(
"Itens alvo",
snapshot.targetItems?.map((itemId) => itemMap.get(itemId) ?? itemId).join(", ") ?? "\u2014"
])
)
);
)
);
row.append(titleGroup, metrics);
list.appendChild(row);
});
card.appendChild(list);
}
return card;
}

View file

@ -268,6 +268,25 @@
text-align: right;
}
.leif-metric {
display: flex;
flex-direction: column;
gap: var(--size-2-1);
min-width: 0;
}
.leif-metric-label {
color: var(--text-muted);
font-size: 12px;
line-height: 1.2;
}
.leif-metric-value {
color: var(--text-normal);
line-height: 1.3;
overflow-wrap: anywhere;
}
.leif-toolbar {
display: flex;
gap: var(--size-4-3);
@ -567,23 +586,10 @@
min-width: 0;
}
.leif-cycle-metric {
display: flex;
flex-direction: column;
gap: var(--size-2-1);
min-width: 0;
}
.leif-cycle-metric-label {
color: var(--text-muted);
font-size: 12px;
line-height: 1.2;
}
.leif-cycle-metric-value {
color: var(--text-normal);
line-height: 1.3;
overflow-wrap: anywhere;
.leif-cycle-card > .leif-inline-actions,
.leif-contest-card > .leif-inline-actions {
justify-content: flex-end;
align-items: center;
}
.leif-cycle-edit-form {
@ -599,9 +605,10 @@
}
.leif-contest-card {
display: flex;
flex-direction: column;
gap: var(--size-4-3);
display: grid;
grid-template-columns: minmax(240px, 1.2fr) minmax(260px, 1fr) auto;
align-items: center;
gap: var(--size-4-4);
padding: var(--size-4-3) 0;
border-top: 1px solid var(--leif-border);
}
@ -632,10 +639,14 @@
}
.leif-contest-meta {
display: flex;
display: grid;
grid-template-columns: repeat(auto-fit, minmax(92px, 1fr));
gap: var(--size-4-3);
flex-wrap: wrap;
color: var(--text-muted);
min-width: 0;
}
.leif-contest-notes {
grid-column: 1 / -1;
}
.leif-contest-edit-form {
@ -801,6 +812,37 @@
align-self: flex-end;
}
.leif-wall-snapshot-list {
display: flex;
flex-direction: column;
}
.leif-wall-snapshot-card {
display: grid;
grid-template-columns: minmax(220px, 0.9fr) minmax(300px, 1.4fr);
align-items: start;
gap: var(--size-4-4);
padding: var(--size-4-3) 0;
border-top: 1px solid var(--leif-border);
}
.leif-wall-snapshot-card:first-child {
border-top: 0;
padding-top: 0;
}
.leif-wall-snapshot-title {
font-size: var(--font-ui-medium);
line-height: 1.3;
overflow-wrap: anywhere;
}
.leif-wall-snapshot-meta {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: var(--size-4-3);
}
/* Dashboard next activity */
.leif-next-activity {
display: flex;
@ -827,6 +869,11 @@
justify-content: center;
}
.leif-next-activity-meta .leif-metric {
align-items: flex-end;
text-align: right;
}
.leif-next-activity-label {
font-size: 13px;
color: var(--text-muted);
@ -1067,6 +1114,11 @@
align-items: flex-start;
}
.leif-next-activity-meta .leif-metric {
align-items: flex-start;
text-align: left;
}
.leif-next-activity-chip {
justify-content: flex-start;
}
@ -1079,13 +1131,17 @@
flex-direction: column;
}
.leif-contest-card,
.leif-cycle-card {
grid-template-columns: 1fr;
gap: var(--size-4-3);
}
.leif-contest-meta,
.leif-cycle-card-meta,
.leif-cycle-edit-form {
.leif-cycle-edit-form,
.leif-wall-snapshot-card,
.leif-wall-snapshot-meta {
grid-template-columns: 1fr;
}

View file

@ -138,32 +138,31 @@ export class ContestsTab {
);
}
const header = DomHelpers.createElement("div", "leif-contest-card-header");
const titleGroup = DomHelpers.createElement("div", "leif-contest-card-title-group");
const title = DomHelpers.createElement("strong", "leif-contest-card-title");
title.textContent = contest.name;
const status = DomHelpers.createElement("span", isActive ? "leif-status-active" : "leif-status-inactive");
status.textContent = isActive ? "Estudando agora" : "Guardado";
titleGroup.append(title, status);
header.append(titleGroup, actions);
const notes = DomHelpers.createParagraph(contest.wall.notes?.trim() || "Sem notas ainda.");
notes.classList.add("leif-contest-notes");
const meta = DomHelpers.createElement("div", "leif-contest-meta");
meta.append(this.renderMetaChip("ID", contest.id));
meta.append(DomHelpers.createMetric("ID", contest.id));
if (contest.examPlan?.examDate) {
meta.append(this.renderMetaChip("Prova", contest.examPlan.examDate));
meta.append(DomHelpers.createMetric("Prova", contest.examPlan.examDate));
}
if (contest.examPlan?.board) {
meta.append(this.renderMetaChip("Banca", contest.examPlan.board));
meta.append(DomHelpers.createMetric("Banca", contest.examPlan.board));
}
if (contest.examPlan?.weeklyStudyHours !== undefined) {
meta.append(this.renderMetaChip("Carga", `${contest.examPlan.weeklyStudyHours} h/semana`));
meta.append(DomHelpers.createMetric("Carga", `${contest.examPlan.weeklyStudyHours} h/semana`));
}
if (contest.examPlan?.weeklyQuestionGoal !== undefined) {
meta.append(this.renderMetaChip("Meta", `${contest.examPlan.weeklyQuestionGoal} questões/semana`));
meta.append(DomHelpers.createMetric("Meta", `${contest.examPlan.weeklyQuestionGoal} questões/semana`));
}
card.append(header, notes, meta);
card.append(titleGroup, meta, actions, notes);
return card;
}
@ -285,16 +284,6 @@ export class ContestsTab {
return form;
}
private renderMetaChip(label: string, value: string): HTMLElement {
const chip = DomHelpers.createElement("span", "leif-next-activity-chip");
const labelEl = DomHelpers.createElement("span", "leif-next-activity-chip-label");
labelEl.textContent = `${label}:`;
const valueEl = DomHelpers.createElement("span", "leif-next-activity-chip-value");
valueEl.textContent = value;
chip.append(labelEl, valueEl);
return chip;
}
private readOptionalNumber(value: string): number | undefined {
if (!value.trim()) {
return undefined;

View file

@ -139,8 +139,8 @@ export class CycleTab {
const meta = DomHelpers.createElement("div", "leif-cycle-card-meta");
meta.append(
this.renderCycleMetric("Tempo", `${subject.plannedStudyMinutes} min`),
this.renderCycleMetric("Etapa", subject.currentStage ?? "—")
DomHelpers.createMetric("Tempo", `${subject.plannedStudyMinutes} min`),
DomHelpers.createMetric("Etapa", subject.currentStage ?? "—")
);
card.append(titleGroup, meta, actions);
@ -302,16 +302,6 @@ export class CycleTab {
return chip;
}
private renderCycleMetric(label: string, value: string): HTMLElement {
const metric = DomHelpers.createElement("div", "leif-cycle-metric");
const labelEl = DomHelpers.createElement("span", "leif-cycle-metric-label");
labelEl.textContent = label;
const valueEl = DomHelpers.createElement("span", "leif-cycle-metric-value");
valueEl.textContent = value;
metric.append(labelEl, valueEl);
return metric;
}
private async moveSubject(
subjects: Subject[],
sourceIndex: number,

View file

@ -216,13 +216,7 @@ export class DashboardTab {
}
private renderActivityMeta(label: string, value: string): HTMLElement {
const item = DomHelpers.createElement("span", "leif-next-activity-chip");
const labelEl = DomHelpers.createElement("span", "leif-next-activity-chip-label");
labelEl.textContent = `${label}:`;
const valueEl = DomHelpers.createElement("span", "leif-next-activity-chip-value");
valueEl.textContent = value;
item.append(labelEl, valueEl);
return item;
return DomHelpers.createMetric(label, value);
}
private formatDaysUntilExam(examDate: string): string {

View file

@ -159,17 +159,28 @@ export class WallTab {
} else {
const subjectMap = new Map(data.subjects.map((s) => [s.id, s.name]));
const itemMap = new Map(data.studyItems.map((item) => [item.id, item.title]));
card.appendChild(
DomHelpers.createTable(
["Matéria", "Peso", "Pontuação", "Itens alvo"],
activeContest.wall.subjectSnapshots.map((snapshot) => [
subjectMap.get(snapshot.subjectId) ?? snapshot.subjectId,
snapshot.weight !== undefined ? String(snapshot.weight) : "—",
snapshot.score !== undefined ? String(snapshot.score) : "—",
const list = DomHelpers.createElement("div", "leif-wall-snapshot-list");
activeContest.wall.subjectSnapshots.forEach((snapshot) => {
const row = DomHelpers.createElement("section", "leif-wall-snapshot-card");
const titleGroup = DomHelpers.createElement("div", "leif-wall-snapshot-title-group");
const title = DomHelpers.createElement("strong", "leif-wall-snapshot-title");
title.textContent = subjectMap.get(snapshot.subjectId) ?? snapshot.subjectId;
titleGroup.appendChild(title);
const metrics = DomHelpers.createElement("div", "leif-wall-snapshot-meta");
metrics.append(
DomHelpers.createMetric("Peso", snapshot.weight !== undefined ? String(snapshot.weight) : "—"),
DomHelpers.createMetric("Pontuação", snapshot.score !== undefined ? String(snapshot.score) : "—"),
DomHelpers.createMetric(
"Itens alvo",
snapshot.targetItems?.map((itemId) => itemMap.get(itemId) ?? itemId).join(", ") ?? "—"
])
)
);
)
);
row.append(titleGroup, metrics);
list.appendChild(row);
});
card.appendChild(list);
}
return card;
}

View file

@ -223,6 +223,19 @@ export class DomHelpers {
return row;
}
/**
* Creates a compact label/value metric for scannable list rows.
*/
static createMetric(label: string, value: string): HTMLElement {
const metric = this.createElement("div", "leif-metric");
const labelEl = this.createElement("span", "leif-metric-label");
labelEl.textContent = label;
const valueEl = this.createElement("span", "leif-metric-value");
valueEl.textContent = value;
metric.append(labelEl, valueEl);
return metric;
}
/**
* Creates a table with headers and rows.
* @param headers - Column headers

View file

@ -268,6 +268,25 @@
text-align: right;
}
.leif-metric {
display: flex;
flex-direction: column;
gap: var(--size-2-1);
min-width: 0;
}
.leif-metric-label {
color: var(--text-muted);
font-size: 12px;
line-height: 1.2;
}
.leif-metric-value {
color: var(--text-normal);
line-height: 1.3;
overflow-wrap: anywhere;
}
.leif-toolbar {
display: flex;
gap: var(--size-4-3);
@ -567,23 +586,10 @@
min-width: 0;
}
.leif-cycle-metric {
display: flex;
flex-direction: column;
gap: var(--size-2-1);
min-width: 0;
}
.leif-cycle-metric-label {
color: var(--text-muted);
font-size: 12px;
line-height: 1.2;
}
.leif-cycle-metric-value {
color: var(--text-normal);
line-height: 1.3;
overflow-wrap: anywhere;
.leif-cycle-card > .leif-inline-actions,
.leif-contest-card > .leif-inline-actions {
justify-content: flex-end;
align-items: center;
}
.leif-cycle-edit-form {
@ -599,9 +605,10 @@
}
.leif-contest-card {
display: flex;
flex-direction: column;
gap: var(--size-4-3);
display: grid;
grid-template-columns: minmax(240px, 1.2fr) minmax(260px, 1fr) auto;
align-items: center;
gap: var(--size-4-4);
padding: var(--size-4-3) 0;
border-top: 1px solid var(--leif-border);
}
@ -632,10 +639,14 @@
}
.leif-contest-meta {
display: flex;
display: grid;
grid-template-columns: repeat(auto-fit, minmax(92px, 1fr));
gap: var(--size-4-3);
flex-wrap: wrap;
color: var(--text-muted);
min-width: 0;
}
.leif-contest-notes {
grid-column: 1 / -1;
}
.leif-contest-edit-form {
@ -801,6 +812,37 @@
align-self: flex-end;
}
.leif-wall-snapshot-list {
display: flex;
flex-direction: column;
}
.leif-wall-snapshot-card {
display: grid;
grid-template-columns: minmax(220px, 0.9fr) minmax(300px, 1.4fr);
align-items: start;
gap: var(--size-4-4);
padding: var(--size-4-3) 0;
border-top: 1px solid var(--leif-border);
}
.leif-wall-snapshot-card:first-child {
border-top: 0;
padding-top: 0;
}
.leif-wall-snapshot-title {
font-size: var(--font-ui-medium);
line-height: 1.3;
overflow-wrap: anywhere;
}
.leif-wall-snapshot-meta {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: var(--size-4-3);
}
/* Dashboard next activity */
.leif-next-activity {
display: flex;
@ -827,6 +869,11 @@
justify-content: center;
}
.leif-next-activity-meta .leif-metric {
align-items: flex-end;
text-align: right;
}
.leif-next-activity-label {
font-size: 13px;
color: var(--text-muted);
@ -1067,6 +1114,11 @@
align-items: flex-start;
}
.leif-next-activity-meta .leif-metric {
align-items: flex-start;
text-align: left;
}
.leif-next-activity-chip {
justify-content: flex-start;
}
@ -1079,13 +1131,17 @@
flex-direction: column;
}
.leif-contest-card,
.leif-cycle-card {
grid-template-columns: 1fr;
gap: var(--size-4-3);
}
.leif-contest-meta,
.leif-cycle-card-meta,
.leif-cycle-edit-form {
.leif-cycle-edit-form,
.leif-wall-snapshot-card,
.leif-wall-snapshot-meta {
grid-template-columns: 1fr;
}

View file

@ -313,7 +313,7 @@ describe("LeifView", () => {
expect(cycleCards.length).toBeGreaterThanOrEqual(2);
expect(cycleCards[0]?.querySelector(".leif-cycle-card-order")?.textContent).toContain("1");
expect(cycleCards[0]?.querySelector(".leif-cycle-card-title")?.textContent).toBeTruthy();
expect(cycleCards[0]?.querySelectorAll(".leif-cycle-metric").length).toBe(2);
expect(cycleCards[0]?.querySelectorAll(".leif-metric").length).toBe(2);
expect(cycleCards[0]?.querySelector(".leif-key-value")).toBeNull();
expect(cycleCards[0]?.textContent).toContain("No ciclo");
expect(cycleCards[0]?.textContent).toContain("Tempo");
@ -510,6 +510,35 @@ describe("LeifView", () => {
it("organizes the wall into clear edital, prova and notes sections", async () => {
const dataStore = new InMemoryPluginDataStore();
await seedUiData(dataStore);
const data = await dataStore.load();
const activeContest = data.contests.find((contest) => contest.id === data.activeContestId);
const subject = data.subjects[0];
const item = data.studyItems[0];
if (!activeContest || !subject || !item) {
throw new Error("Seed data was not created.");
}
const updateContestWall = new UpdateContestWallUseCase(
dataStore,
new EntityRepositoryFactory(dataStore)
);
await updateContestWall.execute({
contestId: activeContest.id,
wall: {
noticeLinks: activeContest.wall.noticeLinks,
examLinks: activeContest.wall.examLinks,
notes: activeContest.wall.notes,
subjectSnapshots: [
{
subjectId: subject.id,
weight: 2,
score: 8,
targetItems: [item.id]
}
]
}
});
const { leaf } = await openLeifView(dataStore);
const wallTabButton = leaf.containerEl.querySelector<HTMLButtonElement>("[data-tab='wall']");
@ -531,6 +560,9 @@ describe("LeifView", () => {
expect(leaf.containerEl.querySelector(".leif-wall-links")).toBeNull();
expect(leaf.containerEl.querySelector(".leif-wall-card-wide")).toBeNull();
expect(leaf.containerEl.querySelector("button.leif-wall-save")).not.toBeNull();
expect(leaf.containerEl.querySelector(".leif-wall-snapshot-card")).not.toBeNull();
expect(leaf.containerEl.querySelector(".leif-wall-snapshot-card .leif-metric")).not.toBeNull();
expect(leaf.containerEl.querySelector(".leif-wall-snapshot-list table.leif-table")).toBeNull();
expect(leaf.containerEl.textContent).toContain("Salvar mural");
});