Skip to content

fix: Analytics - Excel date export and period picker review EXO-88863 - #449

Open
srenault-meeds wants to merge 7 commits into
feature/ai-contributionfrom
fix/analytics-excel-date-and-period-picker-EXO-88863
Open

fix: Analytics - Excel date export and period picker review EXO-88863#449
srenault-meeds wants to merge 7 commits into
feature/ai-contributionfrom
fix/analytics-excel-date-and-period-picker-EXO-88863

Conversation

@srenault-meeds

Copy link
Copy Markdown
Member

Follow-up fixes on the Threshold-on-aggregate delivery (#422 / #448), from review of
commit 763a51c and from reviewer feedback on the resulting UI.

Excel export

  • Date buckets and date columns are written as real date cells instead of the
    localized label or the raw epoch. "1 sept. 2026" in a text cell is a
    picture of a date: it sorts lexicographically, cannot be filtered by period
    and no cell formatting recovers it; a raw 1.75941E+12 is worse. Any column
    the table renders as a date (its dataType, the same signal
    AnalyticsTableCellValue uses) is now exported as a date, in the time zone
    the buckets were aligned on.
  • Quarter, ISO-week and hour-of-day keep their textual label on purpose: no
    faithful spreadsheet format token exists for them, and the hour bucket key
    is an hour of day, not an instant.
  • A row with no value no longer exports the literal string null.
  • The response declared application/vnd.ms-excel for an OOXML .xlsx, which
    is what made Excel open the download with a "file format and extension don't
    match" warning. Corrected.

Period picker

  • One row per bound (From / To), each with an editable date and hour, on a
    grid so both rows align. Laid out on a single centered line the six items
    overflowed the menu's fixed 420px and the leading label was clipped.
  • The samples drawer used four bare date/time inputs. It now uses the same
    picker as the chart header, with a Select period header and a separator
    before the samples list.
  • The panel is extracted (AnalyticsPeriodPickerPanel) so the chart's
    activator+menu and the drawer share one definition instead of a second copy.
    The chart's period-shortcut arithmetic moves to $analyticsUtils.

Review finding

loadAll(), waitUntilLoaded() and getDisplayedGrid() were added to
AnalyticsTable.vue by 763a51c and called by nobody: remains of a
DOM-scraping client export superseded by the server-side one, in the Table
portlet that commit's own description puts out of scope. Removed.

Build

analytics-webapps had no test directory, so its JaCoCo check passed
vacuously. Adding the first test turns the gate on, and the parent POM
defaults exo.test.coverage.ratio to 1.0 — the module now sets it to 0 like
analytics-api and analytics-services. Reproduced with CI's own profile
before and after.

Tests

AnalyticsExcelDateCellTest reads the workbook back through POI rather than
asserting on what was passed in, and was mutation-verified: reverting the fix
fails it on the cell type. mvn -P coverage verify is green (11 tests),
eslint clean.

There is no JS test harness in this repo, so the picker changes are verified
by hand only, on a local instance.

Knowledge: to be added before merge — the corpus PR is drafted but not yet
opened (dev-lifecycle §3b step 5).

Classification: N2 — touches commonAnalyticsVueComponents, a shared
module content, gamification and notes each declare a <depends> on,
and the public AbstractAnalyticsPortlet. Its approver must be an
Architect/Senior Developer who knows it is N2, not an approval on AI review
alone.

🤖 Generated with Claude Code

srenault-meeds and others added 6 commits September 11, 2026 11:14
…XO-88863

763a51c added loadAll(), waitUntilLoaded() and getDisplayedGrid() to
AnalyticsTable.vue. Nothing calls them: they are the remains of a client-side
export that reads the rendered DOM, superseded by the fully server-side export
in AnalyticsTablePortlet.exportExcel, which recomputes every row from the
filter and is not capped by what the current page happens to have loaded.

They also sit in the Table portlet, which that commit's own description puts
out of scope ("not the Table portlet"), and waitUntilLoaded() polls on a
100ms timer with no upper bound - a loop with no caller today, and a hang
waiting to happen for whoever wires it tomorrow.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Both Excel exports wrote a date bucket as the localized label the chart
draws - "1 sept. 2026" - into a text cell. To a spreadsheet that is a
picture of a date, not a date: the column sorts lexicographically ("10 aout"
before "1 sept."), cannot be filtered by period or used in a formula, and no
cell formatting recovers it, because the underlying value is a string.

Write the bucket's epoch-millis key as a date-typed cell instead, in the time
zone the buckets were aligned on so the exported day is the one the chart
shows. The day format is Excel's builtin "m/d/yy", which each reader's Excel
renders in their own short-date convention.

Kept as text, deliberately: quarter and ISO-week, which have no faithful
spreadsheet format token (a real date would display as the period's first
day, losing "Q3 2026" / "W37-2026"), and the hour interval, whose bucket key
is an hour of day cumulated over the period rather than an instant - read as
epoch millis it would export as 1 January 1970.

Also corrects the response content type: both exports write an OOXML workbook
(XSSF, .xlsx) while declaring "application/vnd.ms-excel", the type of the
legacy binary .xls, which is what makes Excel open the download with a "the
file format and the extension don't match" warning.

The styles are cached per workbook: an export runs up to 5000 rows and a
workbook holds a bounded number of cell styles.

Tests read the workbook back through POI itself rather than asserting on what
was passed in, and were mutation-verified: reverting writeDateCell to the
text-cell behaviour fails them on the cell type.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…the samples drawer EXO-88863

The picker's footer laid "from <date> <time> To <date> <time>" on a single
centered nowrap line inside a menu fixed at 420px. The six items overflowed
it and the leading "from" was the part clipped away, in a mix of caption,
primary--text and unstyled native inputs.

One row per bound now, From above To, everything in text-body. The label
column has a min-width so both rows' dates line up, and nothing is kept on
one line any more, so nothing is clipped. "from"/"du" become "From"/"Du" and
the French "A" becomes "Au": the two keys are used by this picker and the
samples drawer only, and the drawer showed a lowercase "from" against a
capitalized "To".

The samples drawer filtered its samples with four bare date/time inputs
instead. It now uses the same picker - extracted to
common-components/components/period/AnalyticsPeriodPicker.vue and registered
as analytics-period-picker, rather than copied a third time - followed by a
separator before the list of samples. It passes `attach` so the popup renders
inside the drawer instead of being teleported behind it, the same reason the
settings drawers pass `attach` to their own selects.

The period shortcuts' date arithmetic moves to $analyticsUtils
(computePeriodDateRange), shared by the picker and the chart's own initial
period. The 25% width budget moves from the picker to the portlet headers
that own it, so the drawer's copy gets the room it needs.

Not touched: the Rate and Table portlets still inline their own copy of the
menu. Neither has the From/To rows this fixes, and their headers are already
validated - they can move to the shared component on their next change.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
AnalyticsExcelDateCellTest is the first test in analytics-webapps. The parent
pom defaults exo.test.coverage.ratio to 1.0 and jacoco:check enforces it on
the whole bundle, so a module is gated at 100% instruction coverage the
moment it has any test at all - which is why analytics-api and
analytics-services both already set the property to 0.

Until now the module had no jacoco data and the check passed vacuously.
Reproduced with CI's own profile (mvn -P coverage verify): "Rule violated for
bundle analytics-webapps: instructions covered ratio is 0.0, but expected
minimum is 1.0", green again with the property set.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The previous commit only converted date-histogram buckets. A column
aggregating a date *field* - a MIN/MAX over "creation date" or "last
connection" - is a numeric aggregation, so it kept falling through to
Double.parseDouble and reached the reader as 1.75941E+12.

Key the conversion off the same signal the table itself renders from: the
column's dataType, which AnalyticsTableCellValue uses to decide between a
<date-format> and a plain value. Any column the screen shows as a date is
now exported as a date, which is the rule the reporter asked for. The
Elasticsearch field mapping stays as a fallback for a column saved before
the data type was recorded, and the conversion now also covers user-profile
columns, not only aggregations.

Also stops writing the literal string "null" into a cell when Elasticsearch
returns no value for a row (a user who never connected): the cell is empty.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Follow-up on the reviewer's feedback on the two pickers:

- both bounds are editable in their own row, not only on the calendar: the
  From/To dates are date inputs writing into the same `dates` array the
  calendar binds, so the two stay one state;
- the three columns (label / date / hour) hug their content on a grid
  aligned on the baseline, so the hour sits next to its date instead of
  being pushed to the far edge, and both rows line up whatever the rendered
  width ("Sep 1" vs "Sep 10");
- the samples drawer regains its "Select period" header above the picker.

A range picker emits its first click as a one-element array. Emitting that
made the owner reload on the opening click and re-render the menu under the
pointer, so the second click never landed and no range could be picked at
all - the panel now emits only once both bounds are set, and closing the
popup still commits a single date.

The panel is split out of the picker (AnalyticsPeriodPickerPanel) so the
chart's activator+menu and the drawer share one definition of the panel
rather than a second copy of its markup.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@srenault-meeds
srenault-meeds force-pushed the fix/analytics-excel-date-and-period-picker-EXO-88863 branch from 638e53c to 6e3045e Compare September 11, 2026 09:16
SonarCloud failed the quality gate on #449: 19.4% coverage on new code,
required 60%. The .vue and .js files are already excluded from coverage by
the parent POM, so the gap was the export code in AnalyticsTablePortlet,
whose new decisions - which columns count as dates, and what a value becomes
in the spreadsheet - had no test at all.

AnalyticsTableExportCellTest reads each value back off the written cell
rather than asserting on what was passed in, so a cell written as text where
a date was meant fails it. It covers the dataType signal and its mapping
fallback, the date/non-date paths, the missing-value cell, a zero that must
not become 1 January 1970, a date column holding something that is not an
instant, and the per-workbook style cache.

Mutation-verified: forcing the date branch off fails
testDateColumnValueIsWrittenAsARealDate and testOneStyleIsSharedByEveryDateCell,
and passes again once restored.

isDateColumn, writeValue and the ExportFormatting record drop from private to
package-private so the test can exercise them directly; testing them through
exportExcel would need a mocked portlet request and response and would pin
the plumbing rather than these decisions.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@sonarqubecloud

Copy link
Copy Markdown

Quality Gate Failed Quality Gate failed

Failed conditions
50.0% Coverage on New Code (required ≥ 60%)

See analysis details on SonarQube Cloud

@ahamdi ahamdi left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

AI review — Round #1

Reviewed at head 03a1d0e0, against the code at that commit — not against develop, since this branch's whole server-side export (a6bdc3dc, #420) is not on develop yet. Six findings inline, most severe first.

The Excel work is the sound part of this PR. The gap is that "which columns hold dates" is answered in three different places, and one of the three — the space-field branch — was never wired up at all.

Verified conform

  • The date-cell mechanism. timestamp is mapped "format": "epoch_millis" (analytics-es-template.json:23), so the key_as_string reaching writeDateCell really is epoch millis and Long.parseLong holds. setCellValue(LocalDateTime) is the right overload for honouring filter.zoneId(), and the style cache is keyed by format string and scoped per workbook — no cross-workbook leak.
  • The hour exclusion is correct, and for exactly the reason the javadoc gives. I set out to report the opposite and was wrong. hour + DATE never reaches appendIntervalTypeQuery: ElasticsearchAnalyticsService:779-801 intercepts it and emits a terms over a painless getHour() script with "size": 24, and AnalyticsAggregation.getLabel:166-169 formats the key as "%02d:00". The bucket key really is 0-23. Adding hour to EXCEL_DATE_FORMATS would put 1 January 1970 in every hourly export.
  • Chart row alignment after the getLabels()getAggregationLabels() switch is exact. ChartDataList.getLabels() is itself a map over the same LinkedHashSet, so the new list is index-for-index identical to the one the old code iterated; the label-only @EqualsAndHashCode already governed both, and the retained instance carries the raw bucket key for that label. writeCategoryCell's size() == 1 guard correctly leaves a composite multi-x-axis label as text.
  • Content type. application/vnd.openxmlformats-officedocument.spreadsheetml.sheet is right for the XSSFWorkbook both exports write.
  • retrieveMapping(false) reads the in-memory cache refreshed on a 2-minute schedule — one HashSet copy per export, not an ES round-trip per row.
  • i18n. analytics.from / analytics.toDate have exactly six consumers at head, all inside the new panel, so fromFrom and duDu regress nothing. The {0: …, 1: …} object form for analytics.period.range matches the call style already used in AnalyticsTableCellValue.vue.
  • The less rework keeps all three portlet headers' width budget — the > combinator matches in the chart, rate and table portlets alike; the samples drawer deliberately matches neither (but see the drawer-styling finding, which is a different rule).
  • The Vue lifecycle is sound. initPanel's $nextTick resolves after Vuetify's bootable renders the content (isActive flips synchronously in the activator's own handler, ahead of @click), so $refs.panel is populated on first open; two mounted instances do not interfere (handleOutsideClick returns on !this.menu); beforeDestroy removes the listener with the same capture flag; and the extracted $refs.activator.$el || $refs.activator fixes a real pre-existing bug — the old version used .$el only, which is undefined for the non-compact <button>.
  • ViewSamplesDrawer still filters correctly. The emitted {period, min, max} is a superset of the old shape, the parent's selectedPeriod prop is never mutated, and attach on the drawer's picker is honoured (Vuetify's initDetach short-circuits on attach === true).
  • exo.test.coverage.ratio=0 matches analytics-api and analytics-services (both at pom line 31), and the comment's claim is true — the parent pom sets the jacoco minimum to 1.0.
  • The dead-code removal in AnalyticsTable.vue is genuinely deadloadAll, waitUntilLoaded and getDisplayedGrid have no callers at head.

What the PR does well

The date-cell decisions are argued from the reader's spreadsheet rather than from the code, and each exclusion is justified by a property of the format or the bucket key rather than by convenience — the hour one turning out correct for a non-obvious reason three layers away in the ES query builder. Both test classes read the workbook back through POI (getLocalDateTimeCellValue, DateUtil.isCellDateFormatted, getCellType) instead of asserting on inputs, which is the only way this class of defect is catchable; testBucketIsWrittenInTheQueriedTimeZone with Honolulu is a genuinely discriminating case, and the style-cache tests pin a real ceiling rather than a tautology.


Classification: N2, as the author proposed, and I would not lower it. The diff touches commonAnalyticsVueComponents — a shared module content, gamification and notes each declare a <depends> on — and the public AbstractAnalyticsPortlet. No socle trigger fires: no ACL, no Liquibase or JPA, no Kernel↔Spring bridge, no MCP tool, no new trust boundary. Per ai-review-and-merge.md §5 its approver must be an Architect/Senior Developer who knows it is N2, not an approval on AI review alone.

Two scope calls are left to a human, not to this review: whether the space-field and createdDate export gaps belong to this PR or a follow-up, and whether the two surviving inline picker copies block merge.

🤖 Generated with Claude Code

writeValue(cell, property == null ? null : String.valueOf(property), dateColumn, formatting);
return;
} else if (StringUtils.isNotBlank(columnFilter.getSpaceField())) {
cell.setCellValue(spaceFieldValue(rowSpace, columnFilter.getSpaceField()));

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 High — every space-field column exports the space display name, whatever field was chosen

} else if (StringUtils.isNotBlank(columnFilter.getSpaceField())) {
  cell.setCellValue(spaceFieldValue(rowSpace, columnFilter.getSpaceField()));  // never reaches writeValue
  return;
}
private String spaceFieldValue(Space space, String field) {
  if (space == null) { return ""; }
  return switch (field) {
  case "description" -> space.getDescription();
  case "groupId"     -> space.getGroupId();
  case "prettyName"  -> space.getPrettyName();
  case "shortName"   -> space.getShortName();
  case "url"         -> space.getUrl();
  default            -> space.getDisplayName();   // <- every field the UI offers lands here
  };
}

The settings UI offers exactly seven space fields — createdTime, managersCount, membersCount, redactorsCount, template, subscription, visibility (AnalyticsTableApplication.vue:315-352) — and not one of them is a case in that switch, so all seven export the space name. AnalyticsUtils.convertFieldName only rewrites to _alt/.keyword variants, so it cannot rescue them either. This is also the one branch that bypasses the new writeValue, discarding the dateColumn flag already computed one line above — a createdTime column carries dataType == "date", exactly what this PR set out to export as a real date.

Pre-existing (introduced by a6bdc3dc, #420) and untouched by this PR — but a6bdc3dc is not on develop, so the whole server-side export is new code that reaches develop with this feature branch, which makes this the moment to fix it.

Fix: route the branch through writeValue(cell, spaceFieldValue(...), dateColumn, formatting) and give spaceFieldValue a case per offered field: createdTime -> String.valueOf(space.getCreatedTime()), visibility -> space.getVisibility(), subscription -> space.getRegistration(), template -> String.valueOf(space.getTemplateId()), and the three counts from space.getManagers()/getMembers()/getRedactors() .length (there is no getMembersCount() on Space — the REST SpaceEntity has one, the core model does not). Keep default -> "" rather than the display name, so the next unmapped field is visibly empty instead of silently wrong.

* fallback, for a column saved before the data type was recorded.
*/
boolean isDateColumn(AnalyticsTableColumnFilter columnFilter, ExportFormatting formatting) {
if (StringUtils.equalsIgnoreCase(columnFilter.getDataType(), "date")) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Medium — an integer-valued aggregation over a date field now exports as 1 January 1970

boolean isDateColumn(AnalyticsTableColumnFilter columnFilter, ExportFormatting formatting) {
  if (StringUtils.equalsIgnoreCase(columnFilter.getDataType(), "date")) {
    return true;                                     // no look at the aggregation type
  }
  AnalyticsTableColumnAggregation valueAggregation = columnFilter.getValueAggregation();
  return valueAggregation != null
      && valueAggregation.getAggregation() != null
      && formatting.isDateField(valueAggregation.getAggregation().getField());   // second trigger
}

isDateColumn asks which field a column aggregates, never how. A CARDINALITY or COUNT over a date field is a date column by both triggers and its value is a count — so writeValue hands "28" to writeTimestampCell, which reads it as an instant and writes 28 ms after the epoch. Before this PR the same cell exported the number 28.

Reachable two ways: AnalyticsTableColumnAggregationSetting.vue offers CARDINALITY with a field selector, and FieldSelection.vue:142 (!this.numeric || field.numeric || field.date) lets date fields through for every aggregation type. For COUNT the selector is only v-shown away, so a column switched from MAX(timestamp) to COUNT keeps aggregation.field = "timestamp", and AnalyticsTableColumnSetting.vue:290-295 then takes dataType from the field mapping — the dataType = 'long' shortcut fires only when the field is empty.

Scoped to those two: ES serialises sum/avg/min/max as floating-point literals (8.0, 3.51882E12), which Long.parseLong rejects, so they still fall through to the numeric path. cardinality and value_count are serialised as integral longs, which is why only they trip.

Nothing pins it: AnalyticsTableExportCellTest.java:86-98 hardcodes AnalyticsAggregationType.MAX in its column(...) helper, and the one small-integer case (line 151) passes dateColumn = false.

Fix: require an aggregation whose result is an instant — return false from isDateColumn for CARDINALITY, COUNT and TERMS whatever the field's mapping — and add the test: a CARDINALITY-over-lastLoginTime column whose "28" must come back as the number 28. The live table has the same defect via dataType === 'date' in AnalyticsTableCellValue.vue, so it is worth fixing on both sides.

Object property = rowIdentity == null || rowIdentity.getProfile() == null ? null
: rowIdentity.getProfile()
.getProperty(columnFilter.getUserField());
writeValue(cell, property == null ? null : String.valueOf(property), dateColumn, formatting);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Medium — the only user-field column the UI offers exports an empty cell

Object property = rowIdentity == null || rowIdentity.getProfile() == null ? null
                                                                         : rowIdentity.getProfile()
                                                                                      .getProperty(columnFilter.getUserField());
writeValue(cell, property == null ? null : String.valueOf(property), dateColumn, formatting);

AnalyticsTableApplication.vue:307-314 offers exactly one user field, {name: 'createdDate', type: 'date'} — and createdDate is never a Profile property. Profile.getProperty(name) is a plain properties.get(name); the creation instant lives in the separate createdTime field with its own getter, and the only createdDate key in social belongs to the REST DTO (ProfileEntity.CREATED_DATE, fed by EntityBuilder from user.getCreatedDate().getTime()). That REST map is what AnalyticsTableCell.vue:157-158 reads client-side, which is why the live table shows a date and the export does not.

To be clear, the writeValue routing this PR introduces is not a regression — it is an improvement: the cell held the literal string "null" before and holds "" now. But it also makes the gap silent, and an empty column is indistinguishable from "this user has no value".

Fix (hypothesis to confirm): special-case createdDate to rowIdentity.getProfile().getCreatedTime() before falling back to getProperty(...), so the export matches what the table draws. If it is deferred, say so — otherwise the next reader will assume the column works.

* thisSemester, thisYear
* @returns {Object} {from, to} Dates, or null for an unknown period name
*/
export function computePeriodDateRange(periodName) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Medium — the extraction is one-third done, and the two surviving copies do not get this PR's fix

The PR extracts the picker and moves the shortcut arithmetic to $analyticsUtils, then migrates only the generic chart portlet. The Table and Rate portlets keep the whole widget inline — v-menu + activator pair + v-date-picker + shortcut buttons, plus periodRangeLabel, initCompactPeriodForm, onCompactDatesInput, selectCompactPeriodItem, applyCompactPeriod, handlePeriodSelectorOutsideClick, the document.addEventListener('click', …, true) / removeEventListener pair, and a byte-identical private computePeriodDateRange:

  • table-portlet/components/AnalyticsTableApplication.vue:26-90, 540-641
  • rate-portlet/components/AnalyticsRateApplication.vue:51-118, 541-642

That is not only duplication: the half-picked-range fix exists in the new panel alone. AnalyticsPeriodPickerPanel.onDatesInput skips apply() for a one-element array, while AnalyticsTableApplication.vue:546-549 and AnalyticsRateApplication.vue:547-550 still call applyCompactPeriod() unconditionally — so the exact defect the PR body describes ("the second click never landed and no range could be picked") stays live in two of the three portlets. AnalyticsRateApplication.vue:247-248 also keeps compactFromTime/compactToTime in data with no markup bound to them.

frontend-vue.md (Centralized skin) treats a component duplicating a catalogued shared one as a finding naming the module that should have been used — here analytics-period-picker, now registered in commonAnalyticsVueComponents, which both portlets already depend on.

Fix: replace both inline copies with <analytics-period-picker :period="selectedPeriod" :compact="periodSelectorCompact" @change="selectedPeriod = $event" /> and delete the dead methods. If the two portlets are deliberately deferred, say so in the PR body and note that they still carry the half-picked-range bug.

// drawer the same component gets the room it needs.
.analytics-chart-header,
.analytics-table-header {
> .analytics-chart-period-selector {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Medium — in the samples drawer the shared picker loses its own styling

.analytics-chart-period-selector (line 221) and .analytics-period-selector-full (line 249) both live inside the .VuetifyApp .analytics-application block opened at line 104 — the next top-level block is at 376. But ViewSamplesDrawer uses <exo-drawer> without attached, and social's ExoDrawer.mountOnParent() does document.querySelector('#vuetify-apps').appendChild(this.$el) on mount and on every open(). #vuetify-apps sits under .VuetifyApp but not under .analytics-application.

So the very component this PR extracted to share with the drawer renders there without position: relative / display: flex / min-width: 40px on its root, and without width: 100%, cursor: pointer or the border-bottom underline on its activator — falling back to Vuetify's generic button reset, i.e. an unstyled borderless label. The PR's two new blocks (.analytics-compact-period-popup at 502, .analytics-compact-period-times at 526) are correctly top-level and do apply, which is what makes the gap easy to miss: the popup looks right and only the activator is bare.

Positioning itself is fine — Vuetify's attach path measures against the same offsetParent either way.

Fix: move .analytics-chart-period-selector and .analytics-period-selector-full to the top level of analytics.less, next to .analytics-compact-period-popup, now that the component is shared with a re-parented drawer. Passing attached to the exo-drawer would also work but changes the drawer's own behaviour, so the stylesheet move is the smaller change.

* when this bucket has no faithful date representation and the
* caller should fall back to the textual label
*/
/**

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟢 Nit — a few small ones

  • Orphaned javadoc. AbstractAnalyticsPortlet.java:442-479 is a full javadoc for writeDateCell (@param aggregation, @param key, @param styles) immediately followed by a second javadoc for writeTimestampCell. Java keeps only the last, so the first documents nothing and writeDateCell (line 496) ends up with none. Move it down.
  • Dead computed. AnalyticsPeriodPickerPanel.vue:102-104 defines uid() { return this._uid; }, referenced nowhere in the template or the script.
  • Two controls, one accessible name. In the same file, lines 50-75, each bound's date input and time input share an :aria-label (analytics.from, then analytics.toDate), so a screen reader announces "From" for both with no way to tell the date from the hour.
  • toDate setter on an unseeded panel. $set(this.dates, 1, value) on an empty array yields [undefined, "…"] (Vue pads the length first), so apply() bails on !this.dates[0] and typing a To date first silently does nothing. Only reachable when period is null, which the chart header and the drawer both prevent — hardening, not a bug. this.$set(this.dates, this.dates.length ? 1 : 0, value) closes it.
  • Duplicated guard comment. The blank/"null" check and its three-line comment appear verbatim in writeCell (487-491) and writeValue (515-520). Both guards are genuinely needed on their own paths; the comment only needs to live once.
  • PR body is stale on tests. It says "green (11 tests)"; head has 16 (6 + 10), AnalyticsTableExportCellTest having arrived in the last commit. It also states mutation-verification for AnalyticsExcelDateCellTest only — per backend-spring.md §7, either mutation-verify the new class too (revert writeValue's date branch; testDateColumnValueIsWrittenAsARealDate and testZeroIsNotExportedAsFirstOfJanuary1970 must fail) or say it was not done.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants