Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 33 additions & 0 deletions .github/workflows/ci.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
name: CI

on:
pull_request:
branches:
- "main"

jobs:
build:
runs-on: ubuntu-latest

steps:
- name: Checkout code
uses: actions/checkout@v4

- name: Set up Node.js
uses: actions/setup-node@v3
with:
node-version: 23
cache: 'npm'

- name: Install dependencies
run: npm i --dev

- name: Run Tests
env:
NODE_ENV: test
run: npm run test

- name: Build the package
env:
NODE_ENV: production
run: npm run build
56 changes: 56 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,62 @@ describe('AppController (e2e)', () => {
});
```

---

## CalendarDate Value Object

**CalendarDate** is a simple and immutable value object representing a calendar date without time or timezone information, storing only the year, month, and day. It ensures valid date creation and provides convenient methods for manipulation and comparison.

* Immutable representation of a date in `YYYY-MM-DD` format.
* Creation from string (`YYYY-MM-DD`) or native `Date` objects.
* Validation to prevent invalid dates.
* Comparison methods (`equals`, `isBefore`, `isAfter`, etc.).
* Methods to add or subtract days safely.
* Conversion back to native `Date` objects (with time zeroed).
* Useful for date-only domain logic where time is irrelevant.

### Usage as value object

```ts
import { CalendarDate } from '../value-object/calendar-date';

// Create from string
const date1 = CalendarDate.fromString('2025-06-14');

// Create from native Date
const date2 = CalendarDate.fromDate(new Date());

// Get today's date as CalendarDate
const today = CalendarDate.today();

// Manipulate dates
const nextWeek = today.addDays(7);
const yesterday = today.subtractDays(1);

// Compare dates
if (date1.isBefore(nextWeek)) {
console.log(`${date1.toString()} is before ${nextWeek.toString()}`);
}

// Convert back to native Date
const nativeDate = date1.toDate();
```
### In NestJS Dependency Injection
```ts
import { Injectable } from '@nestjs/common';
import { IClock, Clock } from '@nestjstools/clock';

@Injectable()
export class ReturnToday {
constructor(@Clock() private readonly clock: IClock) {}

todayIs(): string {
const today = this.clock.today();
return today.toString(); // output in format YYYY-MM-DD
}
}
```

## Benefits

* Avoid scattered use of `new Date()` in your business logic
Expand Down
8 changes: 4 additions & 4 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -43,11 +43,11 @@
"start:debug": "nest start --debug --watch",
"start:prod": "node dist/main",
"lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix",
"test": "jest",
"test": "npm run test:unit",
"test:watch": "jest --watch",
"test:cov": "jest --coverage",
"test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand",
"test:e2e": "jest --config ./test/jest-e2e.json"
"test:unit": "jest --config test/jest-unit.json"
},
"peerDependencies": {
"@nestjs/common": "^10.x||^11.x",
Expand All @@ -57,11 +57,11 @@
"rxjs": "^7.x"
},
"devDependencies": {
"@nestjs/common": "^11.0.0",
"@nestjs/core": "^11.0.0",
"@eslint/eslintrc": "^3.2.0",
"@eslint/js": "^9.18.0",
"@nestjs/cli": "^11.0.0",
"@nestjs/common": "^11.0.0",
"@nestjs/core": "^11.0.0",
"@nestjs/schematics": "^11.0.0",
"@nestjs/testing": "^11.0.1",
"@swc/cli": "^0.6.0",
Expand Down
1 change: 1 addition & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,3 +4,4 @@ export * from './dependency-injection/decorator';
export * from './service/i-clock';
export * from './service/fixed-clock';
export * from './service/system-clock';
export * from './value-object/calendar-date';
5 changes: 5 additions & 0 deletions src/service/fixed-clock.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { IClock } from './i-clock';
import { CalendarDate } from '../value-object/calendar-date';

export class FixedClock implements IClock {
constructor(private readonly date: Date) {
Expand All @@ -7,4 +8,8 @@ export class FixedClock implements IClock {
now(): Date {
return this.date;
}

today(): CalendarDate {
return CalendarDate.fromDate(this.date);
}
}
4 changes: 4 additions & 0 deletions src/service/i-clock.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,7 @@
import { CalendarDate } from '../value-object/calendar-date';

export interface IClock {
now(): Date;

today(): CalendarDate;
}
5 changes: 5 additions & 0 deletions src/service/system-clock.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,12 @@
import { IClock } from './i-clock';
import { CalendarDate } from '../value-object/calendar-date';

export class SystemClock implements IClock {
now(): Date {
return new Date();
}

today(): CalendarDate {
return CalendarDate.fromDate(new Date());
}
}
106 changes: 106 additions & 0 deletions src/value-object/calendar-date.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
export class CalendarDate {
private readonly year: number;
private readonly month: number;
private readonly day: number;

private constructor(year: number, month: number, day: number) {
if (!CalendarDate.isValidDate(year, month, day)) {
throw new Error('Invalid date');
}
this.year = year;
this.month = month;
this.day = day;
}

static fromString(dateString: string): CalendarDate {
if (!/^\d{4}-\d{2}-\d{2}$/.test(dateString)) {
throw new Error('Invalid date format');
}
const [year, month, day] = dateString.split('-').map(Number);
return new CalendarDate(year, month, day);
}

static today(): CalendarDate {
return CalendarDate.fromDate(new Date());
}

static fromDate(date: Date): CalendarDate {
const year = date.getFullYear();
const month = date.getMonth() + 1;
const day = date.getDate();
return new CalendarDate(year, month, day);
}

static isValidDate(year: number, month: number, day: number): boolean {
const d = new Date(year, month - 1, day);
return (
d.getFullYear() === year &&
d.getMonth() === month - 1 &&
d.getDate() === day
);
}

toString(): string {
const mm = String(this.month).padStart(2, '0');
const dd = String(this.day).padStart(2, '0');
return `${this.year}-${mm}-${dd}`;
}

getYear(): number {
return this.year;
}

getMonth(): number {
return this.month;
}

getDay(): number {
return this.day;
}

equals(other: CalendarDate): boolean {
return (
this.year === other.year &&
this.month === other.month &&
this.day === other.day
);
}

toDate(): Date {
return new Date(this.year, this.month - 1, this.day);
}

addDays(days: number): CalendarDate {
if (days < 0) {
throw new Error('days must be a positive number');
}
const date = this.toDate();
date.setDate(date.getDate() + days);
return CalendarDate.fromDate(date);
}

subtractDays(days: number): CalendarDate {
if (days < 0) {
throw new Error('days must be a positive number');
}
const date = this.toDate();
date.setDate(date.getDate() - days);
return CalendarDate.fromDate(date);
}

isBefore(other: CalendarDate): boolean {
return this.toDate() < other.toDate();
}

isAfter(other: CalendarDate): boolean {
return this.toDate() > other.toDate();
}

isSameOrBefore(other: CalendarDate): boolean {
return this.isBefore(other) || this.equals(other);
}

isSameOrAfter(other: CalendarDate): boolean {
return this.isAfter(other) || this.equals(other);
}
}
9 changes: 9 additions & 0 deletions test/jest-unit.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
{
"moduleFileExtensions": ["js", "json", "ts"],
"rootDir": ".",
"testEnvironment": "node",
"testRegex": ".spec.ts$",
"transform": {
"^.+\\.(t|j)s$": "ts-jest"
}
}
74 changes: 74 additions & 0 deletions test/unit/value-object/calendar-date.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
import { CalendarDate } from '../../../src/value-object/calendar-date';

describe('CalendarDate', () => {
it('creates instance from valid string', () => {
const date = CalendarDate.fromString('2025-06-14');
expect(date.getYear()).toBe(2025);
expect(date.getMonth()).toBe(6);
expect(date.getDay()).toBe(14);
expect(date.toString()).toBe('2025-06-14');
});

it('creates instance from Date object', () => {
const jsDate = new Date(2025, 5, 14); // June 14, 2025 (month zero-based)
const calendarDate = CalendarDate.fromDate(jsDate);
expect(calendarDate.toString()).toBe('2025-06-14');
});

it('today() returns today\'s date', () => {
const today = new Date();
const calendarToday = CalendarDate.today();
expect(calendarToday.getYear()).toBe(today.getFullYear());
expect(calendarToday.getMonth()).toBe(today.getMonth() + 1);
expect(calendarToday.getDay()).toBe(today.getDate());
});

it('equals method works correctly', () => {
const date1 = CalendarDate.fromString('2025-06-14');
const date2 = CalendarDate.fromDate(new Date(2025, 5, 14));
const date3 = CalendarDate.fromString('2025-06-15');
expect(date1.equals(date2)).toBe(true);
expect(date1.equals(date3)).toBe(false);
});

it('isValidDate returns correct results', () => {
expect(CalendarDate.isValidDate(2025, 6, 14)).toBe(true);
expect(CalendarDate.isValidDate(2025, 2, 29)).toBe(false);
expect(CalendarDate.isValidDate(2024, 2, 29)).toBe(true);
expect(CalendarDate.isValidDate(2025, 13, 1)).toBe(false);
expect(CalendarDate.isValidDate(2025, 0, 1)).toBe(false);
});

it('throws error for invalid date strings', () => {
expect(() => CalendarDate.fromString('2025-02-30')).toThrow('Invalid date');
expect(() => CalendarDate.fromString('2025-13-01')).toThrow('Invalid date');
expect(() => CalendarDate.fromString('2025-00-10')).toThrow('Invalid date');
expect(() => CalendarDate.fromString('invalid-string')).toThrow('Invalid date');
expect(() => CalendarDate.fromString('2025-6-5')).toThrow('Invalid date');
});

it('throws if addDays receives negative', () => {
expect(() => CalendarDate.fromString('2025-12-01').addDays(-1)).toThrow('days must be a positive number');
expect(() => CalendarDate.fromString('2025-12-01').subtractDays(-1)).toThrow('days must be a positive number');
});

it('addDays and subtractDays work correctly', () => {
const date = CalendarDate.fromString('2025-06-14');
expect(date.addDays(1).toString()).toBe('2025-06-15');
expect(date.subtractDays(1).toString()).toBe('2025-06-13');
expect(date.subtractDays(5).toString()).toBe('2025-06-09');
});

it('comparison methods work as expected', () => {
const d1 = CalendarDate.fromString('2025-06-14');
const d2 = CalendarDate.fromString('2025-06-15');
expect(d1.isBefore(d2)).toBe(true);
expect(d2.isBefore(d1)).toBe(false);
expect(d2.isAfter(d1)).toBe(true);
expect(d1.isAfter(d2)).toBe(false);
expect(d1.isSameOrBefore(d2)).toBe(true);
expect(d1.isSameOrBefore(d1)).toBe(true);
expect(d2.isSameOrAfter(d1)).toBe(true);
expect(d1.isSameOrAfter(d1)).toBe(true);
});
});
Loading