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
2 changes: 1 addition & 1 deletion .cdsrc.json
Original file line number Diff line number Diff line change
Expand Up @@ -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" },
Expand Down
47 changes: 43 additions & 4 deletions app/admin-annotations.cds
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand All @@ -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: [
Expand All @@ -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';
};
Expand Down
42 changes: 27 additions & 15 deletions db/devtoberfest-analytics.cds
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 { * };
16 changes: 16 additions & 0 deletions db/hana/native.cds
Original file line number Diff line number Diff line change
@@ -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
}
24 changes: 24 additions & 0 deletions db/sqlite/native.cds
Original file line number Diff line number Diff line change
@@ -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
}
15 changes: 13 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
13 changes: 7 additions & 6 deletions srv/admin-service.cds
Original file line number Diff line number Diff line change
Expand Up @@ -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
};
Expand Down
64 changes: 47 additions & 17 deletions srv/lib/devtoberfest-signup-enrich.js
Original file line number Diff line number Diff line change
@@ -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)
Expand Down Expand Up @@ -40,31 +47,54 @@ 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).
*/
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;
}
Expand Down
16 changes: 16 additions & 0 deletions test/hybrid/devtoberfest-signup-analytics-hana.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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]);
});
});
Loading
Loading