From ef94c40c9bc43708a9548f7e59cc33233f95d2b7 Mon Sep 17 00:00:00 2001 From: Luke Manning Date: Tue, 18 Aug 2026 22:06:01 +0100 Subject: [PATCH 1/4] fix(sort): treat null dates as oldest in sortByDate Null dates previously sorted to the TOP in desc order (and the bottom in asc), making dateless projects flip position with sort direction. Treat null as epoch-equivalent so dateless projects sort last in newest-first and first in oldest-first, consistently in both directions. This also matches the already-published docs for sortByDate. Fixes #23 --- .../core/src/lib/__tests__/sortByDate.test.ts | 43 +++++++++++++++++-- packages/core/src/lib/sortByDate.ts | 18 +++----- 2 files changed, 45 insertions(+), 16 deletions(-) diff --git a/packages/core/src/lib/__tests__/sortByDate.test.ts b/packages/core/src/lib/__tests__/sortByDate.test.ts index a35d9a3..dae6036 100644 --- a/packages/core/src/lib/__tests__/sortByDate.test.ts +++ b/packages/core/src/lib/__tests__/sortByDate.test.ts @@ -91,7 +91,7 @@ describe('sortByDate', () => { }) describe('handling missing dates', () => { - it('should place projects without dates at the beginning in desc order', () => { + it('should place projects without dates at the end in desc order (newest first)', () => { const projects = [ createProject({ id: '1', updatedAt: '2024-01-01' }), createProject({ id: '2', updatedAt: null, createdAt: null }), @@ -100,10 +100,10 @@ describe('sortByDate', () => { const result = sortByDate(projects, 'desc') - expect(result.map(p => p.id)).toEqual(['2', '3', '1']) + expect(result.map(p => p.id)).toEqual(['3', '1', '2']) }) - it('should place projects without dates at the end in asc order', () => { + it('should place projects without dates at the beginning in asc order (oldest first)', () => { const projects = [ createProject({ id: '1', updatedAt: '2024-01-01' }), createProject({ id: '2', updatedAt: null, createdAt: null }), @@ -112,7 +112,42 @@ describe('sortByDate', () => { const result = sortByDate(projects, 'asc') - expect(result.map(p => p.id)).toEqual(['1', '3', '2']) + expect(result.map(p => p.id)).toEqual(['2', '1', '3']) + }) + + it('should place projects without dates at the end when order is omitted (default desc)', () => { + const projects = [ + createProject({ id: '1', updatedAt: null, createdAt: null }), + createProject({ id: '2', updatedAt: '2024-06-01' }), + ] + + const result = sortByDate(projects) + + expect(result.map(p => p.id)).toEqual(['2', '1']) + }) + + it('should sort dateless projects after createdAt-only projects in desc order', () => { + const projects = [ + createProject({ id: '1', updatedAt: null, createdAt: null }), + createProject({ id: '2', updatedAt: null, createdAt: '2024-01-01' }), + createProject({ id: '3', updatedAt: '2024-06-01' }), + ] + + const result = sortByDate(projects, 'desc') + + expect(result.map(p => p.id)).toEqual(['3', '2', '1']) + }) + + it('should sort dateless projects before createdAt-only projects in asc order', () => { + const projects = [ + createProject({ id: '1', updatedAt: null, createdAt: null }), + createProject({ id: '2', updatedAt: null, createdAt: '2024-01-01' }), + createProject({ id: '3', updatedAt: '2024-06-01' }), + ] + + const result = sortByDate(projects, 'asc') + + expect(result.map(p => p.id)).toEqual(['1', '2', '3']) }) it('should handle all projects without dates', () => { diff --git a/packages/core/src/lib/sortByDate.ts b/packages/core/src/lib/sortByDate.ts index 6a40b26..04a6d1b 100644 --- a/packages/core/src/lib/sortByDate.ts +++ b/packages/core/src/lib/sortByDate.ts @@ -13,18 +13,12 @@ export function sortByDate(projects: ProjexProject[], order: SortOrder = 'desc') const dateA = a.updatedAt || a.createdAt || null const dateB = b.updatedAt || b.createdAt || null - if (!dateA && !dateB) { - return 0 - } + // Treat missing dates as epoch (oldest possible) so dateless projects sort + // last in newest-first order and first in oldest-first order, consistently + // in both directions (issue #23). + const timeA = dateA ? new Date(dateA).getTime() : 0 + const timeB = dateB ? new Date(dateB).getTime() : 0 - if (!dateA) { - return 1 * multiplier - } - - if (!dateB) { - return -1 * multiplier - } - - return (new Date(dateA).getTime() - new Date(dateB).getTime()) * multiplier + return (timeA - timeB) * multiplier }) } From 20dfaa72295946a3ca3d4c5286f2b817ab217573 Mon Sep 17 00:00:00 2001 From: Luke Manning Date: Tue, 18 Aug 2026 22:17:13 +0100 Subject: [PATCH 2/4] docs(changelog): add Unreleased entry for sortByDate null-date fix Address PR #27 review: clarify epoch-equivalent comment, collapse the mirrored desc/asc tests with it.each, and drop the redundant both-null test. --- CHANGELOG.md | 8 +++ .../core/src/lib/__tests__/sortByDate.test.ts | 53 +++++-------------- packages/core/src/lib/sortByDate.ts | 2 +- 3 files changed, 21 insertions(+), 42 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3281c83..8129053 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,12 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [Unreleased] + +### Fixed + +- **`sortByDate` null-date handling** — Projects with no date (`updatedAt` and `createdAt` both null) previously sorted to the top in `desc` order, and flipped position with sort direction. Null dates are now treated as epoch-equivalent (oldest), so dateless projects sort last in newest-first and first in oldest-first, consistently in both directions. Matches the already-documented behavior. Fixes [#23]. + ## [1.5.0] - 2026-08-08 ### Changed @@ -434,6 +440,8 @@ The following are considered implementation details and may change in any versio [#6]: https://github.com/ManningWorks/Projex/issues/6 [#8]: https://github.com/ManningWorks/Projex/issues/8 +[#23]: https://github.com/ManningWorks/Projex/issues/23 +[Unreleased]: https://github.com/ManningWorks/Projex/compare/v1.5.0...HEAD [#10]: https://github.com/ManningWorks/Projex/issues/10 [#11]: https://github.com/ManningWorks/Projex/issues/11 [1.5.0]: https://github.com/ManningWorks/Projex/compare/v1.4.0...v1.5.0 diff --git a/packages/core/src/lib/__tests__/sortByDate.test.ts b/packages/core/src/lib/__tests__/sortByDate.test.ts index dae6036..3b62e20 100644 --- a/packages/core/src/lib/__tests__/sortByDate.test.ts +++ b/packages/core/src/lib/__tests__/sortByDate.test.ts @@ -91,28 +91,19 @@ describe('sortByDate', () => { }) describe('handling missing dates', () => { - it('should place projects without dates at the end in desc order (newest first)', () => { + it.each([ + { order: 'desc' as SortOrder, position: 'end', expected: ['3', '1', '2'] }, + { order: 'asc' as SortOrder, position: 'beginning', expected: ['2', '1', '3'] }, + ])('should place projects without dates at the $position in $order order', ({ order, expected }) => { const projects = [ createProject({ id: '1', updatedAt: '2024-01-01' }), createProject({ id: '2', updatedAt: null, createdAt: null }), createProject({ id: '3', updatedAt: '2024-06-01' }), ] - const result = sortByDate(projects, 'desc') + const result = sortByDate(projects, order) - expect(result.map(p => p.id)).toEqual(['3', '1', '2']) - }) - - it('should place projects without dates at the beginning in asc order (oldest first)', () => { - const projects = [ - createProject({ id: '1', updatedAt: '2024-01-01' }), - createProject({ id: '2', updatedAt: null, createdAt: null }), - createProject({ id: '3', updatedAt: '2024-06-01' }), - ] - - const result = sortByDate(projects, 'asc') - - expect(result.map(p => p.id)).toEqual(['2', '1', '3']) + expect(result.map(p => p.id)).toEqual(expected) }) it('should place projects without dates at the end when order is omitted (default desc)', () => { @@ -126,28 +117,19 @@ describe('sortByDate', () => { expect(result.map(p => p.id)).toEqual(['2', '1']) }) - it('should sort dateless projects after createdAt-only projects in desc order', () => { + it.each([ + { order: 'desc' as SortOrder, position: 'after', expected: ['3', '2', '1'] }, + { order: 'asc' as SortOrder, position: 'before', expected: ['1', '2', '3'] }, + ])('should sort dateless projects $position createdAt-only projects in $order order', ({ order, expected }) => { const projects = [ createProject({ id: '1', updatedAt: null, createdAt: null }), createProject({ id: '2', updatedAt: null, createdAt: '2024-01-01' }), createProject({ id: '3', updatedAt: '2024-06-01' }), ] - const result = sortByDate(projects, 'desc') + const result = sortByDate(projects, order) - expect(result.map(p => p.id)).toEqual(['3', '2', '1']) - }) - - it('should sort dateless projects before createdAt-only projects in asc order', () => { - const projects = [ - createProject({ id: '1', updatedAt: null, createdAt: null }), - createProject({ id: '2', updatedAt: null, createdAt: '2024-01-01' }), - createProject({ id: '3', updatedAt: '2024-06-01' }), - ] - - const result = sortByDate(projects, 'asc') - - expect(result.map(p => p.id)).toEqual(['1', '2', '3']) + expect(result.map(p => p.id)).toEqual(expected) }) it('should handle all projects without dates', () => { @@ -160,17 +142,6 @@ describe('sortByDate', () => { expect(result).toHaveLength(2) }) - - it('should return 0 for comparison when both dates are missing', () => { - const projects = [ - createProject({ id: '1', updatedAt: null, createdAt: null }), - createProject({ id: '2', updatedAt: null, createdAt: null }), - ] - - const result = sortByDate(projects, 'desc') - - expect(result).toHaveLength(2) - }) }) describe('date parsing', () => { diff --git a/packages/core/src/lib/sortByDate.ts b/packages/core/src/lib/sortByDate.ts index 04a6d1b..cb07286 100644 --- a/packages/core/src/lib/sortByDate.ts +++ b/packages/core/src/lib/sortByDate.ts @@ -13,7 +13,7 @@ export function sortByDate(projects: ProjexProject[], order: SortOrder = 'desc') const dateA = a.updatedAt || a.createdAt || null const dateB = b.updatedAt || b.createdAt || null - // Treat missing dates as epoch (oldest possible) so dateless projects sort + // Treat missing dates as epoch-equivalent (oldest) so dateless projects sort // last in newest-first order and first in oldest-first order, consistently // in both directions (issue #23). const timeA = dateA ? new Date(dateA).getTime() : 0 From 557d10b07b57ec77a230086110f8255df3543521 Mon Sep 17 00:00:00 2001 From: Luke Manning Date: Tue, 18 Aug 2026 22:29:28 +0100 Subject: [PATCH 3/4] test(sort): address second-pass review of PR #27 Type it.each rows via generic instead of repeated as-casts, assert stable input order in the all-dateless test, and restore ascending issue-number order of CHANGELOG link refs. --- CHANGELOG.md | 4 ++-- .../core/src/lib/__tests__/sortByDate.test.ts | 15 ++++++++------- 2 files changed, 10 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8129053..fffe47a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -440,8 +440,8 @@ The following are considered implementation details and may change in any versio [#6]: https://github.com/ManningWorks/Projex/issues/6 [#8]: https://github.com/ManningWorks/Projex/issues/8 -[#23]: https://github.com/ManningWorks/Projex/issues/23 -[Unreleased]: https://github.com/ManningWorks/Projex/compare/v1.5.0...HEAD [#10]: https://github.com/ManningWorks/Projex/issues/10 [#11]: https://github.com/ManningWorks/Projex/issues/11 +[#23]: https://github.com/ManningWorks/Projex/issues/23 +[Unreleased]: https://github.com/ManningWorks/Projex/compare/v1.5.0...HEAD [1.5.0]: https://github.com/ManningWorks/Projex/compare/v1.4.0...v1.5.0 diff --git a/packages/core/src/lib/__tests__/sortByDate.test.ts b/packages/core/src/lib/__tests__/sortByDate.test.ts index 3b62e20..d4c620e 100644 --- a/packages/core/src/lib/__tests__/sortByDate.test.ts +++ b/packages/core/src/lib/__tests__/sortByDate.test.ts @@ -91,9 +91,9 @@ describe('sortByDate', () => { }) describe('handling missing dates', () => { - it.each([ - { order: 'desc' as SortOrder, position: 'end', expected: ['3', '1', '2'] }, - { order: 'asc' as SortOrder, position: 'beginning', expected: ['2', '1', '3'] }, + it.each<{ order: SortOrder; position: string; expected: string[] }>([ + { order: 'desc', position: 'end', expected: ['3', '1', '2'] }, + { order: 'asc', position: 'beginning', expected: ['2', '1', '3'] }, ])('should place projects without dates at the $position in $order order', ({ order, expected }) => { const projects = [ createProject({ id: '1', updatedAt: '2024-01-01' }), @@ -117,9 +117,9 @@ describe('sortByDate', () => { expect(result.map(p => p.id)).toEqual(['2', '1']) }) - it.each([ - { order: 'desc' as SortOrder, position: 'after', expected: ['3', '2', '1'] }, - { order: 'asc' as SortOrder, position: 'before', expected: ['1', '2', '3'] }, + it.each<{ order: SortOrder; position: string; expected: string[] }>([ + { order: 'desc', position: 'after', expected: ['3', '2', '1'] }, + { order: 'asc', position: 'before', expected: ['1', '2', '3'] }, ])('should sort dateless projects $position createdAt-only projects in $order order', ({ order, expected }) => { const projects = [ createProject({ id: '1', updatedAt: null, createdAt: null }), @@ -140,7 +140,8 @@ describe('sortByDate', () => { const result = sortByDate(projects) - expect(result).toHaveLength(2) + // Equal (zero) comparisons keep input order via stable sort + expect(result.map(p => p.id)).toEqual(['1', '2']) }) }) From d2375bb3f75ea4552f2c9e29d029618b12b4139b Mon Sep 17 00:00:00 2001 From: Luke Manning Date: Tue, 18 Aug 2026 22:48:55 +0100 Subject: [PATCH 4/4] fix(sort): keep dateless projects oldest against pre-1970 dates The epoch-equivalent (0) sentinel sorted any real pre-1970 date as older than dateless projects, breaking the documented end/beginning placement. Use -Infinity so a missing date is older than any real date. NaN from (-Inf) - (-Inf) is treated as 0 by the sort spec, keeping the all-dateless case stable. --- packages/core/src/lib/__tests__/sortByDate.test.ts | 14 ++++++++++++++ packages/core/src/lib/sortByDate.ts | 6 +++--- 2 files changed, 17 insertions(+), 3 deletions(-) diff --git a/packages/core/src/lib/__tests__/sortByDate.test.ts b/packages/core/src/lib/__tests__/sortByDate.test.ts index d4c620e..9c9bcee 100644 --- a/packages/core/src/lib/__tests__/sortByDate.test.ts +++ b/packages/core/src/lib/__tests__/sortByDate.test.ts @@ -132,6 +132,20 @@ describe('sortByDate', () => { expect(result.map(p => p.id)).toEqual(expected) }) + it.each<{ order: SortOrder; position: string; expected: string[] }>([ + { order: 'desc', position: 'end', expected: ['2', '1'] }, + { order: 'asc', position: 'beginning', expected: ['1', '2'] }, + ])('should place projects without dates at the $position even against pre-1970 dates in $order order', ({ order, expected }) => { + const projects = [ + createProject({ id: '1', updatedAt: null, createdAt: null }), + createProject({ id: '2', updatedAt: '1950-06-01' }), + ] + + const result = sortByDate(projects, order) + + expect(result.map(p => p.id)).toEqual(expected) + }) + it('should handle all projects without dates', () => { const projects = [ createProject({ id: '1', updatedAt: null, createdAt: null }), diff --git a/packages/core/src/lib/sortByDate.ts b/packages/core/src/lib/sortByDate.ts index cb07286..f32a66a 100644 --- a/packages/core/src/lib/sortByDate.ts +++ b/packages/core/src/lib/sortByDate.ts @@ -13,11 +13,11 @@ export function sortByDate(projects: ProjexProject[], order: SortOrder = 'desc') const dateA = a.updatedAt || a.createdAt || null const dateB = b.updatedAt || b.createdAt || null - // Treat missing dates as epoch-equivalent (oldest) so dateless projects sort + // Treat missing dates as older than any real date so dateless projects sort // last in newest-first order and first in oldest-first order, consistently // in both directions (issue #23). - const timeA = dateA ? new Date(dateA).getTime() : 0 - const timeB = dateB ? new Date(dateB).getTime() : 0 + const timeA = dateA ? new Date(dateA).getTime() : Number.NEGATIVE_INFINITY + const timeB = dateB ? new Date(dateB).getTime() : Number.NEGATIVE_INFINITY return (timeA - timeB) * multiplier })