Repository: VTuber-Rally/stamp-rally-app
Pull request #50: Ajout KeyValue et passage de la détection de fin d'évent dedans
Author: login: luclu7
Created: 2025-03-15T22:48:16Z | Merged: 2025-03-27T10:29:11Z

Resolves #46 and #49 

Linked issue #46: Key-Value sur Appwrite storage
On a souvent besoin d'une simple base Clé-Valeur, ça serait pas mal de bricoler une table pour faire office

Diff:
diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml
index aab6cbf8..22ba77c9 100644
--- a/.github/workflows/lint.yml
+++ b/.github/workflows/lint.yml
@@ -88,6 +88,7 @@ jobs:
           VITE_SUBMIT_FUNCTION_ID: example
           VITE_WHEEL_COLLECTION_ID: example
           VITE_GET_PRIVATE_KEY_FUNCTION_ID: example
+          VITE_KV_COLLECTION_ID: example
           VITE_ASSETS_BUCKET_ID: example
         run: pnpm --filter web build
 
diff --git a/appwrite.json b/appwrite.json
index 501e64d3..d05ebcda 100644
--- a/appwrite.json
+++ b/appwrite.json
@@ -349,6 +349,45 @@
                 }
             ],
             "indexes": []
+        },
+        {
+            "$id": "67d54ef600021e18818a",
+            "$permissions": [],
+            "databaseId": "6675f377000709b0db07",
+            "name": "key-value",
+            "enabled": true,
+            "documentSecurity": true,
+            "attributes": [
+                {
+                    "key": "key",
+                    "type": "string",
+                    "required": true,
+                    "array": false,
+                    "size": 256,
+                    "default": null
+                },
+                {
+                    "key": "value",
+                    "type": "string",
+                    "required": true,
+                    "array": false,
+                    "size": 1024,
+                    "default": null
+                }
+            ],
+            "indexes": [
+                {
+                    "key": "index_1",
+                    "type": "key",
+                    "status": "available",
+                    "attributes": [
+                        "key"
+                    ],
+                    "orders": [
+                        "ASC"
+                    ]
+                }
+            ]
         }
     ],
     "buckets": [
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 5b7b1d27..61311ef9 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -46,8 +46,11 @@ importers:
         specifier: ^16.4.5
         version: 16.4.7
       node-appwrite:
-        specifier: ^13.0.0
-        version: 13.0.0
+        specifier: ^15.0.1
+        version: 15.0.1
+      zod:
+        specifier: ^3.24.1
+        version: 3.24.1
     devDependencies:
       '@types/node':
         specifier: ^20.14.9
@@ -3784,12 +3787,12 @@ packages:
   natural-compare@1.4.0:
     resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==}
 
-  node-appwrite@13.0.0:
-    resolution: {integrity: sha512-O1g1RWcRYcvnVvR0Dykjoe/4UcL6ELVvZosb4B8/hhNvleuWfN2fOFxzeCaU8amGW6UtVtZqvNaUT3IF1mPBdA==}
-
   node-appwrite@14.1.0:
     resolution: {integrity: sha512-kuKAZrdaAcGYOMUXtxNb1j+uIy+FIMiiU1dFkgwTXLsMLeLvC6HJ8/FH/kN9JyrWR2a2zcGN7gWfyQgWYoLMTA==}
 
+  node-appwrite@15.0.1:
+    resolution: {integrity: sha512-vewchNVfgxJ1wHmdN2BRSHwoO+trfqKZf5Uq5wXfCHpnVtAtoO2amh8o5mwIfquP6qbZ+DMV25ZXp8MZYxPO7A==}
+
   node-fetch-native-with-agent@1.7.2:
     resolution: {integrity: sha512-5MaOOCuJEvcckoz7/tjdx1M6OusOY6Xc5f459IaruGStWnKzlI1qpNgaAwmn4LmFYcsSlj+jBMk84wmmRxfk5g==}
 
@@ -8722,11 +8725,11 @@ snapshots:
 
   natural-compare@1.4.0: {}
 
-  node-appwrite@13.0.0:
+  node-appwrite@14.1.0:
     dependencies:
       node-fetch-native-with-agent: 1.7.2
 
-  node-appwrite@14.1.0:
+  node-appwrite@15.0.1:
     dependencies:
       node-fetch-native-with-agent: 1.7.2
 
diff --git a/scripts/.env.sample b/scripts/.env.sample
index 7c7e0843..4dbf43b4 100644
--- a/scripts/.env.sample
+++ b/scripts/.env.sample
@@ -1,6 +1,6 @@
 APPWRITE_API_KEY=abc123
 APPWRITE_ENDPOINT=http://localhost/v1
 APPWRITE_PROJECT_ID=abc123
-PROFILE_DATABASE_ID=abc123
-PROFILE_COLLECTION_ID=abc123
+DATABASE_ID=abc123
+STANDISTS_COLLECTION_ID=abc123
 BUCKET_ID="rally-assets"
\ No newline at end of file
diff --git a/scripts/create-profile.tsx b/scripts/create-profile.tsx
index b050841c..6235b12b 100644
--- a/scripts/create-profile.tsx
+++ b/scripts/create-profile.tsx
@@ -22,8 +22,8 @@ const generatePassword = () => randomBytes(32).toString("base64").slice(0, 32);
 const {
   APPWRITE_PROJECT_ID,
   APPWRITE_ENDPOINT,
-  PROFILE_DATABASE_ID,
-  PROFILE_COLLECTION_ID,
+  DATABASE_ID,
+  STANDISTS_COLLECTION_ID,
   APPWRITE_API_KEY,
 } = getEnv();
 
@@ -177,8 +177,8 @@ async function createProfilesAndDocuments() {
     .filter((profile) => profile !== null)
     .map((profile) => {
       return database.createDocument(
-        PROFILE_DATABASE_ID,
-        PROFILE_COLLECTION_ID,
+        DATABASE_ID,
+        STANDISTS_COLLECTION_ID,
         sdk.ID.unique(),
         profile,
         [
diff --git a/scripts/delete-users-test.tsx b/scripts/delete-users-test.tsx
index 41df928d..459e6fcf 100644
--- a/scripts/delete-users-test.tsx
+++ b/scripts/delete-users-test.tsx
@@ -6,8 +6,8 @@ import { deleteUserMedia } from "./upload-user-medias.js";
 const {
   APPWRITE_PROJECT_ID,
   APPWRITE_ENDPOINT,
-  PROFILE_DATABASE_ID,
-  PROFILE_COLLECTION_ID,
+  DATABASE_ID,
+  STANDISTS_COLLECTION_ID,
   APPWRITE_API_KEY,
 } = getEnv();
 
@@ -27,15 +27,15 @@ console.log(
 
 const promises = usersExisting.users.map((user) => {
   return database
-    .listDocuments(PROFILE_DATABASE_ID, PROFILE_COLLECTION_ID, [
+    .listDocuments(DATABASE_ID, STANDISTS_COLLECTION_ID, [
       sdk.Query.equal("userId", user.$id),
     ])
     .then((documents) => {
       const documentDeletionPromises = documents.documents.map((document) => {
         return Promise.all([
           database.deleteDocument(
-            PROFILE_DATABASE_ID,
-            PROFILE_COLLECTION_ID,
+            DATABASE_ID,
+            STANDISTS_COLLECTION_ID,
             document.$id,
           ),
           deleteUserMedia(document.image),
diff --git a/scripts/kv.ts b/scripts/kv.ts
new file mode 100644
index 00000000..b6d953c3
--- /dev/null
+++ b/scripts/kv.ts
@@ -0,0 +1,141 @@
+import { Client, Databases, ID, Query } from "node-appwrite";
+
+import { KV_LIST } from "./kvList.js";
+import { getEnv } from "./shared.js";
+
+const env = getEnv();
+
+const client = new Client()
+  .setEndpoint(env.APPWRITE_ENDPOINT)
+  .setProject(env.APPWRITE_PROJECT_ID)
+  .setKey(env.APPWRITE_API_KEY);
+
+const database = new Databases(client);
+
+class KV {
+  constructor(private db: Databases) {}
+
+  async listEntries() {
+    const docs = await this.db.listDocuments(
+      env.DATABASE_ID,
+      env.KV_COLLECTION_ID,
+    );
+
+    return docs.documents.map((doc) => ({
+      key: doc.key,
+      value: doc.value,
+    }));
+  }
+
+  async getEntry(key: string) {
+    const doc = await this.db.listDocuments(
+      env.DATABASE_ID,
+      env.KV_COLLECTION_ID,
+      [Query.equal("key", key)],
+    );
+
+    return doc.documents[0];
+  }
+
+  async createEntry(key: string, value: string) {
+    const existingEntry = await this.getEntry(key);
+
+    if (existingEntry) {
+      throw new Error("Entry already exists");
+    }
+
+    return this.db.createDocument(
+      env.DATABASE_ID,
+      env.KV_COLLECTION_ID,
+      ID.unique(),
+      { key, value },
+    );
+  }
+
+  async updateEntry(key: string, value: string) {
+    const existingEntry = await this.getEntry(key);
+
+    if (!existingEntry) {
+      throw new Error("Entry does not exist");
+    }
+
+    return this.db.updateDocument(
+      env.DATABASE_ID,
+      env.KV_COLLECTION_ID,
+      existingEntry.$id,
+      { key, value },
+    );
+  }
+
+  async deleteEntry(key: string) {
+    const existingEntry = await this.getEntry(key);
+
+    if (!existingEntry) {
+      throw new Error("Entry does not exist");
+    }
+
+    return this.db.deleteDocument(
+      env.DATABASE_ID,
+      env.KV_COLLECTION_ID,
+      existingEntry.$id,
+    );
+  }
+
+  async createOrUpdateEntry(key: string, value: string) {
+    const existingEntry = await this.getEntry(key);
+
+    if (existingEntry) {
+      await this.updateEntry(key, value);
+    } else {
+      await this.createEntry(key, value);
+    }
+  }
+}
+
+const kv = new KV(database);
+
+switch (process.argv[2]) {
+  case "--list":
+    const entries = await kv.listEntries();
+    console.table(entries);
+    break;
+  case "--create":
+    if (process.argv[3] && process.argv[4]) {
+      await kv.createEntry(process.argv[3], process.argv[4]);
+    } else {
+      console.error("Usage: pnpm run kv --create <key> <value>");
+    }
+    break;
+  case "--import":
+    const entriesBeforeImport = await kv.listEntries();
+    console.log("Before import");
+    console.table(entriesBeforeImport);
+    for (const keyVal of KV_LIST) {
+      await kv.createOrUpdateEntry(keyVal.key, keyVal.value);
+    }
+    const entriesAfterImport = await kv.listEntries();
+    console.log("After import");
+    console.table(entriesAfterImport);
+    break;
+  case "--delete":
+    if (process.argv[3]) {
+      await kv.deleteEntry(process.argv[3]);
+    } else {
+      console.error("Usage: pnpm run kv --delete <key>");
+    }
+    break;
+  case "--update":
+    if (process.argv[3] && process.argv[4]) {
+      await kv.updateEntry(process.argv[3], process.argv[4]);
+    } else {
+      console.error("Usage: pnpm run kv --update <key> <value>");
+    }
+    break;
+
+  default:
+    console.log("Usage: pnpm run kv --list");
+    console.log("Usage: pnpm run kv --create <key> <value>");
+    console.log("Usage: pnpm run kv --import");
+    console.log("Usage: pnpm run kv --delete <key>");
+    console.log("Usage: pnpm run kv --update <key> <value>");
+}
diff --git a/scripts/kvList.ts b/scripts/kvList.ts
new file mode 100644
index 00000000..651d6373
--- /dev/null
+++ b/scripts/kvList.ts
@@ -0,0 +1,11 @@
+type KV = {
+  key: string;
+  value: string;
+};
+
+export const KV_LIST: KV[] = [
+  {
+    key: "eventEndDate",
+    value: "2025-07-14T22:07:04.945Z",
+  },
+] as const;
diff --git a/scripts/package.json b/scripts/package.json
index ea76489f..99962d3b 100644
--- a/scripts/package.json
+++ b/scripts/package.json
@@ -6,9 +6,13 @@
   "type": "module",
   "keywords": [],
   "author": "",
+  "scripts": {
+    "kv": "bun run kv.ts"
+  },
   "dependencies": {
     "dotenv": "^16.4.5",
-    "node-appwrite": "^13.0.0"
+    "node-appwrite": "^15.0.1",
+    "zod": "^3.24.1"
   },
   "devDependencies": {
     "@types/node": "^20.14.9",
diff --git a/scripts/shared.ts b/scripts/shared.ts
index dc31ac26..26f65883 100644
--- a/scripts/shared.ts
+++ b/scripts/shared.ts
@@ -1,41 +1,36 @@
 import "dotenv/config";
+import { z } from "zod";
+
+const envSchema = z.object({
+  APPWRITE_API_KEY: z.string(),
+  APPWRITE_PROJECT_ID: z.string(),
+  APPWRITE_ENDPOINT: z.string(),
+  DATABASE_ID: z.string(),
+  STANDISTS_COLLECTION_ID: z.string(),
+  KV_COLLECTION_ID: z.string(),
+  BUCKET_ID: z.string(),
+});
 
 export const getEnv = () => {
   const {
     APPWRITE_API_KEY,
     APPWRITE_PROJECT_ID,
     APPWRITE_ENDPOINT,
-    PROFILE_DATABASE_ID,
-    PROFILE_COLLECTION_ID,
+    DATABASE_ID,
+    STANDISTS_COLLECTION_ID,
+    KV_COLLECTION_ID,
     BUCKET_ID,
   } = process.env;
 
-  if (!APPWRITE_ENDPOINT) {
-    throw new Error("APPWRITE_ENDPOINT is not set");
-  }
-
-  if (!APPWRITE_API_KEY) {
-    throw new Error("APPWRITE_API_KEY is not set");
-  }
-
-  if (!APPWRITE_PROJECT_ID) {
-    throw new Error("APPWRITE_PROJECT_ID is not set");
-  }
-
-  if (!PROFILE_DATABASE_ID || !PROFILE_COLLECTION_ID) {
-    throw new Error("PROFILE_DATABASE_ID or PROFILE_COLLECTION_ID is not set");
-  }
-
-  if (!BUCKET_ID) {
-    throw new Error("BUCKET_ID is not set");
-  }
-
-  return {
+  const env = envSchema.parse({
     APPWRITE_API_KEY,
     APPWRITE_PROJECT_ID,
     APPWRITE_ENDPOINT,
-    PROFILE_DATABASE_ID,
-    PROFILE_COLLECTION_ID,
+    DATABASE_ID,
+    STANDISTS_COLLECTION_ID,
+    KV_COLLECTION_ID,
     BUCKET_ID,
-  };
+  });
+
+  return env;
 };
diff --git a/web/.env.sample b/web/.env.sample
index 39f53a91..e9bc2bd0 100644
--- a/web/.env.sample
+++ b/web/.env.sample
@@ -16,11 +16,11 @@ VITE_SUBMISSIONS_COLLECTION_ID=abc123
 VITE_WHEEL_COLLECTION_ID=abc123
 VITE_SUBMIT_FUNCTION_ID=abc123
 VITE_GET_PRIVATE_KEY_FUNCTION_ID=abc123
+VITE_KV_COLLECTION_ID=abc123
 VITE_ASSETS_BUCKET_ID=rally-assets
 
 # Rally config
 VITE_STAMPS_TO_COLLECT=10
-VITE_EVENT_END_DATE=2024-07-14T18:00:00Z
 
 VITE_PLAUSIBLE_DOMAIN="localhost"
 VITE_PLAUSIBLE_HOST="https://plausible.io"
\ No newline at end of file
diff --git a/web/src/App.tsx b/web/src/App.tsx
index 5768c929..34bd2662 100644
--- a/web/src/App.tsx
+++ b/web/src/App.tsx
@@ -7,16 +7,20 @@ import { useRegisterSW } from "virtual:pwa-register/react";
 
 import { ToastAction } from "@/components/toasts/Toast.tsx";
 import { QRDrawerContextProvider } from "@/contexts/QRDrawerContextProvider.tsx";
-import { eventEndDate } from "@/lib/consts.ts";
+import { KEY_VALUES } from "@/lib/KeyValues.ts";
+import { useKeyValue } from "@/lib/hooks/useKeyValue";
 import { useToast } from "@/lib/hooks/useToast.ts";
 import { queryClient } from "@/lib/queryClient.ts";
 import { router } from "@/router.tsx";
 
-const LIMIT_DATE = new Date(eventEndDate);
-
-export function App() {
+const AppWrapped = () => {
   const { t } = useTranslation();
   const { toast } = useToast();
+  const { value: eventEndDate } = useKeyValue(KEY_VALUES.eventEndDate);
+
+  const [isEventFinishedScreenClosed, setIsEventFinishedScreenClosed] =
+    useState(false);
+  const isEventFinished = !!eventEndDate && new Date() > eventEndDate;
 
   const { updateServiceWorker } = useRegisterSW({
     onOfflineReady() {
@@ -57,10 +61,6 @@ export function App() {
     },
   });
 
-  const [isEventFinished, setIsEventFinished] = useState(
-    new Date() > LIMIT_DATE,
-  );
-
   const ExpiredBlock = (
     <div className={"flex h-dvh flex-col pb-16"}>
       <div className="flex grow items-center justify-center">
@@ -70,7 +70,9 @@ export function App() {
 
           <hr className={"my-4 w-1/2"} />
           <button
-            onClick={() => setIsEventFinished(false)}
+            onClick={() => {
+              setIsEventFinishedScreenClosed(true);
+            }}
             className="text-gray-800 opacity-40"
           >
             {t("ignore")}
@@ -80,17 +82,23 @@ export function App() {
     </div>
   );
 
+  return (
+    <div className="min-h-dvh md:mx-auto md:max-w-md">
+      {isEventFinished && !isEventFinishedScreenClosed ? (
+        ExpiredBlock
+      ) : (
+        <RouterProvider router={router} />
+      )}
+    </div>
+  );
+};
+
+export function App() {
   return (
     <StrictMode>
       <QueryClientProvider client={queryClient}>
         <QRDrawerContextProvider>
-          <div className="min-h-dvh md:mx-auto md:max-w-md">
-            {isEventFinished ? (
-              ExpiredBlock
-            ) : (
-              <RouterProvider router={router} />
-            )}
-          </div>
+          <AppWrapped />
           <ReactQueryDevtools initialIsOpen={false} />
         </QRDrawerContextProvider>
       </QueryClientProvider>
diff --git a/web/src/lib/KeyValues.ts b/web/src/lib/KeyValues.ts
new file mode 100644
index 00000000..58e159dd
--- /dev/null
+++ b/web/src/lib/KeyValues.ts
@@ -0,0 +1,8 @@
+import { KeyValueEntry } from "./models/KeyValue";
+
+export const KEY_VALUES = {
+  eventEndDate: {
+    key: "eventEndDate",
+    transform: (value: string) => new Date(value),
+  } satisfies KeyValueEntry<Date>,
+} as const satisfies Record<string, KeyValueEntry<unknown>>;
diff --git a/web/src/lib/QueryKeys.ts b/web/src/lib/QueryKeys.ts
index d5ad65ed..b15d692d 100644
--- a/web/src/lib/QueryKeys.ts
+++ b/web/src/lib/QueryKeys.ts
@@ -9,4 +9,5 @@ export const QUERY_KEYS = {
   SHOW_INTRO: "SHOW_INTRO",
   STAFF_GEN_QRCODE: "STAFF_GEN_QRCODE",
   ARTIST_IMAGE: "ARTIST_IMAGE",
+  KV: "KV",
 };
diff --git a/web/src/lib/consts.ts b/web/src/lib/consts.ts
index 47f69084..9886d352 100644
--- a/web/src/lib/consts.ts
+++ b/web/src/lib/consts.ts
@@ -5,12 +5,12 @@ const {
   VITE_APPWRITE_ENDPOINT: appwriteEndpoint,
   VITE_APPWRITE_PROJECT_ID: appwriteProjectId,
   VITE_DATABASE_ID: databaseId,
-  VITE_EVENT_END_DATE: eventEndDate,
   VITE_STANDISTS_COLLECTION_ID: standistsCollectionId,
   VITE_SUBMISSIONS_COLLECTION_ID: submissionsCollectionId,
   VITE_SUBMIT_FUNCTION_ID: submitFunctionId,
   VITE_WHEEL_COLLECTION_ID: wheelCollectionId,
   VITE_GET_PRIVATE_KEY_FUNCTION_ID: getPrivateKeyFunctionId,
+  VITE_KV_COLLECTION_ID: keyValueCollectionId,
   VITE_ASSETS_BUCKET_ID: assetsBucketId,
   VITE_STAMPS_TO_COLLECT,
 } = import.meta.env;
@@ -27,11 +27,11 @@ export {
   appwriteEndpoint,
   appwriteProjectId,
   databaseId,
-  eventEndDate,
   standistsCollectionId,
   submissionsCollectionId,
   submitFunctionId,
   wheelCollectionId,
+  keyValueCollectionId,
   getPrivateKeyFunctionId,
   assetsBucketId,
   stampsToCollect,
diff --git a/web/src/lib/hooks/useDatabase.ts b/web/src/lib/hooks/useDatabase.ts
index a86cabd1..cf5f8328 100644
--- a/web/src/lib/hooks/useDatabase.ts
+++ b/web/src/lib/hooks/useDatabase.ts
@@ -6,11 +6,13 @@ import { QUERY_KEYS } from "@/lib/QueryKeys.ts";
 import { Query, databases } from "@/lib/appwrite.ts";
 import {
   databaseId,
+  keyValueCollectionId,
   standistsCollectionId,
   submissionsCollectionId,
   wheelCollectionId,
 } from "@/lib/consts.ts";
 import { db } from "@/lib/db.ts";
+import { KeyValue } from "@/lib/models/KeyValue";
 import { Standist } from "@/lib/models/Standist.ts";
 import { Submission as SubmissionIndexedDB } from "@/lib/models/Submission.ts";
 
@@ -107,6 +109,20 @@ export const useDatabase = () => {
     }));
   };
 
+  const getKeyValue = async (key: string) => {
+    const doc = await databases.listDocuments<KeyValue>(
+      databaseId,
+      keyValueCollectionId,
+      [Query.equal("key", key)],
+    );
+
+    if (doc.documents.length === 0) {
+      throw new Error("Key not found");
+    }
+
+    return doc.documents[0];
+  };
+
   const findAndUpdateProfile = async (
     userId: string,
     profileData: StandistsEditProfileForm,
@@ -135,6 +151,7 @@ export const useDatabase = () => {
     getSubmission,
     getOwnSubmissions,
     getWheelEntries,
+    getKeyValue,
     findAndUpdateProfile,
   };
 };
diff --git a/web/src/lib/hooks/useKeyValue.ts b/web/src/lib/hooks/useKeyValue.ts
new file mode 100644
index 00000000..0373bda9
--- /dev/null
+++ b/web/src/lib/hooks/useKeyValue.ts
@@ -0,0 +1,20 @@
+import { useQuery } from "@tanstack/react-query";
+
+import { QUERY_KEYS } from "../QueryKeys";
+import { KeyValueEntry } from "../models/KeyValue";
+import { useDatabase } from "./useDatabase";
+
+export const useKeyValue = <T>(keyValueEntry: KeyValueEntry<T>) => {
+  const { getKeyValue } = useDatabase();
+
+  const { data: value, isPending } = useQuery({
+    queryKey: [QUERY_KEYS.KV, keyValueEntry.key],
+    queryFn: () =>
+      getKeyValue(keyValueEntry.key).then((data) =>
+        keyValueEntry.transform(data.value),
+      ),
+    staleTime: Infinity,
+  });
+
+  return { value: value as T | undefined, isPending };
+};
diff --git a/web/src/lib/models/KeyValue.ts b/web/src/lib/models/KeyValue.ts
new file mode 100644
index 00000000..0b3eb97d
--- /dev/null
+++ b/web/src/lib/models/KeyValue.ts
@@ -0,0 +1,11 @@
+import { Models } from "appwrite";
+
+export interface KeyValue extends Models.Document {
+  key: string;
+  value: string;
+}
+
+export type KeyValueEntry<T> = {
+  key: string;
+  transform: (value: string) => T;
+};
diff --git a/web/src/vite-env.d.ts b/web/src/vite-env.d.ts
index 5db94db1..a40652b3 100644
--- a/web/src/vite-env.d.ts
+++ b/web/src/vite-env.d.ts
@@ -13,6 +13,7 @@ interface ImportMetaEnv {
   VITE_SUBMISSIONS_COLLECTION_ID: string;
   VITE_WHEEL_COLLECTION_ID: string;
   VITE_GET_PRIVATE_KEY_FUNCTION_ID: string;
+  VITE_KV_COLLECTION_ID: string;
   VITE_ASSETS_BUCKET_ID: string;
 
   VITE_STAMPS_TO_COLLECT: string;
diff --git a/web/vite.config.ts b/web/vite.config.ts
index 7b674083..39df0342 100644
--- a/web/vite.config.ts
+++ b/web/vite.config.ts
@@ -36,6 +36,7 @@ export default defineConfig({
           VITE_SUBMIT_FUNCTION_ID: Schema.string(),
           VITE_WHEEL_COLLECTION_ID: Schema.string(),
           VITE_GET_PRIVATE_KEY_FUNCTION_ID: Schema.string(),
+          VITE_KV_COLLECTION_ID: Schema.string(),
           VITE_ASSETS_BUCKET_ID: Schema.string(),
         },
       }),


Reviews:
user: login: leonekmi
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-16T20:04:43Z
user: login: luclu7
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-16T20:10:03Z
user: login: leonekmi
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-16T20:12:31Z
user: login: luclu7
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-16T20:23:36Z
user: login: leonekmi
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-16T20:24:26Z
user: login: luclu7
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-16T20:24:46Z
user: login: luclu7
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-16T20:25:17Z
user: login: leonekmi
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-16T20:25:55Z
user: login: leonekmi
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-16T20:26:24Z
user: login: luclu7
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-16T20:29:54Z
user: login: leonekmi
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-27T08:29:36Z
user: login: luclu7
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-27T08:45:29Z
user: login: leonekmi
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-03-27T10:03:28Z

Review comments:
diff_hunk: @@ -57,9 +59,11 @@ export function App() {
     },
   });
 
-  const [isEventFinished, setIsEventFinished] = useState(
-    new Date() > LIMIT_DATE,
-  );
+  useEffect(() => {
+    if (eventEndDate) {
+      setIsEventFinished(new Date() > eventEndDate);
path: web/src/App.tsx
user: login: leonekmi
body: plz un state `isEventFinishedClosed` et un `new Date() > eventEndDate && !isEventFinishedClosed` :pray: 
created_at: 2025-03-16T20:02:25Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,9 @@
+import { KeyValueEntry } from "./models/KeyValue";
+
+// eslint-disable-next-line @typescript-eslint/no-explicit-any
+export const KEY_VALUES: Record<string, KeyValueEntry<any>> = {
path: web/src/lib/KeyValues.ts
user: login: leonekmi
body: si tu mets un `as const` tu peux pas bénéficier d'inférence ?
created_at: 2025-03-16T20:04:35Z
author_association: CONTRIBUTOR
diff_hunk: @@ -57,9 +59,11 @@ export function App() {
     },
   });
 
-  const [isEventFinished, setIsEventFinished] = useState(
-    new Date() > LIMIT_DATE,
-  );
+  useEffect(() => {
+    if (eventEndDate) {
+      setIsEventFinished(new Date() > eventEndDate);
path: web/src/App.tsx
user: login: luclu7
body: J'avais justement fait à peu près ça avant, mais je trouvais pas terrible d'avoir deux useState, mais si tu préfère ça me va très bien
<img width="628" alt="image" src="https://github.com/user-attachments/assets/77fc5ff3-0117-4682-bc86-393c0f73c62a" />
created_at: 2025-03-16T20:10:03Z
author_association: MEMBER
diff_hunk: @@ -57,9 +59,11 @@ export function App() {
     },
   });
 
-  const [isEventFinished, setIsEventFinished] = useState(
-    new Date() > LIMIT_DATE,
-  );
+  useEffect(() => {
+    if (eventEndDate) {
+      setIsEventFinished(new Date() > eventEndDate);
path: web/src/App.tsx
user: login: leonekmi
body: ah non mais le but c'est de téj le useEffect., tu mets pas dans le state le new Date() > eventEndDate, dans un const au rendu, ça coûte moins cher et ça fera pas une boucle de rendu quand Tanstack te donne l'info.
created_at: 2025-03-16T20:12:31Z
author_association: CONTRIBUTOR
diff_hunk: @@ -57,9 +59,11 @@ export function App() {
     },
   });
 
-  const [isEventFinished, setIsEventFinished] = useState(
-    new Date() > LIMIT_DATE,
-  );
+  useEffect(() => {
+    if (eventEndDate) {
+      setIsEventFinished(new Date() > eventEndDate);
path: web/src/App.tsx
user: login: luclu7
body: Ouais t'a raison. Créer un Date à chaque rendu me fait un peu tilter, mais c'est les habitudes d'autres environnement que le web

created_at: 2025-03-16T20:23:36Z
author_association: MEMBER
diff_hunk: @@ -57,9 +59,11 @@ export function App() {
     },
   });
 
-  const [isEventFinished, setIsEventFinished] = useState(
-    new Date() > LIMIT_DATE,
-  );
+  useEffect(() => {
+    if (eventEndDate) {
+      setIsEventFinished(new Date() > eventEndDate);
path: web/src/App.tsx
user: login: leonekmi
body: si on avait une bonne api web................................
created_at: 2025-03-16T20:24:26Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,9 @@
+import { KeyValueEntry } from "./models/KeyValue";
+
+// eslint-disable-next-line @typescript-eslint/no-explicit-any
+export const KEY_VALUES: Record<string, KeyValueEntry<any>> = {
path: web/src/lib/KeyValues.ts
user: login: luclu7
body: J'ai pas l'impression, mais c'est une bonne idée de le mettre
created_at: 2025-03-16T20:24:46Z
author_association: MEMBER
diff_hunk: @@ -57,9 +59,11 @@ export function App() {
     },
   });
 
-  const [isEventFinished, setIsEventFinished] = useState(
-    new Date() > LIMIT_DATE,
-  );
+  useEffect(() => {
+    if (eventEndDate) {
+      setIsEventFinished(new Date() > eventEndDate);
path: web/src/App.tsx
user: login: luclu7
body: ~~on refait tout en Angular?~~
created_at: 2025-03-16T20:25:17Z
author_association: MEMBER
diff_hunk: @@ -57,9 +59,11 @@ export function App() {
     },
   });
 
-  const [isEventFinished, setIsEventFinished] = useState(
-    new Date() > LIMIT_DATE,
-  );
+  useEffect(() => {
+    if (eventEndDate) {
+      setIsEventFinished(new Date() > eventEndDate);
path: web/src/App.tsx
user: login: leonekmi
body: alors c'est pas ce que je...
created_at: 2025-03-16T20:25:55Z
author_association: CONTRIBUTOR
diff_hunk: @@ -70,7 +69,9 @@ export function App() {
 
           <hr className={"my-4 w-1/2"} />
           <button
-            onClick={() => setIsEventFinished(false)}
+            onClick={() => {
+              setIsEventFinishedClosed(false);
path: web/src/App.tsx
user: login: leonekmi
body: oups ?
created_at: 2025-03-16T20:26:24Z
author_association: CONTRIBUTOR
diff_hunk: @@ -70,7 +69,9 @@ export function App() {
 
           <hr className={"my-4 w-1/2"} />
           <button
-            onClick={() => setIsEventFinished(false)}
+            onClick={() => {
+              setIsEventFinishedClosed(false);
path: web/src/App.tsx
user: login: luclu7
body: 💤 
created_at: 2025-03-16T20:29:54Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,9 @@
+import { KeyValueEntry } from "./models/KeyValue";
+
+// eslint-disable-next-line @typescript-eslint/no-explicit-any
+export const KEY_VALUES: Record<string, KeyValueEntry<any>> = {
path: web/src/lib/KeyValues.ts
user: login: leonekmi
body: ça m'empêchait de dormir la nuit alors j'ai fait ceci : 

```typescript
import { KeyValueEntry } from "./models/KeyValue";

export const KEY_VALUES = {
  eventEndDate: {
    key: "eventEndDate",
    transform: (value: string) => new Date(value),
  } satisfies KeyValueEntry<Date>,
} as const satisfies Record<string, KeyValueEntry<unknown>>;

```

Cette structure permet de garder une validation forte, et du coup de l'inférence côté `useKeyValue` !
created_at: 2025-03-27T08:29:36Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,9 @@
+import { KeyValueEntry } from "./models/KeyValue";
+
+// eslint-disable-next-line @typescript-eslint/no-explicit-any
+export const KEY_VALUES: Record<string, KeyValueEntry<any>> = {
path: web/src/lib/KeyValues.ts
user: login: luclu7
body: Je t'aime Leon
created_at: 2025-03-27T08:45:29Z
author_association: MEMBER

PR comments:
user: login: netlify[bot]
created_at: 2025-03-15T22:48:37Z
body: ### <span aria-hidden="true">✅</span> Deploy Preview for *stamp-rally-devel* ready!


|  Name | Link |
|:-:|------------------------|
|<span aria-hidden="true">🔨</span> Latest commit | efc298e3495cb72fdfd50b053dae6ee69d8bb08b |
|<span aria-hidden="true">🔍</span> Latest deploy log | https://app.netlify.com/sites/stamp-rally-devel/deploys/67e512086a2ac50009246f62 |
|<span aria-hidden="true">😎</span> Deploy Preview | [https://deploy-preview-50--stamp-rally-devel.netlify.app](https://deploy-preview-50--stamp-rally-devel.netlify.app) |
|<span aria-hidden="true">📱</span> Preview on mobile | <details><summary> Toggle QR Code... </summary><br /><br />![QR Code](https://app.netlify.com/qr-code/eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJ1cmwiOiJodHRwczovL2RlcGxveS1wcmV2aWV3LTUwLS1zdGFtcC1yYWxseS1kZXZlbC5uZXRsaWZ5LmFwcCJ9.AWpVLgmngsUZ3BkqXuPm6N6SKggMWig13T5ZusCAILo)<br /><br />_Use your smartphone camera to open QR code link._</details> |
---
<!-- [stamp-rally-devel Preview](https://deploy-preview-50--stamp-rally-devel.netlify.app) -->
_To edit notification comments on pull requests, go to your [Netlify site configuration](https://app.netlify.com/sites/stamp-rally-devel/configuration/notifications#deploy-webhooks)._
author_association: NONE
user: login: cloudflare-workers-and-pages[bot]
created_at: 2025-03-15T22:52:31Z
body: ## Deploying sedeto-vtube-stamp-rally with &nbsp;<a href="https://pages.dev"><img alt="Cloudflare Pages" src="https://user-images.githubusercontent.com/23264/106598434-9e719e00-654f-11eb-9e59-6167043cfa01.png" width="16"></a> &nbsp;Cloudflare Pages

<table><tr><td><strong>Latest commit:</strong> </td><td>
<code>1070ad8</code>
</td></tr>
<tr><td><strong>Status:</strong></td><td>&nbsp;✅&nbsp; Deploy successful!</td></tr>
<tr><td><strong>Preview URL:</strong></td><td>
<a href='https://e4f45837.sedeto-vtube-stamp-rally.pages.dev'>https://e4f45837.sedeto-vtube-stamp-rally.pages.dev</a>
</td></tr>
<tr><td><strong>Branch Preview URL:</strong></td><td>
<a href='https://kv-event-date.sedeto-vtube-stamp-rally.pages.dev'>https://kv-event-date.sedeto-vtube-stamp-rally.pages.dev</a>
</td></tr>
</table>

[View logs](https://dash.cloudflare.com/5d09cfe4e8924d7271ed948a1479acb8/pages/view/sedeto-vtube-stamp-rally/e4f45837-b578-4916-8754-3a1d9af0739d)

author_association: NONE