From 606f5c9383dfff94acc04de7f9495d27e8019ec5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=A2u=20Cao?= Date: Sun, 26 Jul 2026 14:42:59 +0200 Subject: [PATCH 1/9] Update package lockfile --- package-lock.json | 106 +++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 105 insertions(+), 1 deletion(-) diff --git a/package-lock.json b/package-lock.json index 9258dda..96d88f3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -61,7 +61,7 @@ "web3-utils": "^1.7.3" }, "engines": { - "node": ">= 14" + "node": ">= 22" } }, "node_modules/@ampproject/remapping": { @@ -5291,6 +5291,17 @@ "url": "https://bevry.me/fund" } }, + "node_modules/bindings": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz", + "integrity": "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "file-uri-to-path": "1.0.0" + } + }, "node_modules/bl": { "version": "4.1.0", "dev": true, @@ -15167,6 +15178,14 @@ "node": "^10.12.0 || >=12.0.0" } }, + "node_modules/file-uri-to-path": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz", + "integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==", + "dev": true, + "license": "MIT", + "optional": true + }, "node_modules/filesize": { "version": "6.4.0", "dev": true, @@ -15738,6 +15757,21 @@ "dev": true, "license": "ISC" }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, "node_modules/function-bind": { "version": "1.1.1", "dev": true, @@ -18796,6 +18830,14 @@ "dev": true, "license": "ISC" }, + "node_modules/nan": { + "version": "2.28.0", + "resolved": "https://registry.npmjs.org/nan/-/nan-2.28.0.tgz", + "integrity": "sha512-fTsDz99OTq2sVePhGdp4qQhggZFtKr64ZNVyVajRKtMOkJxYekplBh577PiJB12v/D3s2E5cGtOI45LWp6rnLQ==", + "dev": true, + "license": "MIT", + "optional": true + }, "node_modules/nanoid": { "version": "3.3.4", "dev": true, @@ -23656,6 +23698,26 @@ "node": ">=0.10.0" } }, + "node_modules/watchpack-chokidar2/node_modules/fsevents": { + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-1.2.13.tgz", + "integrity": "sha512-oWb1Z6mkHIskLzEJ/XWX0srkpkTQ7vaopMQkyaEIoq0fmtFVxOthb8cCxeT+p3ynTdkk/RZwbgG4brR5BeWECw==", + "deprecated": "Upgrade to fsevents v2 to mitigate potential security issues", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "dependencies": { + "bindings": "^1.5.0", + "nan": "^2.12.1" + }, + "engines": { + "node": ">= 4.0" + } + }, "node_modules/watchpack-chokidar2/node_modules/glob-parent": { "version": "3.1.0", "dev": true, @@ -27829,6 +27891,16 @@ "version": "2.3.0", "dev": true }, + "bindings": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz", + "integrity": "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==", + "dev": true, + "optional": true, + "requires": { + "file-uri-to-path": "1.0.0" + } + }, "bl": { "version": "4.1.0", "dev": true, @@ -35048,6 +35120,13 @@ "flat-cache": "^3.0.4" } }, + "file-uri-to-path": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz", + "integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==", + "dev": true, + "optional": true + }, "filesize": { "version": "6.4.0", "dev": true @@ -35471,6 +35550,13 @@ "version": "1.0.0", "dev": true }, + "fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "optional": true + }, "function-bind": { "version": "1.1.1", "dev": true @@ -37591,6 +37677,13 @@ "version": "0.0.8", "dev": true }, + "nan": { + "version": "2.28.0", + "resolved": "https://registry.npmjs.org/nan/-/nan-2.28.0.tgz", + "integrity": "sha512-fTsDz99OTq2sVePhGdp4qQhggZFtKr64ZNVyVajRKtMOkJxYekplBh577PiJB12v/D3s2E5cGtOI45LWp6rnLQ==", + "dev": true, + "optional": true + }, "nanoid": { "version": "3.3.4", "dev": true @@ -40981,6 +41074,17 @@ "to-regex-range": "^2.1.0" } }, + "fsevents": { + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-1.2.13.tgz", + "integrity": "sha512-oWb1Z6mkHIskLzEJ/XWX0srkpkTQ7vaopMQkyaEIoq0fmtFVxOthb8cCxeT+p3ynTdkk/RZwbgG4brR5BeWECw==", + "dev": true, + "optional": true, + "requires": { + "bindings": "^1.5.0", + "nan": "^2.12.1" + } + }, "glob-parent": { "version": "3.1.0", "dev": true, -- 2.50.1 From a7e017971afff4623c295115b6b2c277a41dc75a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=A2u=20Cao?= Date: Sun, 26 Jul 2026 14:08:02 +0200 Subject: [PATCH 2/9] WIP Group contributions by URL --- app/models/contribution.js | 9 +++ app/services/kredits.js | 68 ++++++++++++++++++ app/utils/contribution-grouping-key.js | 17 +++++ tests/fixtures/contributions.js | 8 +-- tests/unit/services/kredits-test.js | 72 +++++++++++++++++++ .../utils/contribution-grouping-key-test.js | 48 +++++++++++++ 6 files changed, 218 insertions(+), 4 deletions(-) create mode 100644 app/utils/contribution-grouping-key.js create mode 100644 tests/unit/utils/contribution-grouping-key-test.js diff --git a/app/models/contribution.js b/app/models/contribution.js index f57bbed..21f862a 100644 --- a/app/models/contribution.js +++ b/app/models/contribution.js @@ -2,6 +2,8 @@ import EmberObject, { computed } from '@ember/object'; import { isEmpty, isPresent } from '@ember/utils'; import moment from 'moment'; +import contributionGroupingKey from 'kredits-web/utils/contribution-grouping-key'; + export default EmberObject.extend({ // Contract id: null, @@ -44,6 +46,13 @@ export default EmberObject.extend({ return isPresent(this.pendingTx); }), + // Stable key shared by all per-contributor contributions created for the + // same issue/pull request (derived from `url`). Manual contributions without + // a URL return null and are treated as singletons (not grouped). + groupId: computed('url', function() { + return contributionGroupingKey(this); + }), + serialize () { return JSON.stringify(this); } diff --git a/app/services/kredits.js b/app/services/kredits.js index 641af86..eaec074 100644 --- a/app/services/kredits.js +++ b/app/services/kredits.js @@ -227,6 +227,74 @@ export default Service.extend({ return kreditsByContributor; }), + // Groups contributions that were created together for the same + // issue/pull request (one contribution per contributor). The grouping key + // is the contribution's `url` (see utils/contribution-grouping-key). + // Manual contributions without a URL each form their own single-item group + // (they are never collapsed together, since `null` is not a real key). + // + // Each group is an EmberObject with: groupId, items, contributors, + // contributorIds, totalAmount, kind, description, url, date, isGrouped + // (true when more than one contribution shares the key). + contributionsGrouped: computed('contributions.[]', function() { + const groupsMap = new Map(); + const singletons = []; + + this.contributions.forEach(c => { + const groupId = c.groupId; + if (isEmpty(groupId)) { + singletons.push(c); + } else if (groupsMap.has(groupId)) { + groupsMap.get(groupId).items.push(c); + } else { + groupsMap.set(groupId, { groupId, items: [c] }); + } + }); + + const groups = [ + ...groupsMap.values(), + ...singletons.map(c => ({ groupId: null, items: [c] })) + ]; + + return groups.map(group => { + const items = group.items; + const first = items.firstObject; + const contributorIds = items.mapBy('contributorId').uniq(); + const contributors = contributorIds + .map(id => this.contributors.findBy('id', id)) + .filter(Boolean); + const totalAmount = items + .mapBy('amount') + .reduce((a, b) => a + (b || 0), 0); + + return EmberObject.create({ + groupId: group.groupId, + items, + contributors, + contributorIds, + totalAmount, + kind: first ? first.kind : null, + description: first ? first.description : null, + url: first ? first.url : null, + date: first ? first.date : null, + isGrouped: items.length > 1 + }); + }); + }), + + // Returns the other contributions that share a grouping key with the given + // contribution (i.e. the co-contributors' contributions). Returns an empty + // array when the contribution has no group (manual, no URL) or has no + // siblings. + siblingContributions (contribution) { + if (!contribution) return []; + const groupId = contribution.groupId; + if (isEmpty(groupId)) return []; + const group = this.contributionsGrouped.findBy('groupId', groupId); + if (!group) return []; + return group.items.filter(c => c !== contribution); + }, + contributionsUnconfirmed: computed('contributions.[]', 'currentBlock', function() { return this.contributions .filter(c => c.confirmedAt > this.currentBlock); diff --git a/app/utils/contribution-grouping-key.js b/app/utils/contribution-grouping-key.js new file mode 100644 index 0000000..596ffb9 --- /dev/null +++ b/app/utils/contribution-grouping-key.js @@ -0,0 +1,17 @@ +import { isEmpty } from '@ember/utils'; + +// +// Derives a stable grouping key for a contribution, so that contributions +// created for the same issue/pull request (one per contributor) can be +// linked together in the UI. +// +// The bot writes the exact same `url` string (the html_url of the issue/PR) +// into every per-contributor IPFS document for a given piece of work, so the +// top-level `url` field on the model is sufficient as a grouping key. Manual +// contributions without a URL return null and are treated as singletons. +// +export default function contributionGroupingKey (contribution) { + let url = contribution ? contribution.url : null; + if (isEmpty(url)) return null; + return url; +} diff --git a/tests/fixtures/contributions.js b/tests/fixtures/contributions.js index 5a809f0..3af9620 100644 --- a/tests/fixtures/contributions.js +++ b/tests/fixtures/contributions.js @@ -5,14 +5,14 @@ import processContributionData from 'kredits-web/utils/process-contribution-data const items = []; const data = [ - { id: 1, contributorId: 1, confirmedAt: 1000, claimed: false, vetoed: false, amount: 1500, kind: 'dev' }, + { id: 1, contributorId: 1, confirmedAt: 1000, claimed: false, vetoed: false, amount: 1500, kind: 'dev', url: 'https://github.com/67P/kredits-contracts/pull/196' }, { id: 2, contributorId: 1, confirmedAt: 1000, claimed: false, vetoed: false, amount: 5000, kind: 'ops' }, - { id: 3, contributorId: 2, confirmedAt: 1000, claimed: false, vetoed: false, amount: 1500, kind: 'ops' }, + { id: 3, contributorId: 2, confirmedAt: 1000, claimed: false, vetoed: false, amount: 1500, kind: 'ops', url: 'https://github.com/67P/kredits-contracts/pull/196' }, { id: 4, contributorId: 2, confirmedAt: 1000, claimed: false, vetoed: false, amount: 1500, kind: 'docs' }, { id: 5, contributorId: 1, confirmedAt: 1000, claimed: false, vetoed: false, amount: 5000, kind: 'design' }, { id: 6, contributorId: 1, confirmedAt: 1000, claimed: false, vetoed: true, amount: 500, kind: 'dev' }, - { id: 7, contributorId: 3, confirmedAt: 2000, claimed: false, vetoed: false, amount: 5000, kind: 'dev' }, - { id: 8, contributorId: 1, confirmedAt: 2000, claimed: false, vetoed: false, amount: 1500, kind: 'community' }, + { id: 7, contributorId: 3, confirmedAt: 2000, claimed: false, vetoed: false, amount: 5000, kind: 'dev', url: 'https://gitea.kosmos.org/kredits/kredits-web/issues/231' }, + { id: 8, contributorId: 1, confirmedAt: 2000, claimed: false, vetoed: false, amount: 1500, kind: 'community', url: 'https://gitea.kosmos.org/kredits/kredits-web/issues/231' }, { id: 9, contributorId: 3, confirmedAt: 2000, claimed: false, vetoed: true, amount: 1500, kind: 'docs' }, ]; diff --git a/tests/unit/services/kredits-test.js b/tests/unit/services/kredits-test.js index 16b5ca4..6087acd 100644 --- a/tests/unit/services/kredits-test.js +++ b/tests/unit/services/kredits-test.js @@ -60,6 +60,78 @@ module('Unit | Service | kredits', function(hooks) { assert.equal(service.contributorsSorted[1].name, 'Manuel', 'sorts by name'); }); + test('#contributionsGrouped groups contributions sharing a url', function(assert) { + let service = this.owner.lookup('service:kredits'); + service.set('contributors', contributors); + service.set('contributions', contributions); + + const grouped = service.contributionsGrouped; + + // 9 contributions, with ids 1+3 sharing a url and ids 7+8 sharing a url, + // so 7 groups total (2 grouped + 5 singletons). + assert.equal(grouped.length, 7, 'produces one group per unique key plus singletons'); + + const ghGroup = grouped.findBy('groupId', 'https://github.com/67P/kredits-contracts/pull/196'); + assert.ok(ghGroup, 'github url group exists'); + assert.ok(ghGroup.isGrouped, 'github group is flagged as grouped'); + assert.equal(ghGroup.items.length, 2, 'github group has two contributions'); + assert.deepEqual(ghGroup.contributorIds, [1, 2], 'github group lists both contributor ids'); + assert.equal(ghGroup.contributors.length, 2, 'github group resolves both contributors'); + assert.equal(ghGroup.totalAmount, 3000, 'github group sums amounts (1500 + 1500)'); + assert.equal(ghGroup.kind, 'dev', 'github group takes kind from first item'); + assert.equal(ghGroup.url, 'https://github.com/67P/kredits-contracts/pull/196'); + + const giteaGroup = grouped.findBy('groupId', 'https://gitea.kosmos.org/kredits/kredits-web/issues/231'); + assert.ok(giteaGroup, 'gitea url group exists'); + assert.ok(giteaGroup.isGrouped, 'gitea group is flagged as grouped'); + assert.deepEqual(giteaGroup.contributorIds, [3, 1], 'gitea group lists both contributor ids'); + assert.equal(giteaGroup.totalAmount, 6500, 'gitea group sums amounts (5000 + 1500)'); + }); + + test('#contributionsGrouped treats manual contributions without url as singletons', function(assert) { + let service = this.owner.lookup('service:kredits'); + service.set('contributors', contributors); + service.set('contributions', contributions); + + const grouped = service.contributionsGrouped; + const singletons = grouped.filterBy('isGrouped', false); + + // 5 contributions without a url: ids 2, 4, 5, 6, 9 + assert.equal(singletons.length, 5, 'one singleton group per url-less contribution'); + singletons.forEach(g => { + assert.equal(g.items.length, 1, 'singleton group has exactly one item'); + assert.notOk(g.groupId, 'singleton group has a null/empty groupId'); + }); + }); + + test('#siblingContributions returns co-contributor contributions for the same url', function(assert) { + let service = this.owner.lookup('service:kredits'); + service.set('contributors', contributors); + service.set('contributions', contributions); + + const c1 = contributions.findBy('id', 1); + const siblings = service.siblingContributions(c1); + assert.equal(siblings.length, 1, 'contribution 1 has one sibling'); + assert.equal(siblings[0].id, 3, 'the sibling is contribution 3'); + }); + + test('#siblingContributions returns empty for manual contributions', function(assert) { + let service = this.owner.lookup('service:kredits'); + service.set('contributors', contributors); + service.set('contributions', contributions); + + const c2 = contributions.findBy('id', 2); + assert.deepEqual(service.siblingContributions(c2), [], 'no siblings for url-less contribution'); + }); + + test('#siblingContributions returns empty for null contribution', function(assert) { + let service = this.owner.lookup('service:kredits'); + service.set('contributors', contributors); + service.set('contributions', contributions); + + assert.deepEqual(service.siblingContributions(null), [], 'no siblings for null'); + }); + test('#kreditsByContributor ignores unconfirmed contributions for contributors that are not loaded', function(assert) { let service = this.owner.lookup('service:kredits'); service.set('contributors', contributors); diff --git a/tests/unit/utils/contribution-grouping-key-test.js b/tests/unit/utils/contribution-grouping-key-test.js new file mode 100644 index 0000000..33e720e --- /dev/null +++ b/tests/unit/utils/contribution-grouping-key-test.js @@ -0,0 +1,48 @@ +import { module, test } from 'qunit'; +import EmberObject from '@ember/object'; +import contributionGroupingKey from 'kredits-web/utils/contribution-grouping-key'; + +function makeContribution (url) { + return EmberObject.create({ url }); +} + +module('Unit | Utils | contribution-grouping-key', function() { + + test('returns the url when present', function(assert) { + const c = makeContribution('https://github.com/67P/kredits-contracts/pull/196'); + assert.equal(contributionGroupingKey(c), 'https://github.com/67P/kredits-contracts/pull/196'); + }); + + test('returns the same key for identical github urls', function(assert) { + const a = makeContribution('https://github.com/67P/kredits-contracts/pull/196'); + const b = makeContribution('https://github.com/67P/kredits-contracts/pull/196'); + assert.equal(contributionGroupingKey(a), contributionGroupingKey(b)); + }); + + test('returns the same key for identical gitea urls', function(assert) { + const a = makeContribution('https://gitea.kosmos.org/kredits/kredits-web/issues/231'); + const b = makeContribution('https://gitea.kosmos.org/kredits/kredits-web/issues/231'); + assert.equal(contributionGroupingKey(a), contributionGroupingKey(b)); + }); + + test('returns different keys for different urls', function(assert) { + const a = makeContribution('https://github.com/67P/kredits-contracts/pull/196'); + const b = makeContribution('https://github.com/67P/kredits-contracts/pull/197'); + assert.notEqual(contributionGroupingKey(a), contributionGroupingKey(b)); + }); + + test('returns null when url is empty', function(assert) { + const c = makeContribution(''); + assert.equal(contributionGroupingKey(c), null); + }); + + test('returns null when url is null/undefined', function(assert) { + assert.equal(contributionGroupingKey(makeContribution(null)), null); + assert.equal(contributionGroupingKey(makeContribution(undefined)), null); + }); + + test('returns null when contribution is null/undefined', function(assert) { + assert.equal(contributionGroupingKey(null), null); + assert.equal(contributionGroupingKey(undefined), null); + }); +}); -- 2.50.1 From 3cab203498577c8e27cafdeda9e2cd7bf8b661df Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=A2u=20Cao?= Date: Sun, 26 Jul 2026 16:04:56 +0200 Subject: [PATCH 3/9] WIP Group contributions in UI --- app/components/contribution-list/component.js | 112 +++++++++++++++--- app/components/contribution-list/template.hbs | 93 +++++++++++---- .../dashboard/contributions/show.js | 7 ++ .../components/_contribution-details.scss | 49 ++++++++ app/styles/components/_contribution-list.scss | 56 +++++++++ .../dashboard/contributions/show.hbs | 16 +++ app/utils/contribution-grouping-key.js | 9 +- .../contribution-list/component-test.js | 85 ++++++++++++- .../dashboard/contributions/show-test.js | 34 ++++++ 9 files changed, 416 insertions(+), 45 deletions(-) create mode 100644 tests/unit/controllers/dashboard/contributions/show-test.js diff --git a/app/components/contribution-list/component.js b/app/components/contribution-list/component.js index 444d813..85491af 100644 --- a/app/components/contribution-list/component.js +++ b/app/components/contribution-list/component.js @@ -1,7 +1,7 @@ import Component from '@ember/component'; -import { computed } from '@ember/object'; +import EmberObject, { computed, observer } from '@ember/object'; import { sort } from '@ember/object/computed'; -import { isPresent } from '@ember/utils'; +import { isEmpty, isPresent } from '@ember/utils'; import { inject as service } from '@ember/service'; export default Component.extend({ @@ -12,6 +12,7 @@ export default Component.extend({ classNames: ['contributions'], selectedContribution: null, + expandedGroup: null, showQuickFilter: false, hideSmallContributions: false, @@ -33,23 +34,96 @@ export default Component.extend({ return this.contributions.mapBy('kind').uniq(); }), - contributionsFiltered: computed('contributions.[]', 'hideSmallContributions', 'contributorId', 'contributionKind', function() { - return this.contributions.filter(c => { - let included = true; + // Groups the contributions by their `groupId` (derived from `url`; see + // utils/contribution-grouping-key), then filters the groups: a group is + // included when any of its items matches the active filters, and when + // included all of its items render (grouped rows stay whole — filtering by + // a contributor does not collapse a group into a singleton). Preserves the + // input order so the controller's date-sort is retained. Manual + // contributions without a URL each form their own single-item group. + // + // Each group object exposes: groupId, items, contributors, contributorIds, + // amount (single contribution's, not a sum), kind, description, url, date, + // isGrouped, visibleContributors (capped at 3), extraContributorCount. + groupedContributions: computed('contributions.[]', 'hideSmallContributions', 'contributorId', 'contributionKind', function() { + const groupsMap = new Map(); + const orderedGroups = []; - if (this.hideSmallContributions && - c.amount <= 500) { included = false; } - - if (isPresent(this.contributorId) && - c.contributorId !== parseInt(this.contributorId)) { included = false; } - - if (isPresent(this.contributionKind) && - c.kind !== this.contributionKind) { included = false; } - - return included; + this.contributions.forEach(c => { + const groupId = c.groupId; + if (isEmpty(groupId)) { + orderedGroups.push({ groupId: null, items: [c] }); + } else if (groupsMap.has(groupId)) { + groupsMap.get(groupId).items.push(c); + } else { + const group = { groupId, items: [c] }; + groupsMap.set(groupId, group); + orderedGroups.push(group); + } }); + + return orderedGroups + .filter(raw => raw.items.any(c => this.matchesFilters(c))) + .map(raw => { + const items = raw.items; + const first = items.firstObject; + const contributorIds = items.mapBy('contributorId').uniq(); + const contributors = contributorIds + .map(id => this.contributors.findBy('id', id)) + .filter(Boolean); + + return EmberObject.create({ + groupId: raw.groupId, + items, + contributors, + contributorIds, + amount: first ? first.amount : null, + kind: first ? first.kind : null, + description: first ? first.description : null, + url: first ? first.url : null, + date: first ? first.date : null, + isGrouped: items.length > 1, + visibleContributors: contributors.slice(0, 3), + extraContributorCount: Math.max(0, contributors.length - 3) + }); + }); }), + // Shared filter predicates for a single contribution. Used to decide group + // inclusion in `groupedContributions`. A group is shown when any of its + // items passes; "hide small contributions" thus keeps a group as long as + // any item is above the threshold. + matchesFilters (c) { + if (this.hideSmallContributions && c.amount <= 500) return false; + if (isPresent(this.contributorId) && + c.contributorId !== parseInt(this.contributorId)) return false; + if (isPresent(this.contributionKind) && + c.kind !== this.contributionKind) return false; + return true; + }, + + // Auto-expands the group containing the selected contribution so the + // selected child row is visible with the `selected` highlight. + autoExpandSelected: observer('selectedContributionId', function() { + this._autoExpandSelected(); + }), + + _autoExpandSelected () { + const selectedId = this.selectedContributionId; + if (!selectedId) return; + const group = this.groupedContributions.find(g => + g.items.findBy('id', selectedId) + ); + if (group && group.isGrouped && this.expandedGroup !== group.groupId) { + this.set('expandedGroup', group.groupId); + } + }, + + init () { + this._super(...arguments); + this._autoExpandSelected(); + }, + actions: { veto (contributionId) { @@ -62,6 +136,14 @@ export default Component.extend({ openContributionDetails(contribution) { this.router.transitionTo('dashboard.contributions.show', contribution); + }, + + toggleGroup (group) { + if (this.expandedGroup === group.groupId) { + this.set('expandedGroup', null); + } else { + this.set('expandedGroup', group.groupId); + } } } diff --git a/app/components/contribution-list/template.hbs b/app/components/contribution-list/template.hbs index be603d0..e536656 100644 --- a/app/components/contribution-list/template.hbs +++ b/app/components/contribution-list/template.hbs @@ -30,27 +30,76 @@ {{/if}}
    - {{#each this.contributionsFiltered as |contribution|}} -
  • -

    - - ({{contribution.kind}}) - {{contribution.description}} -

    -

    - {{contribution.amount}}₭S -

    - {{#unless contribution.vetoed}} - {{#unless (is-confirmed-contribution contribution)}} -

    - {{input type="button" class="button small danger" value="veto" - click=(action "veto" contribution.id) - disabled=contribution.hasPendingChanges}} + {{#each this.groupedContributions as |group|}} + {{#if group.isGrouped}} +

  • +

    + + {{#each group.visibleContributors as |contributor|}} + + {{/each}} + {{#if group.extraContributorCount}} + +{{group.extraContributorCount}} + {{/if}} + + ({{group.kind}}) + {{group.description}} +

    +

    + {{group.amount}}₭S +

    +
  • + {{#if (eq this.expandedGroup group.groupId)}} + {{#each group.items key="id" as |contribution|}} +
  • +

    + + ({{contribution.kind}}) + {{contribution.description}} +

    +

    + {{contribution.amount}}₭S +

    + {{#unless contribution.vetoed}} + {{#unless (is-confirmed-contribution contribution)}} +

    + {{input type="button" class="button small danger" value="veto" + click=(action "veto" contribution.id) + disabled=contribution.hasPendingChanges}} +

    + {{/unless}} + {{/unless}} +
  • + {{/each}} + {{/if}} + {{else}} + {{#each group.items as |contribution|}} +
  • +

    + + ({{contribution.kind}}) + {{contribution.description}}

    - {{/unless}} - {{/unless}} -
  • +

    + {{contribution.amount}}₭S +

    + {{#unless contribution.vetoed}} + {{#unless (is-confirmed-contribution contribution)}} +

    + {{input type="button" class="button small danger" value="veto" + click=(action "veto" contribution.id) + disabled=contribution.hasPendingChanges}} +

    + {{/unless}} + {{/unless}} + + {{/each}} + {{/if}} {{/each}} -
\ No newline at end of file + diff --git a/app/controllers/dashboard/contributions/show.js b/app/controllers/dashboard/contributions/show.js index cd9a097..00e7d8f 100644 --- a/app/controllers/dashboard/contributions/show.js +++ b/app/controllers/dashboard/contributions/show.js @@ -1,12 +1,19 @@ import Controller from '@ember/controller'; import { computed } from '@ember/object'; +import { inject as service } from '@ember/service'; import config from 'kredits-web/config/environment'; export default Controller.extend({ + kredits: service(), + ipfsGatewayUrl: computed(function() { return config.ipfs.gatewayUrl; + }), + + siblingContributions: computed('model.groupId', 'kredits.contributions.[]', function() { + return this.kredits.siblingContributions(this.model); }) }); diff --git a/app/styles/components/_contribution-details.scss b/app/styles/components/_contribution-details.scss index a2071a0..bbd2d0f 100644 --- a/app/styles/components/_contribution-details.scss +++ b/app/styles/components/_contribution-details.scss @@ -49,6 +49,55 @@ section#contribution-details { text-decoration: underline; } } + + .co-contributors { + margin-top: 2rem; + padding-top: 2rem; + border-top: 1px solid $item-border-color; + + h4 { + font-size: 1.2rem; + margin-bottom: 1rem; + color: $body-text-color; + } + + ul { + list-style: none; + + li { + display: flex; + align-items: center; + padding: 0.5rem 0; + gap: 0.5rem; + + .co-contributor { + display: inline-flex; + align-items: center; + text-decoration: none; + color: $body-text-color; + flex: 1; + + .name { + margin-left: 0.5rem; + } + + .amount { + margin-left: auto; + font-weight: 500; + } + + .symbol { + font-size: 0.8rem; + padding-left: 0.2rem; + } + + &:hover .name { + color: $primary-color; + } + } + } + } + } } .actions { diff --git a/app/styles/components/_contribution-list.scss b/app/styles/components/_contribution-list.scss index 2c6034d..d6c781c 100644 --- a/app/styles/components/_contribution-list.scss +++ b/app/styles/components/_contribution-list.scss @@ -41,6 +41,62 @@ ul.contribution-list { opacity: 0.6; } + &.grouped { + grid-template-columns: auto 5rem; + + .avatars { + display: inline-flex; + align-items: center; + + img.avatar { + margin-right: 0; + margin-left: -0.6rem; + border: 2px solid $item-background-color; + z-index: 1; + position: relative; + + &:first-child { + margin-left: 0; + z-index: 3; + } + + &:nth-child(2) { + z-index: 2; + } + } + + .avatar-overflow { + display: inline-flex; + align-items: center; + justify-content: center; + width: 2rem; + height: 2rem; + margin-left: -0.6rem; + border-radius: 1rem; + background-color: $blue; + color: #fff; + font-size: 0.8rem; + font-weight: 500; + border: 2px solid $item-background-color; + z-index: 0; + position: relative; + } + } + + &.expanded { + border-bottom: none; + } + } + + &.group-item { + padding-left: 2.8rem; + background-color: $item-highlighted-background-color; + + &:not(:last-child) { + border-bottom: 1px solid $item-border-color; + } + } + p { align-self: center; margin: 0; diff --git a/app/templates/dashboard/contributions/show.hbs b/app/templates/dashboard/contributions/show.hbs index 52d6b3d..a6a56ed 100644 --- a/app/templates/dashboard/contributions/show.hbs +++ b/app/templates/dashboard/contributions/show.hbs @@ -32,6 +32,22 @@

{{/if}} + {{#if this.siblingContributions.length}} +
+

Also contributed to this

+
    + {{#each this.siblingContributions as |sibling|}} +
  • + + + {{sibling.contributor.name}} + {{sibling.amount}}₭S + +
  • + {{/each}} +
+
+ {{/if}} {{#if this.model.vetoed}}
diff --git a/app/utils/contribution-grouping-key.js b/app/utils/contribution-grouping-key.js index 596ffb9..e036ab7 100644 --- a/app/utils/contribution-grouping-key.js +++ b/app/utils/contribution-grouping-key.js @@ -1,15 +1,12 @@ import { isEmpty } from '@ember/utils'; -// // Derives a stable grouping key for a contribution, so that contributions // created for the same issue/pull request (one per contributor) can be // linked together in the UI. // -// The bot writes the exact same `url` string (the html_url of the issue/PR) -// into every per-contributor IPFS document for a given piece of work, so the -// top-level `url` field on the model is sufficient as a grouping key. Manual -// contributions without a URL return null and are treated as singletons. -// +// Until we have identical IPFS documents for different contributions, we will +// use the URL as grouping key + export default function contributionGroupingKey (contribution) { let url = contribution ? contribution.url : null; if (isEmpty(url)) return null; diff --git a/tests/integration/components/contribution-list/component-test.js b/tests/integration/components/contribution-list/component-test.js index b7f87ba..2bb80b7 100644 --- a/tests/integration/components/contribution-list/component-test.js +++ b/tests/integration/components/contribution-list/component-test.js @@ -8,11 +8,17 @@ import contributions from '../../../fixtures/contributions'; module('Integration | Component | contribution-list', function(hooks) { setupRenderingTest(hooks); - test('it renders all contributions', async function(assert) { + test('it renders all contributions with grouped rows collapsed', async function(assert) { + let kredits = this.owner.lookup('service:kredits'); + kredits.set('contributors', contributors); + this.set('fixtures', contributions); await render(hbs`{{contribution-list contributions=fixtures}}`); - assert.equal(this.element.querySelectorAll('li').length, 9); + // 9 contributions: 2 groups (ids 1+3, 7+8) + 5 singletons = 7 top-level rows + assert.equal(this.element.querySelectorAll('li').length, 7, '7 top-level rows when collapsed'); + assert.equal(this.element.querySelectorAll('li.grouped').length, 2, '2 grouped headers'); + assert.equal(this.element.querySelectorAll('li.group-item').length, 0, 'no expanded sub-items'); }); test('it renders filtered contributions', async function(assert) { @@ -23,12 +29,87 @@ module('Integration | Component | contribution-list', function(hooks) { await render(hbs`{{contribution-list contributions=fixtures showQuickFilter=true}}`); await fillIn('.filter-contributor select', '1'); + // Contributor 1 is in ids 1, 2, 5, 6, 8. Grouped rows stay whole: id 1's + // group (with id 3) and id 8's group (with id 7) show as grouped rows + // because id 1 / id 8 match; ids 2, 5, 6 are singletons. 5 top-level rows. assert.equal(this.element.querySelectorAll('li').length, 5, 'select contributor'); await click('.filter-contribution-size input'); + // Hide small (<=500): drops id 6 only. Grouped rows stay (matching items + // are 1500/1500). 4 top-level rows. assert.equal(this.element.querySelectorAll('li').length, 4, 'hide small contributions'); await fillIn('.filter-contribution-kind select', 'dev'); + // Kind dev (still filtered to contributor 1, hide small on): only groups + // where a single item passes all three filters survive. Group {1,3}: id 1 + // is contributor 1 + dev → included. Group {8,7}: id 8 is contributor 1 but + // community; id 7 is dev but contributor 3 → no item passes both → excluded. + // Singletons id 2 (ops), id 5 (design) → excluded. 1 top-level row. assert.equal(this.element.querySelectorAll('li').length, 1, 'select kind'); }); + + test('filtering by contributor keeps grouped rows whole', async function(assert) { + let kredits = this.owner.lookup('service:kredits'); + kredits.set('contributors', contributors); + + this.set('fixtures', contributions); + await render(hbs`{{contribution-list contributions=fixtures showQuickFilter=true}}`); + + await fillIn('.filter-contributor select', '1'); + + // The first row is the group containing id 1 (contributor 1) and id 3 + // (contributor 2) — it stays grouped, not collapsed to a singleton. + const firstRow = this.element.querySelector('li'); + assert.ok(firstRow.classList.contains('grouped'), 'first row is still grouped'); + assert.equal(firstRow.querySelectorAll('.avatars img').length, 2, 'shows both contributors avatars'); + }); + + test('grouped row expands on click to reveal sub-items', async function(assert) { + let kredits = this.owner.lookup('service:kredits'); + kredits.set('contributors', contributors); + + this.set('fixtures', contributions); + await render(hbs`{{contribution-list contributions=fixtures}}`); + + assert.equal(this.element.querySelectorAll('li.group-item').length, 0, 'no sub-items when collapsed'); + + await click('.grouped:first-child'); + + assert.equal(this.element.querySelectorAll('li.group-item').length, 2, '2 sub-items when expanded'); + assert.ok(this.element.querySelector('.grouped').classList.contains('expanded'), 'header has expanded class'); + + await click('.grouped:first-child'); + assert.equal(this.element.querySelectorAll('li.group-item').length, 0, 'sub-items hidden when collapsed'); + }); + + test('grouped row shows stacked avatars and single amount', async function(assert) { + let kredits = this.owner.lookup('service:kredits'); + kredits.set('contributors', contributors); + + this.set('fixtures', contributions); + await render(hbs`{{contribution-list contributions=fixtures}}`); + + const firstGroup = this.element.querySelector('.grouped'); + // Group ids 1+3 → 2 contributors → 2 avatars, no overflow + assert.equal(firstGroup.querySelectorAll('.avatars img').length, 2, '2 avatar images'); + assert.equal(firstGroup.querySelectorAll('.avatar-overflow').length, 0, 'no overflow bubble for 2 contributors'); + // Single amount (first item's amount = 1500, not the sum) + assert.equal(firstGroup.querySelector('.amount').textContent.trim(), '1500', 'shows single contribution amount'); + }); + + test('auto-expands when selectedContributionId is a group child', async function(assert) { + let kredits = this.owner.lookup('service:kredits'); + kredits.set('contributors', contributors); + + this.set('fixtures', contributions); + this.set('selectedId', 3); + + await render(hbs`{{contribution-list contributions=fixtures selectedContributionId=selectedId}}`); + + assert.equal(this.element.querySelectorAll('li.group-item').length, 2, 'group auto-expanded'); + + const selectedChild = this.element.querySelector('.group-item.selected'); + assert.ok(selectedChild, 'selected child has selected class'); + assert.equal(selectedChild.dataset.contributionId, '3', 'correct child is selected'); + }); }); diff --git a/tests/unit/controllers/dashboard/contributions/show-test.js b/tests/unit/controllers/dashboard/contributions/show-test.js new file mode 100644 index 0000000..724af21 --- /dev/null +++ b/tests/unit/controllers/dashboard/contributions/show-test.js @@ -0,0 +1,34 @@ +import { module, test } from 'qunit'; +import { setupTest } from 'ember-qunit'; +import contributors from '../../../../fixtures/contributors'; +import contributions from '../../../../fixtures/contributions'; + +module('Unit | Controller | dashboard/contributions/show', function(hooks) { + setupTest(hooks); + + test('#siblingContributions returns co-contributor contributions for a grouped contribution', function(assert) { + let kredits = this.owner.lookup('service:kredits'); + kredits.set('contributors', contributors); + kredits.set('contributions', contributions); + + let controller = this.owner.lookup('controller:dashboard.contributions.show'); + // id=1 shares a url with id=3 + controller.set('model', contributions.findBy('id', 1)); + + const siblings = controller.siblingContributions; + assert.equal(siblings.length, 1, 'one sibling'); + assert.equal(siblings[0].id, 3, 'sibling is contribution 3'); + }); + + test('#siblingContributions returns empty for a manual contribution without url', function(assert) { + let kredits = this.owner.lookup('service:kredits'); + kredits.set('contributors', contributors); + kredits.set('contributions', contributions); + + let controller = this.owner.lookup('controller:dashboard.contributions.show'); + // id=2 has no url + controller.set('model', contributions.findBy('id', 2)); + + assert.deepEqual(controller.siblingContributions, [], 'no siblings for manual contribution'); + }); +}); -- 2.50.1 From f7ee8640713d8eb2cc3c28cb274c1446d82f5b5e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=A2u=20Cao?= Date: Sun, 26 Jul 2026 16:40:08 +0200 Subject: [PATCH 4/9] Fix Ember not re-using DOM elements on block updates Causing serious performance issues without the stable key --- app/components/contribution-list/component.js | 1 + app/components/contribution-list/template.hbs | 4 ++-- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/app/components/contribution-list/component.js b/app/components/contribution-list/component.js index 85491af..24cf332 100644 --- a/app/components/contribution-list/component.js +++ b/app/components/contribution-list/component.js @@ -73,6 +73,7 @@ export default Component.extend({ .filter(Boolean); return EmberObject.create({ + key: raw.groupId || `c-${first.id}`, groupId: raw.groupId, items, contributors, diff --git a/app/components/contribution-list/template.hbs b/app/components/contribution-list/template.hbs index e536656..4359668 100644 --- a/app/components/contribution-list/template.hbs +++ b/app/components/contribution-list/template.hbs @@ -30,7 +30,7 @@ {{/if}}
    - {{#each this.groupedContributions as |group|}} + {{#each this.groupedContributions key="key" as |group|}} {{#if group.isGrouped}}
  • -- 2.50.1 From 33a429c4484bed533397fc3fb01eef1950d9bd67 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=A2u=20Cao?= Date: Sun, 26 Jul 2026 17:22:53 +0200 Subject: [PATCH 5/9] Group same contributions without URL, make grouping more precise --- app/models/contribution.js | 7 +- app/utils/contribution-grouping-key.js | 25 ++++-- tests/fixtures/contributions.js | 20 +++-- .../contribution-list/component-test.js | 15 ++-- tests/unit/services/kredits-test.js | 42 +++++---- .../utils/contribution-grouping-key-test.js | 89 +++++++++++++++---- 6 files changed, 135 insertions(+), 63 deletions(-) diff --git a/app/models/contribution.js b/app/models/contribution.js index 21f862a..75bd7fe 100644 --- a/app/models/contribution.js +++ b/app/models/contribution.js @@ -47,9 +47,10 @@ export default EmberObject.extend({ }), // Stable key shared by all per-contributor contributions created for the - // same issue/pull request (derived from `url`). Manual contributions without - // a URL return null and are treated as singletons (not grouped). - groupId: computed('url', function() { + // same piece of work (derived from `url`, `description`, `date`, `amount`, + // and `kind`). Contributions without enough fields to derive a key return + // null and are treated as singletons (not grouped). + groupId: computed('url', 'description', 'date', 'amount', 'kind', function() { return contributionGroupingKey(this); }), diff --git a/app/utils/contribution-grouping-key.js b/app/utils/contribution-grouping-key.js index e036ab7..92763cc 100644 --- a/app/utils/contribution-grouping-key.js +++ b/app/utils/contribution-grouping-key.js @@ -1,14 +1,29 @@ +import ethers from 'ethers'; import { isEmpty } from '@ember/utils'; // Derives a stable grouping key for a contribution, so that contributions // created for the same issue/pull request (one per contributor) can be // linked together in the UI. // -// Until we have identical IPFS documents for different contributions, we will -// use the URL as grouping key +// The key is a 6-char keccak256 prefix of a combination of fields: +// url (optional), description, date, amount, kind. When the URL is present +// (bot contributions) it is included; when absent (manual contributions) +// the remaining fields still produce a reliable key. All of description, +// date, amount, and kind must be present — if any is missing, the +// contribution is treated as a singleton (null key). export default function contributionGroupingKey (contribution) { - let url = contribution ? contribution.url : null; - if (isEmpty(url)) return null; - return url; + if (!contribution) return null; + + const { url, description, date, amount, kind } = contribution; + + if (isEmpty(description) || isEmpty(date) || isEmpty(amount) || isEmpty(kind)) { + return null; + } + + const input = isEmpty(url) + ? `${description}|${date}|${amount}|${kind}` + : `${url}|${description}|${date}|${amount}|${kind}`; + + return ethers.utils.id(input).slice(2, 8); } diff --git a/tests/fixtures/contributions.js b/tests/fixtures/contributions.js index 3af9620..8a22cac 100644 --- a/tests/fixtures/contributions.js +++ b/tests/fixtures/contributions.js @@ -5,15 +5,17 @@ import processContributionData from 'kredits-web/utils/process-contribution-data const items = []; const data = [ - { id: 1, contributorId: 1, confirmedAt: 1000, claimed: false, vetoed: false, amount: 1500, kind: 'dev', url: 'https://github.com/67P/kredits-contracts/pull/196' }, - { id: 2, contributorId: 1, confirmedAt: 1000, claimed: false, vetoed: false, amount: 5000, kind: 'ops' }, - { id: 3, contributorId: 2, confirmedAt: 1000, claimed: false, vetoed: false, amount: 1500, kind: 'ops', url: 'https://github.com/67P/kredits-contracts/pull/196' }, - { id: 4, contributorId: 2, confirmedAt: 1000, claimed: false, vetoed: false, amount: 1500, kind: 'docs' }, - { id: 5, contributorId: 1, confirmedAt: 1000, claimed: false, vetoed: false, amount: 5000, kind: 'design' }, - { id: 6, contributorId: 1, confirmedAt: 1000, claimed: false, vetoed: true, amount: 500, kind: 'dev' }, - { id: 7, contributorId: 3, confirmedAt: 2000, claimed: false, vetoed: false, amount: 5000, kind: 'dev', url: 'https://gitea.kosmos.org/kredits/kredits-web/issues/231' }, - { id: 8, contributorId: 1, confirmedAt: 2000, claimed: false, vetoed: false, amount: 1500, kind: 'community', url: 'https://gitea.kosmos.org/kredits/kredits-web/issues/231' }, - { id: 9, contributorId: 3, confirmedAt: 2000, claimed: false, vetoed: true, amount: 1500, kind: 'docs' }, + { id: 1, contributorId: 1, confirmedAt: 1000, claimed: false, vetoed: false, amount: 1500, kind: 'dev', url: 'https://github.com/67P/kredits-contracts/pull/196', description: 'Chore/dependency updates', date: '2020-05-27' }, + { id: 2, contributorId: 1, confirmedAt: 1000, claimed: false, vetoed: false, amount: 5000, kind: 'ops', description: 'Server maintenance', date: '2020-05-27' }, + { id: 3, contributorId: 2, confirmedAt: 1000, claimed: false, vetoed: false, amount: 1500, kind: 'dev', url: 'https://github.com/67P/kredits-contracts/pull/196', description: 'Chore/dependency updates', date: '2020-05-27' }, + { id: 4, contributorId: 2, confirmedAt: 1000, claimed: false, vetoed: false, amount: 1500, kind: 'docs', description: 'API documentation', date: '2020-05-28' }, + { id: 5, contributorId: 1, confirmedAt: 1000, claimed: false, vetoed: false, amount: 5000, kind: 'design', description: 'UI redesign', date: '2020-05-29' }, + { id: 6, contributorId: 1, confirmedAt: 1000, claimed: false, vetoed: true, amount: 500, kind: 'dev', description: 'Minor fix', date: '2020-05-30' }, + { id: 7, contributorId: 3, confirmedAt: 2000, claimed: false, vetoed: false, amount: 5000, kind: 'dev', url: 'https://gitea.kosmos.org/kredits/kredits-web/issues/231', description: 'Grouped contributions UI', date: '2020-06-01' }, + { id: 8, contributorId: 1, confirmedAt: 2000, claimed: false, vetoed: false, amount: 5000, kind: 'dev', url: 'https://gitea.kosmos.org/kredits/kredits-web/issues/231', description: 'Grouped contributions UI', date: '2020-06-01' }, + { id: 9, contributorId: 3, confirmedAt: 2000, claimed: false, vetoed: true, amount: 1500, kind: 'docs', description: 'Minor docs fix', date: '2020-06-02' }, + { id: 10, contributorId: 2, confirmedAt: 2000, claimed: false, vetoed: false, amount: 1500, kind: 'dev', description: 'Pair programming session', date: '2020-06-03' }, + { id: 11, contributorId: 3, confirmedAt: 2000, claimed: false, vetoed: false, amount: 1500, kind: 'dev', description: 'Pair programming session', date: '2020-06-03' }, ]; data.forEach(attrs => { diff --git a/tests/integration/components/contribution-list/component-test.js b/tests/integration/components/contribution-list/component-test.js index 2bb80b7..febf8e6 100644 --- a/tests/integration/components/contribution-list/component-test.js +++ b/tests/integration/components/contribution-list/component-test.js @@ -15,9 +15,9 @@ module('Integration | Component | contribution-list', function(hooks) { this.set('fixtures', contributions); await render(hbs`{{contribution-list contributions=fixtures}}`); - // 9 contributions: 2 groups (ids 1+3, 7+8) + 5 singletons = 7 top-level rows - assert.equal(this.element.querySelectorAll('li').length, 7, '7 top-level rows when collapsed'); - assert.equal(this.element.querySelectorAll('li.grouped').length, 2, '2 grouped headers'); + // 11 contributions: 3 groups ({1,3}, {7,8}, {10,11}) + 5 singletons = 8 rows + assert.equal(this.element.querySelectorAll('li').length, 8, '8 top-level rows when collapsed'); + assert.equal(this.element.querySelectorAll('li.grouped').length, 3, '3 grouped headers'); assert.equal(this.element.querySelectorAll('li.group-item').length, 0, 'no expanded sub-items'); }); @@ -36,16 +36,15 @@ module('Integration | Component | contribution-list', function(hooks) { await click('.filter-contribution-size input'); // Hide small (<=500): drops id 6 only. Grouped rows stay (matching items - // are 1500/1500). 4 top-level rows. + // are 1500/5000). 4 top-level rows. assert.equal(this.element.querySelectorAll('li').length, 4, 'hide small contributions'); await fillIn('.filter-contribution-kind select', 'dev'); // Kind dev (still filtered to contributor 1, hide small on): only groups // where a single item passes all three filters survive. Group {1,3}: id 1 - // is contributor 1 + dev → included. Group {8,7}: id 8 is contributor 1 but - // community; id 7 is dev but contributor 3 → no item passes both → excluded. - // Singletons id 2 (ops), id 5 (design) → excluded. 1 top-level row. - assert.equal(this.element.querySelectorAll('li').length, 1, 'select kind'); + // is contributor 1 + dev → included. Group {7,8}: id 8 is contributor 1 + + // dev → included. Singletons id 2 (ops), id 5 (design) → excluded. 2 rows. + assert.equal(this.element.querySelectorAll('li').length, 2, 'select kind'); }); test('filtering by contributor keeps grouped rows whole', async function(assert) { diff --git a/tests/unit/services/kredits-test.js b/tests/unit/services/kredits-test.js index 6087acd..276b888 100644 --- a/tests/unit/services/kredits-test.js +++ b/tests/unit/services/kredits-test.js @@ -23,7 +23,7 @@ module('Unit | Service | kredits', function(hooks) { service.set('currentBlock', 1023); const items = service.contributionsUnconfirmed; - assert.equal(items.length, 3); + assert.equal(items.length, 5); }); test('#kreditsByContributor', function(assert) { @@ -38,18 +38,18 @@ module('Unit | Service | kredits', function(hooks) { const c1 = kreditsByContributor.find(k => k.contributor.id == 1); assert.equal(c1.amountConfirmed, 11500, 'correct amount confirmed'); - assert.equal(c1.amountUnconfirmed, 1500, 'correct amount unconfirmed'); - assert.equal(c1.amountTotal, 13000, 'correct amount total'); + assert.equal(c1.amountUnconfirmed, 5000, 'correct amount unconfirmed'); + assert.equal(c1.amountTotal, 16500, 'correct amount total'); const c2 = kreditsByContributor.find(k => k.contributor.id == 2); assert.equal(c2.amountConfirmed, 3000, 'correct amount confirmed'); - assert.equal(c2.amountUnconfirmed, 0, 'correct amount unconfirmed'); - assert.equal(c2.amountTotal, 3000, 'correct amount total'); + assert.equal(c2.amountUnconfirmed, 1500, 'correct amount unconfirmed'); + assert.equal(c2.amountTotal, 4500, 'correct amount total'); const c3 = kreditsByContributor.find(k => k.contributor.id == 3); assert.equal(c3.amountConfirmed, 0, 'correct amount confirmed'); - assert.equal(c3.amountUnconfirmed, 5000, 'correct amount unconfirmed'); - assert.equal(c3.amountTotal, 5000, 'correct amount total'); + assert.equal(c3.amountUnconfirmed, 6500, 'correct amount unconfirmed'); + assert.equal(c3.amountTotal, 6500, 'correct amount total'); }); test('#contributorsSorted', function(assert) { @@ -67,11 +67,10 @@ module('Unit | Service | kredits', function(hooks) { const grouped = service.contributionsGrouped; - // 9 contributions, with ids 1+3 sharing a url and ids 7+8 sharing a url, - // so 7 groups total (2 grouped + 5 singletons). - assert.equal(grouped.length, 7, 'produces one group per unique key plus singletons'); + // 11 contributions: 3 groups ({1,3}, {7,8}, {10,11}) + 5 singletons + assert.equal(grouped.length, 8, 'produces one group per unique key plus singletons'); - const ghGroup = grouped.findBy('groupId', 'https://github.com/67P/kredits-contracts/pull/196'); + const ghGroup = grouped.findBy('groupId', contributions.findBy('id', 1).groupId); assert.ok(ghGroup, 'github url group exists'); assert.ok(ghGroup.isGrouped, 'github group is flagged as grouped'); assert.equal(ghGroup.items.length, 2, 'github group has two contributions'); @@ -81,14 +80,20 @@ module('Unit | Service | kredits', function(hooks) { assert.equal(ghGroup.kind, 'dev', 'github group takes kind from first item'); assert.equal(ghGroup.url, 'https://github.com/67P/kredits-contracts/pull/196'); - const giteaGroup = grouped.findBy('groupId', 'https://gitea.kosmos.org/kredits/kredits-web/issues/231'); + const giteaGroup = grouped.findBy('groupId', contributions.findBy('id', 7).groupId); assert.ok(giteaGroup, 'gitea url group exists'); assert.ok(giteaGroup.isGrouped, 'gitea group is flagged as grouped'); assert.deepEqual(giteaGroup.contributorIds, [3, 1], 'gitea group lists both contributor ids'); - assert.equal(giteaGroup.totalAmount, 6500, 'gitea group sums amounts (5000 + 1500)'); + assert.equal(giteaGroup.totalAmount, 10000, 'gitea group sums amounts (5000 + 5000)'); + + const manualGroup = grouped.findBy('groupId', contributions.findBy('id', 10).groupId); + assert.ok(manualGroup, 'manual (no-url) group exists'); + assert.ok(manualGroup.isGrouped, 'manual group is flagged as grouped'); + assert.deepEqual(manualGroup.contributorIds, [2, 3], 'manual group lists both contributor ids'); + assert.equal(manualGroup.totalAmount, 3000, 'manual group sums amounts (1500 + 1500)'); }); - test('#contributionsGrouped treats manual contributions without url as singletons', function(assert) { + test('#contributionsGrouped treats contributions without enough fields as singletons', function(assert) { let service = this.owner.lookup('service:kredits'); service.set('contributors', contributors); service.set('contributions', contributions); @@ -96,11 +101,10 @@ module('Unit | Service | kredits', function(hooks) { const grouped = service.contributionsGrouped; const singletons = grouped.filterBy('isGrouped', false); - // 5 contributions without a url: ids 2, 4, 5, 6, 9 - assert.equal(singletons.length, 5, 'one singleton group per url-less contribution'); + // 5 singletons: ids 2, 4, 5, 6, 9 (each has unique description/date/amount/kind) + assert.equal(singletons.length, 5, 'one singleton group per non-grouped contribution'); singletons.forEach(g => { assert.equal(g.items.length, 1, 'singleton group has exactly one item'); - assert.notOk(g.groupId, 'singleton group has a null/empty groupId'); }); }); @@ -115,13 +119,13 @@ module('Unit | Service | kredits', function(hooks) { assert.equal(siblings[0].id, 3, 'the sibling is contribution 3'); }); - test('#siblingContributions returns empty for manual contributions', function(assert) { + test('#siblingContributions returns empty for contributions without siblings', function(assert) { let service = this.owner.lookup('service:kredits'); service.set('contributors', contributors); service.set('contributions', contributions); const c2 = contributions.findBy('id', 2); - assert.deepEqual(service.siblingContributions(c2), [], 'no siblings for url-less contribution'); + assert.deepEqual(service.siblingContributions(c2), [], 'no siblings for unique contribution'); }); test('#siblingContributions returns empty for null contribution', function(assert) { diff --git a/tests/unit/utils/contribution-grouping-key-test.js b/tests/unit/utils/contribution-grouping-key-test.js index 33e720e..45e649e 100644 --- a/tests/unit/utils/contribution-grouping-key-test.js +++ b/tests/unit/utils/contribution-grouping-key-test.js @@ -2,43 +2,94 @@ import { module, test } from 'qunit'; import EmberObject from '@ember/object'; import contributionGroupingKey from 'kredits-web/utils/contribution-grouping-key'; -function makeContribution (url) { - return EmberObject.create({ url }); +function makeContribution (attrs) { + return EmberObject.create(attrs); } +const baseAttrs = { + url: 'https://github.com/67P/kredits-contracts/pull/196', + description: 'Chore/dependency updates', + date: '2020-05-27', + amount: 1500, + kind: 'dev' +}; + module('Unit | Utils | contribution-grouping-key', function() { - test('returns the url when present', function(assert) { - const c = makeContribution('https://github.com/67P/kredits-contracts/pull/196'); - assert.equal(contributionGroupingKey(c), 'https://github.com/67P/kredits-contracts/pull/196'); + test('returns a 6-char hex string when all fields are present', function(assert) { + const c = makeContribution(baseAttrs); + const key = contributionGroupingKey(c); + assert.equal(key.length, 6, '6 chars'); + assert.ok(/^[0-9a-f]{6}$/.test(key), 'is lowercase hex: ' + key); }); - test('returns the same key for identical github urls', function(assert) { - const a = makeContribution('https://github.com/67P/kredits-contracts/pull/196'); - const b = makeContribution('https://github.com/67P/kredits-contracts/pull/196'); + test('returns the same key for identical contributions with url', function(assert) { + const a = makeContribution(baseAttrs); + const b = makeContribution(baseAttrs); assert.equal(contributionGroupingKey(a), contributionGroupingKey(b)); }); - test('returns the same key for identical gitea urls', function(assert) { - const a = makeContribution('https://gitea.kosmos.org/kredits/kredits-web/issues/231'); - const b = makeContribution('https://gitea.kosmos.org/kredits/kredits-web/issues/231'); + test('returns the same key for identical contributions without url', function(assert) { + const attrs = { ...baseAttrs, url: null }; + const a = makeContribution(attrs); + const b = makeContribution(attrs); assert.equal(contributionGroupingKey(a), contributionGroupingKey(b)); }); - test('returns different keys for different urls', function(assert) { - const a = makeContribution('https://github.com/67P/kredits-contracts/pull/196'); - const b = makeContribution('https://github.com/67P/kredits-contracts/pull/197'); + test('returns different keys when url differs', function(assert) { + const a = makeContribution(baseAttrs); + const b = makeContribution({ ...baseAttrs, url: 'https://github.com/67P/kredits-contracts/pull/197' }); assert.notEqual(contributionGroupingKey(a), contributionGroupingKey(b)); }); - test('returns null when url is empty', function(assert) { - const c = makeContribution(''); + test('returns different keys when amount differs', function(assert) { + const a = makeContribution(baseAttrs); + const b = makeContribution({ ...baseAttrs, amount: 5000 }); + assert.notEqual(contributionGroupingKey(a), contributionGroupingKey(b)); + }); + + test('returns different keys when kind differs', function(assert) { + const a = makeContribution(baseAttrs); + const b = makeContribution({ ...baseAttrs, kind: 'ops' }); + assert.notEqual(contributionGroupingKey(a), contributionGroupingKey(b)); + }); + + test('returns different keys when description differs', function(assert) { + const a = makeContribution(baseAttrs); + const b = makeContribution({ ...baseAttrs, description: 'Something else' }); + assert.notEqual(contributionGroupingKey(a), contributionGroupingKey(b)); + }); + + test('returns different keys when date differs', function(assert) { + const a = makeContribution(baseAttrs); + const b = makeContribution({ ...baseAttrs, date: '2020-05-28' }); + assert.notEqual(contributionGroupingKey(a), contributionGroupingKey(b)); + }); + + test('returns different keys for url vs no-url with otherwise identical fields', function(assert) { + const withUrl = makeContribution(baseAttrs); + const withoutUrl = makeContribution({ ...baseAttrs, url: null }); + assert.notEqual(contributionGroupingKey(withUrl), contributionGroupingKey(withoutUrl)); + }); + + test('returns null when description is missing', function(assert) { + const c = makeContribution({ ...baseAttrs, description: null }); assert.equal(contributionGroupingKey(c), null); }); - test('returns null when url is null/undefined', function(assert) { - assert.equal(contributionGroupingKey(makeContribution(null)), null); - assert.equal(contributionGroupingKey(makeContribution(undefined)), null); + test('returns null when date is missing', function(assert) { + const c = makeContribution({ ...baseAttrs, date: null }); + assert.equal(contributionGroupingKey(c), null); + }); + + test('returns null when amount is missing', function(assert) { + const c = makeContribution({ ...baseAttrs, amount: null }); + assert.equal(contributionGroupingKey(c), null); + }); + + test('returns null when kind is missing', function(assert) { + const c = makeContribution({ ...baseAttrs, kind: null }); + assert.equal(contributionGroupingKey(c), null); }); test('returns null when contribution is null/undefined', function(assert) { -- 2.50.1 From 26f24be551fe63ff21005d03b556ae616a17b884 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=A2u=20Cao?= Date: Sun, 26 Jul 2026 17:30:11 +0200 Subject: [PATCH 6/9] Fix grouped entry styles Correct avatar size and vertical centering --- app/styles/components/_contribution-list.scss | 3 +++ 1 file changed, 3 insertions(+) diff --git a/app/styles/components/_contribution-list.scss b/app/styles/components/_contribution-list.scss index d6c781c..a156397 100644 --- a/app/styles/components/_contribution-list.scss +++ b/app/styles/components/_contribution-list.scss @@ -47,11 +47,13 @@ ul.contribution-list { .avatars { display: inline-flex; align-items: center; + vertical-align: middle; img.avatar { margin-right: 0; margin-left: -0.6rem; border: 2px solid $item-background-color; + box-sizing: content-box; z-index: 1; position: relative; @@ -78,6 +80,7 @@ ul.contribution-list { font-size: 0.8rem; font-weight: 500; border: 2px solid $item-background-color; + box-sizing: content-box; z-index: 0; position: relative; } -- 2.50.1 From 4b6ca6eea9b14f3edbc3b18819c0e8f9f787a369 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=A2u=20Cao?= Date: Sun, 26 Jul 2026 17:38:08 +0200 Subject: [PATCH 7/9] Improve background colors for grouped entries --- app/styles/components/_contribution-list.scss | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/app/styles/components/_contribution-list.scss b/app/styles/components/_contribution-list.scss index a156397..9b8bab8 100644 --- a/app/styles/components/_contribution-list.scss +++ b/app/styles/components/_contribution-list.scss @@ -93,7 +93,11 @@ ul.contribution-list { &.group-item { padding-left: 2.8rem; - background-color: $item-highlighted-background-color; + background-color: rgba(255,255,255,0.05); + + &.selected { + background-color: $item-highlighted-background-color; + } &:not(:last-child) { border-bottom: 1px solid $item-border-color; -- 2.50.1 From 0b14742a15463c674bf7eb987618586a2447f8dc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=A2u=20Cao?= Date: Sun, 26 Jul 2026 17:43:41 +0200 Subject: [PATCH 8/9] Improve color/contrast of contributor number circle --- app/styles/components/_contribution-list.scss | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/styles/components/_contribution-list.scss b/app/styles/components/_contribution-list.scss index 9b8bab8..c97e9a0 100644 --- a/app/styles/components/_contribution-list.scss +++ b/app/styles/components/_contribution-list.scss @@ -75,7 +75,7 @@ ul.contribution-list { height: 2rem; margin-left: -0.6rem; border-radius: 1rem; - background-color: $blue; + background-color: #232323; color: #fff; font-size: 0.8rem; font-weight: 500; -- 2.50.1 From 8a8d4f3d04f33b7c834b7db7d83d5626da3e29e2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=A2u=20Cao?= Date: Sun, 26 Jul 2026 18:06:04 +0200 Subject: [PATCH 9/9] Make details pane sticky on desktop closes #234 --- app/styles/_layout.scss | 24 +++++++++++++++---- .../components/_contribution-details.scss | 2 +- 2 files changed, 21 insertions(+), 5 deletions(-) diff --git a/app/styles/_layout.scss b/app/styles/_layout.scss index e65c4ee..bb0f120 100644 --- a/app/styles/_layout.scss +++ b/app/styles/_layout.scss @@ -97,7 +97,7 @@ main { } } -@media (min-width: 550px) { +@include media-min(small) { main { &#dashboard { grid-column-gap: 4rem; @@ -107,10 +107,9 @@ main { "stats contributions"; &.with-details { - grid-column-gap: 3rem; - grid-template-columns: 2fr 4fr 2fr; + grid-template-columns: 1fr; grid-template-areas: - "stats contributions details"; + "details"; } } @@ -123,6 +122,23 @@ main { } } +@include media-min(medium) { + main#dashboard.with-details { + grid-column-gap: 3rem; + grid-template-columns: 2fr 4fr 2fr; + grid-template-areas: + "stats contributions details"; + + #details { + position: sticky; + top: 2rem; + align-self: start; + max-height: calc(100vh - 4rem); + overflow-y: auto; + } + } +} + main section { margin-bottom: 5rem; diff --git a/app/styles/components/_contribution-details.scss b/app/styles/components/_contribution-details.scss index bbd2d0f..af086e5 100644 --- a/app/styles/components/_contribution-details.scss +++ b/app/styles/components/_contribution-details.scss @@ -137,7 +137,7 @@ section#contribution-details { // On small screens, hide intro text, contributor and contributions list when // showing details -@include media-max(small) { +@include media-max(medium) { #dashboard.with-details { #contributions, #stats { display: none; -- 2.50.1