@dyadav0607/calendardate 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Dharamvir Kumar Yadav
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,107 @@
1
+ # @dyadav0607/calendardate
2
+
3
+ Calendar dates without a timezone — a year, a month, a day, and the arithmetic
4
+ that goes with them.
5
+
6
+ ```ts
7
+ import { parseCalendarDate, addMonths, formatIso } from '@dyadav0607/calendardate'
8
+
9
+ const released = parseCalendarDate('2026-08-19')
10
+ formatIso(addMonths(released, -6)) // '2026-02-19'
11
+ ```
12
+
13
+ No dependencies. Under 200 lines.
14
+
15
+ ```sh
16
+ npm install @dyadav0607/calendardate
17
+ ```
18
+
19
+ ## Why
20
+
21
+ A birthday is not an instant. Neither is a release date, a due date, or a public
22
+ holiday. "The 19th of August" is the 19th of August in Mumbai and in Texas, and
23
+ the moment you store it as a timestamp it stops being that.
24
+
25
+ ```js
26
+ new Date('2026-08-19').getDate()
27
+ // 19 in London
28
+ // 18 in Los Angeles
29
+ ```
30
+
31
+ That is a real bug, not a curiosity. It is why a film released on a Friday shows
32
+ up as Thursday for some readers, why a "today" filter drops a row after 5pm, and
33
+ why a report run in one office disagrees with the same report run in another.
34
+
35
+ `calendardate` has no instant to get wrong. A date is `{ year, month, day }` and
36
+ stays that way.
37
+
38
+ ## What it does
39
+
40
+ ```ts
41
+ // parse and format, ISO 8601
42
+ parseCalendarDate('2026-08-19') // { year: 2026, month: 8, day: 19 } | null
43
+ formatIso(date) // '2026-08-19'
44
+ isValidCalendarDate(value)
45
+
46
+ // arithmetic
47
+ addDays(date, 14)
48
+ addMonths(date, -6) // clamps: 31 Aug − 6 months → 28 Feb
49
+ daysInMonth(2026, 2)
50
+
51
+ // comparison
52
+ compareDates(a, b) // negative, zero, positive
53
+ datesEqual(a, b)
54
+ isWithin(date, start, end) // inclusive
55
+ isSameMonth(a, b)
56
+
57
+ // boundaries — weeks start Monday
58
+ startOfWeek(date)
59
+ endOfWeek(date)
60
+ startOfMonth(date)
61
+ endOfMonth(date)
62
+ isoWeekday(date) // 1 Monday … 7 Sunday
63
+
64
+ // the reader's own date
65
+ todayInLocalTime()
66
+ ```
67
+
68
+ ## The one impure function
69
+
70
+ `todayInLocalTime()` reads the system clock. Everything else is a pure function
71
+ of its arguments.
72
+
73
+ It is here because the alternative is worse: without it, every caller writes the
74
+ same conversion from `Date` to a calendar date, and gets it subtly wrong. It
75
+ takes an optional `Date` so a test can pass its own:
76
+
77
+ ```ts
78
+ todayInLocalTime(new Date('2026-08-19T23:30:00-05:00')) // { year: 2026, month: 8, day: 19 }
79
+ ```
80
+
81
+ ## What it is not
82
+
83
+ - **Not a time library.** No hours, no minutes, no timezones, no durations.
84
+ Reach for `Temporal` or `date-fns` when you need an instant.
85
+ - **Not localised.** Nothing here produces a word. Month names and formatting
86
+ belong to whatever is doing the displaying, which knows the language.
87
+ - **Not a `Date` replacement.** It is for the dates that were never instants in
88
+ the first place.
89
+
90
+ ## Should you use this?
91
+
92
+ `Temporal.PlainDate` is the same idea, in the language itself, and it is landing
93
+ in browsers. When you can rely on it, use it.
94
+
95
+ Until then, or if you want something small with no dependencies and no
96
+ polyfill, this does the job. It was extracted from a production site where the
97
+ timezone bug above was a real one.
98
+
99
+ ## Weeks start on Monday
100
+
101
+ `startOfWeek` and `isoWeekday` follow ISO 8601. There is no option to change it —
102
+ an option would mean every caller deciding, and every caller deciding is how two
103
+ parts of one system come to disagree about what week it is.
104
+
105
+ ## License
106
+
107
+ MIT
package/package.json ADDED
@@ -0,0 +1,39 @@
1
+ {
2
+ "name": "@dyadav0607/calendardate",
3
+ "private": false,
4
+ "version": "0.1.0",
5
+ "type": "module",
6
+ "main": "./src/index.ts",
7
+ "types": "./src/index.ts",
8
+ "exports": {
9
+ ".": "./src/index.ts"
10
+ },
11
+ "devDependencies": {
12
+ "vitest": "^4.1.10"
13
+ },
14
+ "description": "Calendar dates without a timezone \u2014 a year, a month, a day, and the arithmetic that goes with them.",
15
+ "license": "MIT",
16
+ "keywords": [
17
+ "date",
18
+ "calendar",
19
+ "timezone",
20
+ "plain-date",
21
+ "civil-date",
22
+ "iso-8601"
23
+ ],
24
+ "files": [
25
+ "src/calendar.ts",
26
+ "src/calendarDate.ts",
27
+ "src/index.ts",
28
+ "README.md",
29
+ "LICENSE"
30
+ ],
31
+ "sideEffects": false,
32
+ "scripts": {
33
+ "test": "vitest run",
34
+ "typecheck": "tsc --noEmit"
35
+ },
36
+ "publishConfig": {
37
+ "access": "public"
38
+ }
39
+ }
@@ -0,0 +1,142 @@
1
+ import type { CalendarDate } from './calendarDate'
2
+
3
+ /**
4
+ * Calendar arithmetic that never leaves the year/month/day frame.
5
+ *
6
+ * Every helper here that needs a JS `Date` constructs it with `Date.UTC` and
7
+ * reads it back with a `getUTC*` accessor. Mixing the two frames is what makes
8
+ * "2026-08-01" read back as July 31 west of UTC, which would put an entry in the
9
+ * wrong section for some viewers and no others.
10
+ */
11
+
12
+ const MS_PER_DAY = 86_400_000
13
+
14
+ function isPositiveInteger(value: unknown): value is number {
15
+ return typeof value === 'number' && Number.isInteger(value) && value > 0
16
+ }
17
+
18
+ /** Days in a month, honouring leap years. */
19
+ export function daysInMonth(year: number, month: number): number {
20
+ // Day 0 of the next month is the last day of this one. Constructed in UTC and
21
+ // read back in UTC, so no timezone shift can occur.
22
+ return new Date(Date.UTC(year, month, 0)).getUTCDate()
23
+ }
24
+
25
+ /**
26
+ * A calendar date is valid only if the day actually exists in that month, so
27
+ * 2026-02-30 is rejected rather than silently rolling over into March.
28
+ */
29
+ export function isValidCalendarDate(value: unknown): value is CalendarDate {
30
+ if (typeof value !== 'object' || value === null) return false
31
+
32
+ const { year, month, day } = value as Partial<CalendarDate>
33
+ if (!isPositiveInteger(year) || !isPositiveInteger(month) || !isPositiveInteger(day)) {
34
+ return false
35
+ }
36
+ if (month > 12) return false
37
+
38
+ return day <= daysInMonth(year, month)
39
+ }
40
+
41
+ /**
42
+ * Parses an ISO-style `YYYY-MM-DD` string by reading its digits directly.
43
+ *
44
+ * Deliberately does not use `new Date(string)`: that parses to a UTC instant,
45
+ * and any later local-time read shifts the day.
46
+ */
47
+ export function parseCalendarDate(value: string): CalendarDate | null {
48
+ const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(value.trim())
49
+ if (!match) return null
50
+
51
+ const year = Number(match[1])
52
+ const month = Number(match[2])
53
+ const day = Number(match[3])
54
+
55
+ if (month < 1 || month > 12) return null
56
+ if (day < 1 || day > daysInMonth(year, month)) return null
57
+
58
+ return { year, month, day }
59
+ }
60
+
61
+ /** Formats a calendar date back to `YYYY-MM-DD`. */
62
+ export function formatIso(date: CalendarDate): string {
63
+ const month = String(date.month).padStart(2, '0')
64
+ const day = String(date.day).padStart(2, '0')
65
+ return `${date.year}-${month}-${day}`
66
+ }
67
+
68
+ /** Negative when `a` is earlier, positive when later, zero when equal. */
69
+ export function compareDates(a: CalendarDate, b: CalendarDate): number {
70
+ if (a.year !== b.year) return a.year - b.year
71
+ if (a.month !== b.month) return a.month - b.month
72
+ return a.day - b.day
73
+ }
74
+
75
+ export function datesEqual(a: CalendarDate, b: CalendarDate): boolean {
76
+ return compareDates(a, b) === 0
77
+ }
78
+
79
+ /** True when `date` falls within `start`..`end`, both inclusive. */
80
+ export function isWithin(date: CalendarDate, start: CalendarDate, end: CalendarDate): boolean {
81
+ return compareDates(date, start) >= 0 && compareDates(date, end) <= 0
82
+ }
83
+
84
+ /** True when both dates fall in the same calendar month of the same year. */
85
+ export function isSameMonth(a: CalendarDate, b: CalendarDate): boolean {
86
+ return a.year === b.year && a.month === b.month
87
+ }
88
+
89
+ /** Day of the week, 1 = Monday through 7 = Sunday. */
90
+ export function isoWeekday(date: CalendarDate): number {
91
+ // getUTCDay gives 0 = Sunday; shift so Monday is 1 and Sunday is 7.
92
+ const sundayBased = new Date(Date.UTC(date.year, date.month - 1, date.day)).getUTCDay()
93
+ return sundayBased === 0 ? 7 : sundayBased
94
+ }
95
+
96
+ /** Moves `date` by `days`, which may be negative. Crosses month and year ends. */
97
+ export function addDays(date: CalendarDate, days: number): CalendarDate {
98
+ const shifted = new Date(Date.UTC(date.year, date.month - 1, date.day) + days * MS_PER_DAY)
99
+ return {
100
+ year: shifted.getUTCFullYear(),
101
+ month: shifted.getUTCMonth() + 1,
102
+ day: shifted.getUTCDate(),
103
+ }
104
+ }
105
+
106
+ /**
107
+ * Moves `date` by `months`, clamping the day to the target month's length so
108
+ * that adding a month to January 31 gives the last day of February rather than
109
+ * rolling into March.
110
+ */
111
+ export function addMonths(date: CalendarDate, months: number): CalendarDate {
112
+ const zeroBased = date.year * 12 + (date.month - 1) + months
113
+ const year = Math.floor(zeroBased / 12)
114
+ const month = (zeroBased % 12) + 1
115
+
116
+ return { year, month, day: Math.min(date.day, daysInMonth(year, month)) }
117
+ }
118
+
119
+ /** The Monday of the week containing `date`. */
120
+ export function startOfWeek(date: CalendarDate): CalendarDate {
121
+ return addDays(date, -(isoWeekday(date) - 1))
122
+ }
123
+
124
+ /** The Sunday of the week containing `date`. */
125
+ export function endOfWeek(date: CalendarDate): CalendarDate {
126
+ return addDays(date, 7 - isoWeekday(date))
127
+ }
128
+
129
+ export function startOfMonth(date: CalendarDate): CalendarDate {
130
+ return { year: date.year, month: date.month, day: 1 }
131
+ }
132
+
133
+ export function endOfMonth(date: CalendarDate): CalendarDate {
134
+ return { year: date.year, month: date.month, day: daysInMonth(date.year, date.month) }
135
+ }
136
+
137
+ /** Reads today's date from the system clock in the viewer's local timezone. */
138
+ export function todayInLocalTime(now: Date = new Date()): CalendarDate {
139
+ // Local accessors throughout: the viewer's own calendar day is what "today"
140
+ // means here, so this is the one place local time is intentional.
141
+ return { year: now.getFullYear(), month: now.getMonth() + 1, day: now.getDate() }
142
+ }
@@ -0,0 +1,20 @@
1
+ /**
2
+ * A calendar date with no time-of-day and no timezone.
3
+ *
4
+ * Release dates are calendar dates, not instants: an entry dated 2026-08-01 must
5
+ * group as 2026-08-01 for every viewer regardless of their timezone. Representing
6
+ * dates as year/month/day fields keeps them out of the JS `Date` type, where
7
+ * parsing "2026-08-01" yields a UTC midnight instant that reads back as July 31
8
+ * anywhere west of UTC.
9
+ *
10
+ * Lives in `shared/` because nothing about it is film-specific: any feature
11
+ * dealing in dates — watchlists, reminders — wants the same representation.
12
+ */
13
+ export interface CalendarDate {
14
+ /** Four-digit year. */
15
+ readonly year: number
16
+ /** 1-12, January is 1. */
17
+ readonly month: number
18
+ /** 1-31. */
19
+ readonly day: number
20
+ }
package/src/index.ts ADDED
@@ -0,0 +1,31 @@
1
+ /**
2
+ * Calendar dates without a timezone.
3
+ *
4
+ * A release date is not an instant. "Released on 19 August" is true in Mumbai
5
+ * and in Texas, and representing it as a timestamp makes it drift by a day
6
+ * depending on where the reader is. Everything here works on a plain
7
+ * `{ year, month, day }` instead.
8
+ *
9
+ * No dependencies, no words, no policy — only arithmetic and parsing.
10
+ */
11
+
12
+ export type { CalendarDate } from './calendarDate'
13
+
14
+ export {
15
+ addDays,
16
+ addMonths,
17
+ compareDates,
18
+ datesEqual,
19
+ daysInMonth,
20
+ endOfMonth,
21
+ endOfWeek,
22
+ formatIso,
23
+ isoWeekday,
24
+ isSameMonth,
25
+ isValidCalendarDate,
26
+ isWithin,
27
+ parseCalendarDate,
28
+ startOfMonth,
29
+ startOfWeek,
30
+ todayInLocalTime,
31
+ } from './calendar'