From 107fcc165e8b087fe7175aba848be09316f046f4 Mon Sep 17 00:00:00 2001
From: Tyler <26290074+tylersayshi@users.noreply.github.com>
Date: Fri, 25 Oct 2024 23:44:16 -0700
Subject: [PATCH] feat: Add parseAsISODate (#707)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

* feat: Add parseAsISODate

* fix: Comments from review

* fix: Remove GMT

Co-authored-by: François Best <github@francoisbest.com>

* fix: Query keys

---------

Co-authored-by: Tyler <26290074+thegitduck@users.noreply.github.com>
Co-authored-by: Tyler <tyler@secondspectrum.com>
Co-authored-by: François Best <github@francoisbest.com>
---
 .../docs/content/docs/parsers/built-in.mdx    | 67 +++++++++++--------
 packages/docs/content/docs/parsers/demos.tsx  | 27 ++++++--
 packages/nuqs/src/parsers.test.ts             |  9 +++
 packages/nuqs/src/parsers.ts                  | 19 ++++++
 4 files changed, 90 insertions(+), 32 deletions(-)

diff --git a/packages/docs/content/docs/parsers/built-in.mdx b/packages/docs/content/docs/parsers/built-in.mdx
index 3afb76185..9dda246b9 100644
--- a/packages/docs/content/docs/parsers/built-in.mdx
+++ b/packages/docs/content/docs/parsers/built-in.mdx
@@ -12,6 +12,7 @@ import {
   BooleanParserDemo,
   StringLiteralParserDemo,
   DateISOParserDemo,
+  DatetimeISOParserDemo,
   DateTimestampParserDemo,
   JsonParserDemo
 } from '@/content/docs/parsers/demos'
@@ -29,8 +30,8 @@ This is where **parsers** come in.
 import { parseAsString } from 'nuqs'
 ```
 
-<Suspense fallback={<DemoFallback/>}>
-  <StringParserDemo/>
+<Suspense fallback={<DemoFallback />}>
+  <StringParserDemo />
 </Suspense>
 
 <Callout title="Type-safety tip">
@@ -39,6 +40,7 @@ and will accept **any** value.
 
 If you're expecting a certain set of string values, like `'foo' | 'bar'{:ts}`,
 see [Literals](#literals) for ensuring type-runtime safety.
+
 </Callout>
 
 If search params are strings by default, what's the point of this _"parser"_ ?
@@ -67,11 +69,10 @@ import { parseAsInteger } from 'nuqs'
 useQueryState('int', parseAsInteger.withDefault(0))
 ```
 
-<Suspense fallback={<DemoFallback/>}>
-  <IntegerParserDemo/>
+<Suspense fallback={<DemoFallback />}>
+  <IntegerParserDemo />
 </Suspense>
 
-
 ### Floating point
 
 Same as integer, but uses `parseFloat` under the hood.
@@ -82,11 +83,10 @@ import { parseAsFloat } from 'nuqs'
 useQueryState('float', parseAsFloat.withDefault(0))
 ```
 
-<Suspense fallback={<DemoFallback/>}>
-  <FloatParserDemo/>
+<Suspense fallback={<DemoFallback />}>
+  <FloatParserDemo />
 </Suspense>
 
-
 ### Hexadecimal
 
 Encodes integers in hexadecimal.
@@ -97,12 +97,12 @@ import { parseAsHex } from 'nuqs'
 useQueryState('hex', parseAsHex.withDefault(0x00))
 ```
 
-<Suspense fallback={<DemoFallback/>}>
-  <HexParserDemo/>
+<Suspense fallback={<DemoFallback />}>
+  <HexParserDemo />
 </Suspense>
 
 <Callout title="Going further">
- Check out the [Hex Colors](/playground/hex-colors) playground for a demo.
+  Check out the [Hex Colors](/playground/hex-colors) playground for a demo.
 </Callout>
 
 ## Boolean
@@ -113,8 +113,8 @@ import { parseAsBoolean } from 'nuqs'
 useQueryState('bool', parseAsBoolean.withDefault(false))
 ```
 
-<Suspense fallback={<DemoFallback/>}>
-  <BooleanParserDemo/>
+<Suspense fallback={<DemoFallback />}>
+  <BooleanParserDemo />
 </Suspense>
 
 ## Literals
@@ -138,14 +138,13 @@ const sortOrder = ['asc', 'desc'] as const
 parseAsStringLiteral(sortOrder)
 
 // Optional: extract the type from them
-type SortOrder = (typeof sortOrder)[number]; // 'asc' | 'desc'
+type SortOrder = (typeof sortOrder)[number] // 'asc' | 'desc'
 ```
 
-<Suspense fallback={<DemoFallback/>}>
-  <StringLiteralParserDemo/>
+<Suspense fallback={<DemoFallback />}>
+  <StringLiteralParserDemo />
 </Suspense>
 
-
 ### Numeric literals
 
 ```ts /as const/
@@ -174,8 +173,8 @@ parseAsStringEnum<Direction>(Object.values(Direction))
 ```
 
 <Callout title="Note">
-The query string value will be the **value** of the enum, not its name
-(here: `?direction=UP`).
+  The query string value will be the **value** of the enum, not its name (here:
+  `?direction=UP`).
 </Callout>
 
 ## Dates & timestamps
@@ -183,14 +182,26 @@ The query string value will be the **value** of the enum, not its name
 There are two parsers that give you a `Date` object, their difference is
 on how they encode the value into the query string.
 
-### ISO 8601
+### ISO 8601 Datetime
 
 ```ts
 import { parseAsIsoDateTime } from 'nuqs'
 ```
 
 <Suspense>
-  <DateISOParserDemo/>
+  <DatetimeISOParserDemo />
+</Suspense>
+
+### ISO 8601 Date
+
+Note: the Date is parsed without the time zone offset, making it at GMT 00:00:00 UTC.
+
+```ts
+import { parseAsIsoDate } from 'nuqs'
+```
+
+<Suspense>
+  <DateISOParserDemo />
 </Suspense>
 
 ### Timestamp
@@ -202,7 +213,7 @@ import { parseAsTimestamp } from 'nuqs'
 ```
 
 <Suspense>
-  <DateTimestampParserDemo/>
+  <DateTimestampParserDemo />
 </Suspense>
 
 ## Arrays
@@ -241,14 +252,14 @@ const schema = z.object({
 const [json, setJson] = useQueryState('json', parseAsJson(schema.parse))
 
 setJson({
-  pkg: "nuqs",
+  pkg: 'nuqs',
   version: 2,
-  worksWith: ["Next.js", "React", "Remix", "React Router", "and more"],
-});
+  worksWith: ['Next.js', 'React', 'Remix', 'React Router', 'and more']
+})
 ```
 
 <Suspense>
-  <JsonParserDemo/>
+  <JsonParserDemo />
 </Suspense>
 
 Using other validation libraries is possible, as long as they throw an error
@@ -264,6 +275,6 @@ import { parseAsString } from 'nuqs/server'
 ```
 
 <Callout title="Note">
-It used to be available under the alias import `nuqs/parsers`,
-which will be dropped in the next major version.
+  It used to be available under the alias import `nuqs/parsers`, which will be
+  dropped in the next major version.
 </Callout>
diff --git a/packages/docs/content/docs/parsers/demos.tsx b/packages/docs/content/docs/parsers/demos.tsx
index fab14bcd6..2429926d5 100644
--- a/packages/docs/content/docs/parsers/demos.tsx
+++ b/packages/docs/content/docs/parsers/demos.tsx
@@ -14,6 +14,7 @@ import {
   parseAsFloat,
   parseAsHex,
   parseAsInteger,
+  parseAsIsoDate,
   parseAsIsoDateTime,
   parseAsJson,
   parseAsStringLiteral,
@@ -246,10 +247,12 @@ export function StringLiteralParserDemo() {
 
 export function DateParserDemo({
   queryKey,
-  parser
+  parser,
+  type
 }: {
   queryKey: string
   parser: ParserBuilder<Date>
+  type: 'date' | 'datetime-local'
 }) {
   const [value, setValue] = useQueryState(queryKey, parser)
   return (
@@ -257,7 +260,7 @@ export function DateParserDemo({
       <div className="flex w-full flex-col items-stretch gap-2 @md:flex-row">
         <div className="flex flex-1 items-center gap-2">
           <input
-            type="datetime-local"
+            type={type}
             className="flex h-10 flex-[2] rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
             value={value === null ? '' : value.toISOString().slice(0, -8)}
             onChange={e => {
@@ -290,12 +293,28 @@ export function DateParserDemo({
   )
 }
 
+export function DatetimeISOParserDemo() {
+  return (
+    <DateParserDemo
+      type="datetime-local"
+      queryKey="iso"
+      parser={parseAsIsoDateTime}
+    />
+  )
+}
+
 export function DateISOParserDemo() {
-  return <DateParserDemo queryKey="iso" parser={parseAsIsoDateTime} />
+  return <DateParserDemo type="date" queryKey="date" parser={parseAsIsoDate} />
 }
 
 export function DateTimestampParserDemo() {
-  return <DateParserDemo queryKey="ts" parser={parseAsTimestamp} />
+  return (
+    <DateParserDemo
+      type="datetime-local"
+      queryKey="ts"
+      parser={parseAsTimestamp}
+    />
+  )
 }
 
 const jsonParserSchema = z.object({
diff --git a/packages/nuqs/src/parsers.test.ts b/packages/nuqs/src/parsers.test.ts
index e2fb37ce3..74d9d3d0c 100644
--- a/packages/nuqs/src/parsers.test.ts
+++ b/packages/nuqs/src/parsers.test.ts
@@ -5,6 +5,7 @@ import {
   parseAsHex,
   parseAsInteger,
   parseAsIsoDateTime,
+  parseAsIsoDate,
   parseAsString,
   parseAsTimestamp
 } from './parsers'
@@ -48,6 +49,14 @@ describe('parsers', () => {
       ref
     )
   })
+  test('parseAsIsoDate', () => {
+    expect(parseAsIsoDate.parse('')).toBeNull()
+    expect(parseAsIsoDate.parse('not-a-date')).toBeNull()
+    const moment = '2020-01-01'
+    const ref = new Date(moment)
+    expect(parseAsIsoDate.parse(moment)).toStrictEqual(ref)
+    expect(parseAsIsoDate.serialize(ref)).toEqual(moment)
+  })
   test('parseAsArrayOf', () => {
     const parser = parseAsArrayOf(parseAsString)
     expect(parser.serialize([])).toBe('')
diff --git a/packages/nuqs/src/parsers.ts b/packages/nuqs/src/parsers.ts
index 22d45cc14..82973a601 100644
--- a/packages/nuqs/src/parsers.ts
+++ b/packages/nuqs/src/parsers.ts
@@ -215,6 +215,25 @@ export const parseAsIsoDateTime = createParser({
   serialize: (v: Date) => v.toISOString()
 })
 
+/**
+ * Querystring encoded as an ISO-8601 string (UTC)
+ * without the time zone offset, and returned as
+ * a Date object.
+ *
+ * The Date is parsed without the time zone offset,
+ * making it at 00:00:00 UTC.
+ */
+export const parseAsIsoDate = createParser({
+  parse: v => {
+    const date = new Date(v.slice(0, 10))
+    if (Number.isNaN(date.valueOf())) {
+      return null
+    }
+    return date
+  },
+  serialize: (v: Date) => v.toISOString().slice(0, 10)
+})
+
 /**
  * String-based enums provide better type-safety for known sets of values.
  * You will need to pass the parseAsStringEnum function a list of your enum values