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