From f0c0707bd098d8a77eff3cb4ec6fc15af1ef9eb8 Mon Sep 17 00:00:00 2001 From: Thomas Jung Date: Thu, 27 Aug 2026 09:46:51 -0400 Subject: [PATCH] feat(admin): readable week axis + total KPI + region breakdown for Devtoberfest Signups (#2047) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Devtoberfest Signups Analytical List Page opened chart-first with the X-axis bound to the internal integer weekIndex — meaningless to users. This makes the report readable and adds the requested breakdowns: - Readable week axis: add a real, GROUPABLE weekMonday:Date column so the chart groups on it and shows the week's Monday date instead of weekIndex. No portable date-add exists across HANA/SQLite, so weekMonday is supplied per dialect (db/sqlite/native.cds via strftime, db/hana/native.cds via ADD_DAYS) — the CAP-documented per-DB model pattern. The analytics view is split into a JOIN-bearing facts view + a JOIN-free public projection because the compiler refuses to `extend` a view containing a JOIN. - Overall total: @UI.KPI #totalSignups header card (SUM(signups), unfiltered); the AnalyticalTable also renders its grand-total row. - Region breakdown: a "By Region" SelectionPresentationVariant (chart grouped by region); every GroupableProperty (region/role/edition) is also reachable via the chart's built-in dimension drill-down. enrich now derives weekLabel/cumulative from the real weekMonday when present, falling back to weekIndex — keeping weekIndex-grouped reads working. Wiring notes: - package.json requires.db.[development].model=db/sqlite, [hybrid]/[production]=db/hana. - .cdsrc nodejs build task also lists db/hana so the precompiled gen/srv/csn.json bakes the HANA weekMonday (verified: gen csn carries the ADD_DAYS calc). - Specifying requires.db.model defeats cds's compiled-model fast path, so a cold cds.connect.to('db') in dev/tests recompiles the model (~500ms). This is dev/test-only (prod uses the precompiled csn). It exposed a latent 30ms race in test/unit/rebuild-trigger.test.js, fixed there by mocking resolveTenantSettings so the dispatch unit does no real DB work. Verified: cds build --production (HANA hdbview uses ADD_DAYS; runtime csn has weekMonday), sqlite $apply groupby(weekMonday) returns readable dates, prod-profile model resolution loads cleanly, unit suite 8990 passed / 0 failed. --- .cdsrc.json | 2 +- app/admin-annotations.cds | 47 ++++++++++++-- db/devtoberfest-analytics.cds | 42 +++++++----- db/hana/native.cds | 16 +++++ db/sqlite/native.cds | 24 +++++++ package.json | 15 ++++- srv/admin-service.cds | 13 ++-- srv/lib/devtoberfest-signup-enrich.js | 64 ++++++++++++++----- ...devtoberfest-signup-analytics-hana.test.js | 16 +++++ .../devtoberfest-signup-analytics.test.js | 24 +++++++ test/unit/devtoberfest-signup-enrich.test.js | 21 ++++++ test/unit/rebuild-trigger.test.js | 11 ++++ 12 files changed, 250 insertions(+), 45 deletions(-) create mode 100644 db/hana/native.cds create mode 100644 db/sqlite/native.cds diff --git a/.cdsrc.json b/.cdsrc.json index a8a7270f2..6465c1dd4 100644 --- a/.cdsrc.json +++ b/.cdsrc.json @@ -8,7 +8,7 @@ "target": "gen", "tasks": [ { "for": "hana", "src": "db", "dest": "db" }, - { "for": "nodejs", "src": "srv", "dest": "srv", "options": { "model": ["srv", "db", "app", "@cap-js/data-inspector", "@cap-js/ai/srv/AICoreService", "cds-caching/db/cache-store", "cds-caching/db/statistics", "@cap-js/attachments/srv/malware-scanner/malwareScanner-mocked"] } }, + { "for": "nodejs", "src": "srv", "dest": "srv", "options": { "model": ["srv", "db", "db/hana", "app", "@cap-js/data-inspector", "@cap-js/ai/srv/AICoreService", "cds-caching/db/cache-store", "cds-caching/db/statistics", "@cap-js/attachments/srv/malware-scanner/malwareScanner-mocked"] } }, { "for": "hana", "src": "db-qa", "dest": "db-qa", "options": { "model": ["db-qa"] } }, { "for": "nodejs", "src": "srv-qa", "dest": "srv-qa", "options": { "model": ["srv-qa", "db-qa"] } }, { "for": "cds-caching" }, diff --git a/app/admin-annotations.cds b/app/admin-annotations.cds index 88cf38ec7..6af66f2e4 100644 --- a/app/admin-annotations.cds +++ b/app/admin-annotations.cds @@ -2007,7 +2007,7 @@ annotate AdminService.CompletionAnalytics with { annotate AdminService.DevtoberfestSignupAnalytics with @( Aggregation.ApplySupported: { Transformations: ['aggregate', 'groupby', 'filter', 'top', 'skip', 'orderby'], - GroupableProperties: [ weekIndex, eventName, eventType, region, role ], + GroupableProperties: [ weekMonday, weekIndex, eventName, eventType, region, role ], AggregatableProperties: [ { Property: signups } ] }, Analytics.AggregatedProperty #newSignups: { @@ -2016,14 +2016,53 @@ annotate AdminService.DevtoberfestSignupAnalytics with @( AggregatableProperty: signups, ![@Common.Label]: 'New Signups' }, + // Overall total registrations as a prominent KPI header card (issue #2047). + // SUM(signups) with no filter → grand total across all Devtoberfest signups. + // The AnalyticalTable additionally renders its own grand-total row for the + // measure, so the total is visible in both places. + UI.DataPoint #totalSignups: { + Value: signups, + Title: 'Total Registrations' + }, + UI.PresentationVariant #totalSignups: { + Visualizations: ['@UI.DataPoint#totalSignups'] + }, + UI.SelectionVariant #totalSignups: { + SelectOptions: [] + }, + UI.KPI #totalSignups: { + SelectionVariant : ![@UI.SelectionVariant#totalSignups], + DataPoint : ![@UI.DataPoint#totalSignups], + ![@UI.PresentationVariant] : ![@UI.PresentationVariant#totalSignups] + }, + // Default breakdown: signups per calendar week. The chart groups on the real + // Date column weekMonday (issue #2047) so the X-axis shows the week's Monday + // date (e.g. "Sep 7, 2026") instead of the meaningless internal weekIndex. UI.Chart: { ChartType: #Column, - Dimensions: [weekIndex], + Dimensions: [weekMonday], DynamicMeasures: ['@Analytics.AggregatedProperty#newSignups'] }, UI.PresentationVariant: { Visualizations: ['@UI.Chart', '@UI.LineItem'], - SortOrder: [{ Property: weekIndex }] + SortOrder: [{ Property: weekMonday }] + }, + // Alternate breakdown by region, one click away via Page variant management. + // (Every GroupableProperty — region, role, edition — is also reachable through + // the chart's built-in dimension drill-down.) + UI.Chart #byRegion: { + ChartType: #Column, + Dimensions: [region], + DynamicMeasures: ['@Analytics.AggregatedProperty#newSignups'] + }, + UI.PresentationVariant #byRegion: { + Visualizations: ['@UI.Chart#byRegion', '@UI.LineItem'], + SortOrder: [{ Property: newSignups, Descending: true }] + }, + UI.SelectionPresentationVariant #byRegion: { + Text : 'By Region', + SelectionVariant : { SelectOptions: [] }, + PresentationVariant : ![@UI.PresentationVariant#byRegion] }, UI.SelectionFields: [ eventName, region, role ], UI.LineItem: [ @@ -2037,13 +2076,13 @@ annotate AdminService.DevtoberfestSignupAnalytics with @( ] ) { ID @UI.Hidden; + weekMonday @title: 'Week Starting' @Analytics.Dimension; weekIndex @title: 'Week #' @Analytics.Dimension; eventName @title: 'Edition' @Analytics.Dimension; eventType @title: 'Event Type' @Analytics.Dimension; region @title: 'Region' @Analytics.Dimension; role @title: 'Role' @Analytics.Dimension; signups @title: 'Signups' @Analytics.Measure @Aggregation.default: #SUM; - weekMonday @title: 'Week Starting'; weekLabel @title: 'Week'; cumulativeSignups @title: 'Cumulative'; }; diff --git a/db/devtoberfest-analytics.cds b/db/devtoberfest-analytics.cds index 7cdebede3..d92966707 100644 --- a/db/devtoberfest-analytics.cds +++ b/db/devtoberfest-analytics.cds @@ -4,28 +4,37 @@ using { com.sap.developers.ims as ims } from './schema'; // --- Devtoberfest signups analytics ------------------------------------- // -// Per-signup fact view feeding the admin "Devtoberfest Signups" Analytical -// List Page (spec 2026-08-13). One row per EventRegistrations row (i.e. one -// row per (user, event) signup), flattened into groupable scalar dimensions -// so Fiori Elements can drive native OData V4 $apply aggregation over it -// (group by week / edition / region / role, aggregate $count) without any -// association navigation. +// Two-layer view feeding the admin "Devtoberfest Signups" Analytical List Page +// (spec 2026-08-13; readable-week axis added for issue #2047). // -// Scope: eventType = 'DEVTOBERFEST' only — captures signups across ALL -// Devtoberfest editions. The ALP filter bar defaults the edition to the -// active event (resolved server-side in the read handler). +// 1. DevtoberfestSignupFacts — the per-signup fact view (one row per +// EventRegistrations row) with the association joins, flattened into +// groupable scalar dimensions so Fiori Elements can drive native OData V4 +// $apply aggregation (group by week / edition / region / role, aggregate +// $count) without any association navigation. Scope: eventType = +// 'DEVTOBERFEST' only — signups across ALL Devtoberfest editions. +// +// 2. DevtoberfestSignupAnalytics — the public analytical view the service +// projects on. It is a plain, JOIN-free projection of the facts so the +// per-dialect models (db/sqlite/native.cds, db/hana/native.cds) can +// `extend projection` it with a real, GROUPABLE `weekMonday : Date` — the +// CDS compiler refuses to extend a view that contains a JOIN, hence the +// split. A real weekMonday is needed because the analytical chart's X-axis +// must group on a real column; a read-time virtual cannot sit on a $apply +// axis, which is why the raw integer weekIndex used to leak through (#2047). // // weekIndex: portable ISO-aligned week bucket. 2018-01-01 is a Monday, so // floor(days_between(anchorMonday, joinedDate) / 7) // numbers each Mon–Sun week from that anchor. Uses only the portable -// days_between / floor functions (CAP "Standard Functions" — translate to -// both HANA and SQLite), so it groups identically in prod (HANA) and unit -// tests (in-memory SQLite). The Monday date + 'YYYY-Www' label for each -// bucket is derived from weekIndex in the read handler (srv/admin-service.js) -// — see weekIndexToMonday(). region/role come from the optional +// days_between / floor functions (CAP "Standard Functions" — translate to both +// HANA and SQLite), so it groups identically in prod (HANA) and unit tests +// (in-memory SQLite). weekMonday (the calendar Monday date) is derived per +// dialect from weekIndex's inputs; the 'YYYY-Www' weekLabel is derived in the +// read handler (srv/lib/devtoberfest-signup-enrich.js) because no portable +// ISO-week function exists. region/role come from the optional // UserLearningPreferences (left join): null for most users, surfaced as a // "Not set" bucket in the UI, by design. -view DevtoberfestSignupAnalytics as +view DevtoberfestSignupFacts as select from ims.EventRegistrations as reg inner join ims.Events as evt on evt.ID = reg.event.ID left join ims.UserLearningPreferences as pref on pref.user.ID = reg.user.ID @@ -43,3 +52,6 @@ view DevtoberfestSignupAnalytics as 1 as signups : Integer } where evt.eventType = 'DEVTOBERFEST'; + +// JOIN-free public projection (see header) — extended per dialect with weekMonday. +view DevtoberfestSignupAnalytics as select from DevtoberfestSignupFacts { * }; diff --git a/db/hana/native.cds b/db/hana/native.cds new file mode 100644 index 000000000..79c2d5534 --- /dev/null +++ b/db/hana/native.cds @@ -0,0 +1,16 @@ +// HANA-specific extension for DevtoberfestSignupAnalytics (issue #2047). +// +// HANA twin of db/sqlite/native.cds — supplies the real, GROUPABLE +// `weekMonday : Date` the analytical chart uses as its readable time axis. See +// that file's header for why weekMonday is dialect-specific and why it recomputes +// the Mon-anchored week start from `joinedDate`. +// +// HANA has the native ADD_DAYS(date, n) function; floor(days_between(anchor,d)/7)*7 +// is the whole-week day offset from the 2018-01-01 Monday anchor. Wired per profile +// via cds.requires.db.[hybrid|production].model. Extends the JOIN-free projection +// (the compiler refuses to `extend` a view containing a JOIN). +using { com.sap.developers.ims.DevtoberfestSignupAnalytics } from '../devtoberfest-analytics'; + +extend projection DevtoberfestSignupAnalytics with { + ADD_DAYS(date'2018-01-01', cast(floor(days_between(date'2018-01-01', joinedDate) / 7) * 7 as Integer)) as weekMonday : Date +} diff --git a/db/sqlite/native.cds b/db/sqlite/native.cds new file mode 100644 index 000000000..0c28c94cd --- /dev/null +++ b/db/sqlite/native.cds @@ -0,0 +1,24 @@ +// SQLite-specific extension for DevtoberfestSignupAnalytics (issue #2047). +// +// The base view (db/devtoberfest-analytics.cds) exposes only the portable +// integer `weekIndex` because no single date-arithmetic expression is portable +// across SQLite and HANA (only days_between / floor / year / month / day map to +// both). To give the analytical CHART a human-readable, GROUPABLE time axis we +// need a real `weekMonday : Date` column — a read-time virtual cannot sit on a +// $apply chart axis. So each dialect supplies weekMonday with its own native +// date-add expression (see db/hana/native.cds for the HANA twin), wired per +// profile via cds.requires.db.[development].model — the CAP-documented pattern +// for "different native expression per DB". +// +// weekMonday recomputes the Mon-anchored week start from `joinedDate`. 2018-01-01 +// is a Monday; floor(days_between(anchor,d)/7)*7 is the whole-week day offset, +// and strftime adds it as days. `strftime` (not `date`) is the outer function +// because CAP treats lowercase date(x,…) as the 1-arg agnostic function and +// silently drops the modifier — strftime is passed through verbatim, preserving +// the '+N days' modifier. Extends the JOIN-free projection (the compiler refuses +// to `extend` a view containing a JOIN). +using { com.sap.developers.ims.DevtoberfestSignupAnalytics } from '../devtoberfest-analytics'; + +extend projection DevtoberfestSignupAnalytics with { + strftime('%Y-%m-%d', '2018-01-01', cast(floor(days_between(date'2018-01-01', joinedDate) / 7) * 7 as Integer) || ' days') as weekMonday : Date +} diff --git a/package.json b/package.json index c623578dd..ee83bf99d 100644 --- a/package.json +++ b/package.json @@ -277,11 +277,22 @@ } }, "db": { + "[development]": { + "model": [ + "db/sqlite" + ] + }, "[hybrid]": { - "kind": "hana" + "kind": "hana", + "model": [ + "db/hana" + ] }, "[production]": { - "kind": "hana" + "kind": "hana", + "model": [ + "db/hana" + ] } }, "audit-log": { diff --git a/srv/admin-service.cds b/srv/admin-service.cds index 54d27ff4a..f38f1f8a5 100644 --- a/srv/admin-service.cds +++ b/srv/admin-service.cds @@ -580,18 +580,19 @@ service AdminService { // "Devtoberfest Signups" Analytical List Page (spec 2026-08-13). Read-only, // aggregation-enabled (see @Aggregation.ApplySupported in app/admin-annotations.cds). // The virtual elements are populated by the read handler in srv/admin-service.js: - // weekMonday/weekLabel — human-readable derivation of the portable integer - // weekIndex (the DB has no portable ISO-week function; weekIndex buckets by - // Mon–Sun from a 2018-01-01 Monday anchor, and Node derives the calendar - // Monday + 'YYYY-Www' label from it). + // weekMonday — real, GROUPABLE Mon-anchored week-start Date supplied per + // dialect (db/sqlite/native.cds via strftime, db/hana/native.cds via + // ADD_DAYS; issue #2047) so the analytical chart has a human-readable time + // axis instead of the raw integer weekIndex. + // weekLabel — 'YYYY-Www' ISO label; the DB has no portable ISO-week function, + // so Node derives it from weekMonday (see srv/lib/devtoberfest-signup-enrich.js). // cumulativeSignups — running total, populated ONLY on the pure by-week series - // (grouped by weekIndex alone); left null when the result is sliced by another + // (one row per week); left null when the result is sliced by another // dimension, where a running total would be meaningless. @readonly @cds.redirection.target: false entity DevtoberfestSignupAnalytics as projection on ims.DevtoberfestSignupAnalytics { *, - virtual null as weekMonday : Date, virtual null as weekLabel : String(10), virtual null as cumulativeSignups : Integer }; diff --git a/srv/lib/devtoberfest-signup-enrich.js b/srv/lib/devtoberfest-signup-enrich.js index f9a0a1eb3..e6f2d23d6 100644 --- a/srv/lib/devtoberfest-signup-enrich.js +++ b/srv/lib/devtoberfest-signup-enrich.js @@ -1,14 +1,21 @@ 'use strict'; -// Enrichment for AdminService.DevtoberfestSignupAnalytics (spec 2026-08-13). +// Enrichment for AdminService.DevtoberfestSignupAnalytics (spec 2026-08-13; +// readable-week axis reworked for issue #2047). // -// The underlying view exposes a portable integer `weekIndex` (Mon–Sun bucket -// counted from the 2018-01-01 Monday anchor) because no CAP-portable ISO-week -// function exists (only days_between / year / month / day translate to both -// HANA and SQLite). This module derives the human-readable calendar Monday and -// ISO 'YYYY-Www' label from weekIndex, and computes a running cumulative total -// — the two things OData $apply cannot produce natively (aggregation gives -// per-group counts, never a window/running sum). +// The view now exposes a REAL, groupable `weekMonday : Date` (the calendar Monday +// of each Mon–Sun bucket), supplied per dialect (db/sqlite/native.cds via +// strftime, db/hana/native.cds via ADD_DAYS) so the analytical chart can group on +// a human-readable time axis instead of the raw integer `weekIndex`. This module +// derives the two things OData $apply still cannot produce natively: +// - weekLabel: the ISO 'YYYY-Www' string (no portable ISO-week SQL function), and +// - cumulativeSignups: a running total (a window/running sum, not a per-group +// aggregate). +// +// It keys off `weekMonday` when the row carries it (the chart / by-week table), +// and falls back to deriving the Monday from the portable integer `weekIndex` +// when a read groups by weekIndex alone — stamping `weekMonday` onto the row so +// both grouping shapes yield the same enriched fields. // // Cumulative is populated ONLY when the read is the pure by-week series (one row // per week). When the result is sliced by another dimension (region/role/edition) @@ -40,9 +47,28 @@ export function isoWeekLabel(monday) { return `${isoYear}-W${String(week).padStart(2, '0')}`; } +/** + * Resolve a row's week Monday as a UTC-midnight Date, from `weekMonday` (real + * DB column — string 'YYYY-MM-DD' or Date) or, failing that, the portable + * integer `weekIndex`. Returns null when neither is present (e.g. grand-total + * or a slice grouped by region only). Pure. + */ +function rowMonday(row) { + const wm = row.weekMonday; + if (wm != null) { + if (wm instanceof Date) return new Date(Date.UTC(wm.getUTCFullYear(), wm.getUTCMonth(), wm.getUTCDate())); + const iso = String(wm).slice(0, 10); // 'YYYY-MM-DD' + const t = Date.parse(`${iso}T00:00:00Z`); + if (!Number.isNaN(t)) return new Date(t); + } + if (typeof row.weekIndex === 'number') return weekIndexToMonday(row.weekIndex); + return null; +} + /** * Enrich signup analytics rows in place. - * - weekMonday / weekLabel: set on every row carrying a numeric weekIndex. + * - weekMonday / weekLabel: set on every row that resolves to a week Monday + * (weekMonday is stamped from weekIndex when a read grouped by weekIndex alone). * - cumulativeSignups: running total of `newSignups` over ascending week order, * populated only when the result is one row per week (see module doc). * Returns the same array reference (CAP after-READ convention). @@ -50,21 +76,25 @@ export function isoWeekLabel(monday) { export function enrichSignupRows(rows) { if (!Array.isArray(rows)) return rows; - const weekRows = rows.filter((r) => r && typeof r.weekIndex === 'number'); - for (const row of weekRows) { - row.weekMonday = weekIndexToMondayISO(row.weekIndex); - row.weekLabel = isoWeekLabel(weekIndexToMonday(row.weekIndex)); + const weekRows = []; + for (const row of rows) { + if (!row) continue; + const monday = rowMonday(row); + if (!monday) continue; + row.weekMonday = monday.toISOString().slice(0, 10); + row.weekLabel = isoWeekLabel(monday); + weekRows.push({ row, ms: monday.getTime() }); } // Pure by-week series ⇔ each week appears exactly once and a measure is present. - const distinctWeeks = new Set(weekRows.map((r) => r.weekIndex)); - const hasMeasure = weekRows.length > 0 && weekRows.every((r) => typeof r.newSignups === 'number'); + const distinctWeeks = new Set(weekRows.map((w) => w.ms)); + const hasMeasure = weekRows.length > 0 && weekRows.every((w) => typeof w.row.newSignups === 'number'); const isByWeekSeries = hasMeasure && distinctWeeks.size === weekRows.length; if (isByWeekSeries) { - const ordered = [...weekRows].sort((a, b) => a.weekIndex - b.weekIndex); + const ordered = [...weekRows].sort((a, b) => a.ms - b.ms); let running = 0; - for (const row of ordered) { + for (const { row } of ordered) { running += row.newSignups; row.cumulativeSignups = running; } diff --git a/test/hybrid/devtoberfest-signup-analytics-hana.test.js b/test/hybrid/devtoberfest-signup-analytics-hana.test.js index b4fabf18c..5bfa4bc1e 100644 --- a/test/hybrid/devtoberfest-signup-analytics-hana.test.js +++ b/test/hybrid/devtoberfest-signup-analytics-hana.test.js @@ -70,4 +70,20 @@ describe('DevtoberfestSignupAnalytics — real HANA', () => { expect(rows[0].weekMonday).toBe('2026-09-07'); expect(rows[0].weekLabel).toBe('2026-W37'); }); + + it('groups on the real weekMonday Date (ADD_DAYS) with readable dates on HANA (#2047)', async () => { + const srv = await cds.connect.to('AdminService'); + const rows = await srv.tx({ user: ADMIN }, (tx) => tx.run( + SELECT.from('DevtoberfestSignupAnalytics') + .columns('weekMonday', { func: 'sum', args: [{ ref: ['signups'] }], as: 'newSignups' }) + .where({ event_ID: testEventId }) + .groupBy('weekMonday') + .orderBy('weekMonday') + )); + expect(rows.length).toBe(2); + expect(rows.map((r) => String(r.weekMonday).slice(0, 10))).toEqual(['2026-09-07', '2026-09-14']); + expect(rows.map((r) => Number(r.newSignups))).toEqual([3, 1]); + expect(rows.map((r) => r.weekLabel)).toEqual(['2026-W37', '2026-W38']); + expect(rows.map((r) => r.cumulativeSignups)).toEqual([3, 4]); + }); }); diff --git a/test/unit/devtoberfest-signup-analytics.test.js b/test/unit/devtoberfest-signup-analytics.test.js index c0592e5ce..0de7d3130 100644 --- a/test/unit/devtoberfest-signup-analytics.test.js +++ b/test/unit/devtoberfest-signup-analytics.test.js @@ -95,6 +95,30 @@ describe('DevtoberfestSignupAnalytics', () => { expect(rows[0].weekLabel).toBe('2026-W37'); }); + it('exposes a real, groupable weekMonday Date on every fact row (issue #2047)', async () => { + const rows = await SELECT.from(DevtoberfestSignupAnalytics).columns('weekMonday', 'joinedDate').orderBy('joinedDate'); + // Every signup maps to its Mon–Sun week's Monday date. + expect(rows[0].weekMonday).toBe('2026-09-07'); // 2026-09-07 signup + expect(rows[2].weekMonday).toBe('2026-09-07'); // 2026-09-13 (Sun) still in that week + expect(rows[3].weekMonday).toBe('2026-09-14'); + expect(rows[4].weekMonday).toBe('2026-09-21'); + }); + + it('chart path: aggregated read grouped by the real weekMonday returns readable dates + enrichment', async () => { + const srv = await cds.connect.to('AdminService'); + const rows = await srv.tx({ user: ADMIN }, (tx) => tx.run( + SELECT.from('DevtoberfestSignupAnalytics') + .columns('weekMonday', { func: 'sum', args: [{ ref: ['signups'] }], as: 'newSignups' }) + .groupBy('weekMonday') + .orderBy('weekMonday') + )); + expect(rows.map((r) => r.weekMonday)).toEqual(['2026-09-07', '2026-09-14', '2026-09-21']); + expect(rows.map((r) => r.newSignups)).toEqual([3, 1, 2]); + // enrichment keys off weekMonday even though weekIndex was not grouped + expect(rows.map((r) => r.weekLabel)).toEqual(['2026-W37', '2026-W38', '2026-W39']); + expect(rows.map((r) => r.cumulativeSignups)).toEqual([3, 4, 6]); + }); + it('grand total aggregates all Devtoberfest signups', async () => { const srv = await cds.connect.to('AdminService'); const rows = await srv.tx({ user: ADMIN }, (tx) => tx.run( diff --git a/test/unit/devtoberfest-signup-enrich.test.js b/test/unit/devtoberfest-signup-enrich.test.js index 744c9b260..7790b4348 100644 --- a/test/unit/devtoberfest-signup-enrich.test.js +++ b/test/unit/devtoberfest-signup-enrich.test.js @@ -51,6 +51,27 @@ describe('enrichSignupRows', () => { expect(byWeek[455].cumulativeSignups).toBe(6); }); + it('keys off a real weekMonday when weekIndex is absent (chart grouped by weekMonday)', () => { + // deliberately unordered; no weekIndex (chart groups on the real weekMonday Date) + const rows = [ + { weekMonday: '2026-09-21', newSignups: 2 }, + { weekMonday: '2026-09-07', newSignups: 3 }, + { weekMonday: '2026-09-14', newSignups: 1 }, + ]; + enrichSignupRows(rows); + const byMon = Object.fromEntries(rows.map((r) => [r.weekMonday, r])); + expect(byMon['2026-09-07']).toMatchObject({ weekLabel: '2026-W37', cumulativeSignups: 3 }); + expect(byMon['2026-09-14'].cumulativeSignups).toBe(4); + expect(byMon['2026-09-21'].cumulativeSignups).toBe(6); + }); + + it('accepts a Date instance for weekMonday (HANA may return a Date, not a string)', () => { + const rows = [{ weekMonday: new Date(Date.UTC(2026, 8, 7)), newSignups: 5 }]; + enrichSignupRows(rows); + expect(rows[0].weekMonday).toBe('2026-09-07'); + expect(rows[0].weekLabel).toBe('2026-W37'); + }); + it('does NOT compute cumulative when a second dimension makes weeks repeat', () => { const rows = [ { weekIndex: 453, region: 'EMEA', newSignups: 2 }, diff --git a/test/unit/rebuild-trigger.test.js b/test/unit/rebuild-trigger.test.js index b85c7735b..76372e888 100644 --- a/test/unit/rebuild-trigger.test.js +++ b/test/unit/rebuild-trigger.test.js @@ -12,6 +12,17 @@ vi.mock('../../srv/lib/credstore.js', () => ({ readSecret: vi.fn().mockResolvedValue(null), // default: credstore has no value })); +// Isolate the dispatch path from real DB work. scheduleRebuild's debounced +// callback calls resolveTenantSettings(), which otherwise triggers a cold +// cds.connect.to('db') + full dev-model compile the first time it runs in a +// worker with no booted server. That compile time makes the fixed post-debounce +// waits below racy (surfaced by #2047, which enlarged the dev model with a +// per-dialect weekMonday). The mode-merge logic under test doesn't depend on +// tenant settings, so stub it to a constant. +vi.mock('../../srv/lib/runtime-config/tenant-settings.js', () => ({ + resolveTenantSettings: vi.fn().mockResolvedValue({ rebuildTargetEnv: 'dev' }), +})); + // Flag-on path: rebuild-trigger should dispatch using the App installation token. vi.mock('../../srv/lib/github-app-token.js', async (importOriginal) => { const actual = await importOriginal();