feat(frontend): scaffold — Tailwind v4, routing, API layer, UserContext
- tailwindcss + @tailwindcss/vite, react-router-dom, date-fns, zod installed - vite.config.ts: Tailwind v4 Vite plugin wired in - index.css: single @import "tailwindcss" (zero-config v4) - main.tsx: wrapped in <BrowserRouter>; imports index.css - App.tsx: layout shell with <TopBar/> + <Routes> (/, /weekly, /monthly, /projects) - lib/api.ts: Zod schemas for all API types + typed fetch wrapper; all responses validated before field access (per CLAUDE.md rule) - lib/format.ts: minutesToHmm, hmmToMinutes, centsToEur, ISO week helpers, year-month helpers, formatDate - lib/types.ts: z.infer<> re-exports from api.ts - state/UserContext.tsx: selected user persisted in localStorage; auto- selects first user on load Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Generated
+690
@@ -8,12 +8,15 @@
|
|||||||
"name": "frontend",
|
"name": "frontend",
|
||||||
"version": "0.0.1",
|
"version": "0.0.1",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"date-fns": "^4.1.0",
|
||||||
"react": "^19.0.0",
|
"react": "^19.0.0",
|
||||||
"react-dom": "^19.0.0",
|
"react-dom": "^19.0.0",
|
||||||
|
"react-router-dom": "^7.15.1",
|
||||||
"zod": "^3.23.8"
|
"zod": "^3.23.8"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@biomejs/biome": "^1.9.4",
|
"@biomejs/biome": "^1.9.4",
|
||||||
|
"@tailwindcss/vite": "^4.3.0",
|
||||||
"@testing-library/jest-dom": "^6.6.3",
|
"@testing-library/jest-dom": "^6.6.3",
|
||||||
"@testing-library/react": "^16.1.0",
|
"@testing-library/react": "^16.1.0",
|
||||||
"@testing-library/user-event": "^14.5.2",
|
"@testing-library/user-event": "^14.5.2",
|
||||||
@@ -21,6 +24,7 @@
|
|||||||
"@types/react-dom": "^19.0.2",
|
"@types/react-dom": "^19.0.2",
|
||||||
"@vitejs/plugin-react": "^4.3.4",
|
"@vitejs/plugin-react": "^4.3.4",
|
||||||
"jsdom": "^25.0.1",
|
"jsdom": "^25.0.1",
|
||||||
|
"tailwindcss": "^4.3.0",
|
||||||
"typescript": "~5.7.2",
|
"typescript": "~5.7.2",
|
||||||
"vite": "^6.0.5",
|
"vite": "^6.0.5",
|
||||||
"vitest": "^2.1.8"
|
"vitest": "^2.1.8"
|
||||||
@@ -1525,6 +1529,290 @@
|
|||||||
"win32"
|
"win32"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
"node_modules/@tailwindcss/node": {
|
||||||
|
"version": "4.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.3.0.tgz",
|
||||||
|
"integrity": "sha512-aFb4gUhFOgdh9AXo4IzBEOzBkkAxm9VigwDJnMIYv3lcfXCJVesNfbEaBl4BNgVRyid92AmdviqwBUBRKSeY3g==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@jridgewell/remapping": "^2.3.5",
|
||||||
|
"enhanced-resolve": "^5.21.0",
|
||||||
|
"jiti": "^2.6.1",
|
||||||
|
"lightningcss": "1.32.0",
|
||||||
|
"magic-string": "^0.30.21",
|
||||||
|
"source-map-js": "^1.2.1",
|
||||||
|
"tailwindcss": "4.3.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tailwindcss/oxide": {
|
||||||
|
"version": "4.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.3.0.tgz",
|
||||||
|
"integrity": "sha512-F7HZGBeN9I0/AuuJS5PwcD8xayx5ri5GhjYUDBEVYUkexyA/giwbDNjRVrxSezE3T250OU2K/wp/ltWx3UOefg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 20"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"@tailwindcss/oxide-android-arm64": "4.3.0",
|
||||||
|
"@tailwindcss/oxide-darwin-arm64": "4.3.0",
|
||||||
|
"@tailwindcss/oxide-darwin-x64": "4.3.0",
|
||||||
|
"@tailwindcss/oxide-freebsd-x64": "4.3.0",
|
||||||
|
"@tailwindcss/oxide-linux-arm-gnueabihf": "4.3.0",
|
||||||
|
"@tailwindcss/oxide-linux-arm64-gnu": "4.3.0",
|
||||||
|
"@tailwindcss/oxide-linux-arm64-musl": "4.3.0",
|
||||||
|
"@tailwindcss/oxide-linux-x64-gnu": "4.3.0",
|
||||||
|
"@tailwindcss/oxide-linux-x64-musl": "4.3.0",
|
||||||
|
"@tailwindcss/oxide-wasm32-wasi": "4.3.0",
|
||||||
|
"@tailwindcss/oxide-win32-arm64-msvc": "4.3.0",
|
||||||
|
"@tailwindcss/oxide-win32-x64-msvc": "4.3.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tailwindcss/oxide-android-arm64": {
|
||||||
|
"version": "4.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.3.0.tgz",
|
||||||
|
"integrity": "sha512-TJPiq67tKlLuObP6RkwvVGDoxCMBVtDgKkLfa/uyj7/FyxvQwHS+UOnVrXXgbEsfUaMgiVvC4KbJnRr26ho4Ng==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"android"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 20"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tailwindcss/oxide-darwin-arm64": {
|
||||||
|
"version": "4.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.3.0.tgz",
|
||||||
|
"integrity": "sha512-oMN/WZRb+SO37BmUElEgeEWuU8E/HXRkiODxJxLe1UTHVXLrdVSgfaJV7pSlhRGMSOiXLuxTIjfsF3wYvz8cgQ==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 20"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tailwindcss/oxide-darwin-x64": {
|
||||||
|
"version": "4.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.3.0.tgz",
|
||||||
|
"integrity": "sha512-N6CUmu4a6bKVADfw77p+iw6Yd9Q3OBhe0veaDX+QazfuVYlQsHfDgxBrsjQ/IW+zywL8mTrNd0SdJT/zgtvMdA==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 20"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tailwindcss/oxide-freebsd-x64": {
|
||||||
|
"version": "4.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.3.0.tgz",
|
||||||
|
"integrity": "sha512-zDL5hBkQdH5C6MpqbK3gQAgP80tsMwSI26vjOzjJtNCMUo0lFgOItzHKBIupOZNQxt3ouPH7RPhvNhiTfCe5CQ==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"freebsd"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 20"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": {
|
||||||
|
"version": "4.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.3.0.tgz",
|
||||||
|
"integrity": "sha512-R06HdNi7A7OEoMsf6d4tjZ71RCWnZQPHj2mnotSFURjNLdBC+cIgXQ7l81CqeoiQftjf6OOblxXMInMgN2VzMA==",
|
||||||
|
"cpu": [
|
||||||
|
"arm"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 20"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tailwindcss/oxide-linux-arm64-gnu": {
|
||||||
|
"version": "4.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.3.0.tgz",
|
||||||
|
"integrity": "sha512-qTJHELX8jetjhRQHCLilkVLmybpzNQAtaI/gaoVoidn/ufbNDbAo8KlK2J+yPoc8wQxvDxCmh/5lr8nC1+lTbg==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"libc": [
|
||||||
|
"glibc"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 20"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tailwindcss/oxide-linux-arm64-musl": {
|
||||||
|
"version": "4.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.3.0.tgz",
|
||||||
|
"integrity": "sha512-Z6sukiQsngnWO+l39X4pPbiWT81IC+PLKF+PHxIlyZbGNb9MODfYlXEVlFvej5BOZInWX01kVyzeLvHsXhfczQ==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"libc": [
|
||||||
|
"musl"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 20"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tailwindcss/oxide-linux-x64-gnu": {
|
||||||
|
"version": "4.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.3.0.tgz",
|
||||||
|
"integrity": "sha512-DRNdQRpSGzRGfARVuVkxvM8Q12nh19l4BF/G7zGA1oe+9wcC6saFBHTISrpIcKzhiXtSrlSrluCfvMuledoCTQ==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"libc": [
|
||||||
|
"glibc"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 20"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tailwindcss/oxide-linux-x64-musl": {
|
||||||
|
"version": "4.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.3.0.tgz",
|
||||||
|
"integrity": "sha512-Z0IADbDo8bh6I7h2IQMx601AdXBLfFpEdUotft86evd/8ZPflZe9COPO8Q1vw+pfLWIUo9zN/JGZvwuAJqduqg==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"libc": [
|
||||||
|
"musl"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 20"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tailwindcss/oxide-wasm32-wasi": {
|
||||||
|
"version": "4.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.3.0.tgz",
|
||||||
|
"integrity": "sha512-HNZGOUxEmElksYR7S6sC5jTeNGpobAsy9u7Gu0AskJ8/20FR9GqebUyB+HBcU/ax6BHuiuJi+Oda4B+YX6H1yA==",
|
||||||
|
"bundleDependencies": [
|
||||||
|
"@napi-rs/wasm-runtime",
|
||||||
|
"@emnapi/core",
|
||||||
|
"@emnapi/runtime",
|
||||||
|
"@tybys/wasm-util",
|
||||||
|
"@emnapi/wasi-threads",
|
||||||
|
"tslib"
|
||||||
|
],
|
||||||
|
"cpu": [
|
||||||
|
"wasm32"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"dependencies": {
|
||||||
|
"@emnapi/core": "^1.10.0",
|
||||||
|
"@emnapi/runtime": "^1.10.0",
|
||||||
|
"@emnapi/wasi-threads": "^1.2.1",
|
||||||
|
"@napi-rs/wasm-runtime": "^1.1.4",
|
||||||
|
"@tybys/wasm-util": "^0.10.1",
|
||||||
|
"tslib": "^2.8.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=14.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tailwindcss/oxide-win32-arm64-msvc": {
|
||||||
|
"version": "4.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.3.0.tgz",
|
||||||
|
"integrity": "sha512-Pe+RPVTi1T+qymuuRpcdvwSVZjnll/f7n8gBxMMh3xLTctMDKqpdfGimbMyioqtLhUYZxdJ9wGNhV7MKHvgZsQ==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"win32"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 20"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tailwindcss/oxide-win32-x64-msvc": {
|
||||||
|
"version": "4.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.3.0.tgz",
|
||||||
|
"integrity": "sha512-Mvrf2kXW/yeW/OTezZlCGOirXRcUuLIBx/5Y12BaPM7wJoryG6dfS/NJL8aBPqtTEx/Vm4T4vKzFUcKDT+TKUA==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"win32"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 20"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tailwindcss/vite": {
|
||||||
|
"version": "4.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.3.0.tgz",
|
||||||
|
"integrity": "sha512-t6J3OrB5Fc0ExuhohouH0fWUGMYL6PTLhW+E7zIk/pdbnJARZDCwjBznFnkh5ynRnIRSI4YjtTH0t6USjJISrw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@tailwindcss/node": "4.3.0",
|
||||||
|
"@tailwindcss/oxide": "4.3.0",
|
||||||
|
"tailwindcss": "4.3.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"vite": "^5.2.0 || ^6 || ^7 || ^8"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@testing-library/dom": {
|
"node_modules/@testing-library/dom": {
|
||||||
"version": "10.4.1",
|
"version": "10.4.1",
|
||||||
"resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz",
|
"resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz",
|
||||||
@@ -2003,6 +2291,19 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/cookie": {
|
||||||
|
"version": "1.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz",
|
||||||
|
"integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/express"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/css.escape": {
|
"node_modules/css.escape": {
|
||||||
"version": "1.5.1",
|
"version": "1.5.1",
|
||||||
"resolved": "https://registry.npmjs.org/css.escape/-/css.escape-1.5.1.tgz",
|
"resolved": "https://registry.npmjs.org/css.escape/-/css.escape-1.5.1.tgz",
|
||||||
@@ -2052,6 +2353,16 @@
|
|||||||
"node": ">=18"
|
"node": ">=18"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/date-fns": {
|
||||||
|
"version": "4.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.1.0.tgz",
|
||||||
|
"integrity": "sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/kossnocorp"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/debug": {
|
"node_modules/debug": {
|
||||||
"version": "4.4.3",
|
"version": "4.4.3",
|
||||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
||||||
@@ -2107,6 +2418,16 @@
|
|||||||
"node": ">=6"
|
"node": ">=6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/detect-libc": {
|
||||||
|
"version": "2.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
|
||||||
|
"integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/dom-accessibility-api": {
|
"node_modules/dom-accessibility-api": {
|
||||||
"version": "0.5.16",
|
"version": "0.5.16",
|
||||||
"resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz",
|
"resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz",
|
||||||
@@ -2137,6 +2458,20 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
|
"node_modules/enhanced-resolve": {
|
||||||
|
"version": "5.21.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.21.3.tgz",
|
||||||
|
"integrity": "sha512-QyL119InA+XXEkNLNTPCXPugSvOfhwv0JOlGNzvxs0hZaiHLNvXSpudUWsOlsXGWJh8G6ckCScEkVHfX3kw/2Q==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"graceful-fs": "^4.2.4",
|
||||||
|
"tapable": "^2.3.3"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10.13.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/entities": {
|
"node_modules/entities": {
|
||||||
"version": "6.0.1",
|
"version": "6.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz",
|
||||||
@@ -2400,6 +2735,13 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/graceful-fs": {
|
||||||
|
"version": "4.2.11",
|
||||||
|
"resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz",
|
||||||
|
"integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "ISC"
|
||||||
|
},
|
||||||
"node_modules/has-symbols": {
|
"node_modules/has-symbols": {
|
||||||
"version": "1.1.0",
|
"version": "1.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
|
||||||
@@ -2513,6 +2855,16 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/jiti": {
|
||||||
|
"version": "2.7.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz",
|
||||||
|
"integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"bin": {
|
||||||
|
"jiti": "lib/jiti-cli.mjs"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/js-tokens": {
|
"node_modules/js-tokens": {
|
||||||
"version": "4.0.0",
|
"version": "4.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
|
||||||
@@ -2587,6 +2939,279 @@
|
|||||||
"node": ">=6"
|
"node": ">=6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/lightningcss": {
|
||||||
|
"version": "1.32.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz",
|
||||||
|
"integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MPL-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"detect-libc": "^2.0.3"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 12.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/parcel"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"lightningcss-android-arm64": "1.32.0",
|
||||||
|
"lightningcss-darwin-arm64": "1.32.0",
|
||||||
|
"lightningcss-darwin-x64": "1.32.0",
|
||||||
|
"lightningcss-freebsd-x64": "1.32.0",
|
||||||
|
"lightningcss-linux-arm-gnueabihf": "1.32.0",
|
||||||
|
"lightningcss-linux-arm64-gnu": "1.32.0",
|
||||||
|
"lightningcss-linux-arm64-musl": "1.32.0",
|
||||||
|
"lightningcss-linux-x64-gnu": "1.32.0",
|
||||||
|
"lightningcss-linux-x64-musl": "1.32.0",
|
||||||
|
"lightningcss-win32-arm64-msvc": "1.32.0",
|
||||||
|
"lightningcss-win32-x64-msvc": "1.32.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/lightningcss-android-arm64": {
|
||||||
|
"version": "1.32.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz",
|
||||||
|
"integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MPL-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"android"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 12.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/parcel"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/lightningcss-darwin-arm64": {
|
||||||
|
"version": "1.32.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz",
|
||||||
|
"integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MPL-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 12.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/parcel"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/lightningcss-darwin-x64": {
|
||||||
|
"version": "1.32.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz",
|
||||||
|
"integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MPL-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 12.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/parcel"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/lightningcss-freebsd-x64": {
|
||||||
|
"version": "1.32.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz",
|
||||||
|
"integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MPL-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"freebsd"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 12.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/parcel"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/lightningcss-linux-arm-gnueabihf": {
|
||||||
|
"version": "1.32.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz",
|
||||||
|
"integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==",
|
||||||
|
"cpu": [
|
||||||
|
"arm"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MPL-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 12.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/parcel"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/lightningcss-linux-arm64-gnu": {
|
||||||
|
"version": "1.32.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz",
|
||||||
|
"integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"libc": [
|
||||||
|
"glibc"
|
||||||
|
],
|
||||||
|
"license": "MPL-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 12.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/parcel"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/lightningcss-linux-arm64-musl": {
|
||||||
|
"version": "1.32.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz",
|
||||||
|
"integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"libc": [
|
||||||
|
"musl"
|
||||||
|
],
|
||||||
|
"license": "MPL-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 12.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/parcel"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/lightningcss-linux-x64-gnu": {
|
||||||
|
"version": "1.32.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz",
|
||||||
|
"integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"libc": [
|
||||||
|
"glibc"
|
||||||
|
],
|
||||||
|
"license": "MPL-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 12.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/parcel"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/lightningcss-linux-x64-musl": {
|
||||||
|
"version": "1.32.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz",
|
||||||
|
"integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"libc": [
|
||||||
|
"musl"
|
||||||
|
],
|
||||||
|
"license": "MPL-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 12.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/parcel"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/lightningcss-win32-arm64-msvc": {
|
||||||
|
"version": "1.32.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz",
|
||||||
|
"integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MPL-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"win32"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 12.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/parcel"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/lightningcss-win32-x64-msvc": {
|
||||||
|
"version": "1.32.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz",
|
||||||
|
"integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MPL-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"win32"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 12.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/parcel"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/loupe": {
|
"node_modules/loupe": {
|
||||||
"version": "3.2.1",
|
"version": "3.2.1",
|
||||||
"resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz",
|
||||||
@@ -2852,6 +3477,44 @@
|
|||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/react-router": {
|
||||||
|
"version": "7.15.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/react-router/-/react-router-7.15.1.tgz",
|
||||||
|
"integrity": "sha512-R8rl9HhgikFYoPJymnUtPXWbnDb3oget6lQnfIoupbt61aT9aOhRkDsY2XRhZRyX1Z/8a5sL74fXmFNm3NRK5A==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"cookie": "^1.0.1",
|
||||||
|
"set-cookie-parser": "^2.6.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20.0.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": ">=18",
|
||||||
|
"react-dom": ">=18"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"react-dom": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/react-router-dom": {
|
||||||
|
"version": "7.15.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.15.1.tgz",
|
||||||
|
"integrity": "sha512-AzF62gjY6U9rkMq4RfP/r2EVtQ7DMfNMjyOp/flLTCrtRylLiK4wT4pSq6O8rOXZ2eXdZYJPEYe+ifomiv+Igg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"react-router": "7.15.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20.0.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": ">=18",
|
||||||
|
"react-dom": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/redent": {
|
"node_modules/redent": {
|
||||||
"version": "3.0.0",
|
"version": "3.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz",
|
||||||
@@ -2954,6 +3617,12 @@
|
|||||||
"semver": "bin/semver.js"
|
"semver": "bin/semver.js"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/set-cookie-parser": {
|
||||||
|
"version": "2.7.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz",
|
||||||
|
"integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/siginfo": {
|
"node_modules/siginfo": {
|
||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz",
|
||||||
@@ -3005,6 +3674,27 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/tailwindcss": {
|
||||||
|
"version": "4.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.0.tgz",
|
||||||
|
"integrity": "sha512-y6nxMGB1nMW9R6k96e5gdIFzcfL/gTJRNaqGes1YvkLnPVXzWgbqFF2yLC0T8G774n24cx3Pe8XrKoniCOAH+Q==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/tapable": {
|
||||||
|
"version": "2.3.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.3.tgz",
|
||||||
|
"integrity": "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=6"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/webpack"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/tinybench": {
|
"node_modules/tinybench": {
|
||||||
"version": "2.9.0",
|
"version": "2.9.0",
|
||||||
"resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz",
|
"resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz",
|
||||||
|
|||||||
@@ -13,12 +13,15 @@
|
|||||||
"format": "biome format --write src"
|
"format": "biome format --write src"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"date-fns": "^4.1.0",
|
||||||
"react": "^19.0.0",
|
"react": "^19.0.0",
|
||||||
"react-dom": "^19.0.0",
|
"react-dom": "^19.0.0",
|
||||||
|
"react-router-dom": "^7.15.1",
|
||||||
"zod": "^3.23.8"
|
"zod": "^3.23.8"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@biomejs/biome": "^1.9.4",
|
"@biomejs/biome": "^1.9.4",
|
||||||
|
"@tailwindcss/vite": "^4.3.0",
|
||||||
"@testing-library/jest-dom": "^6.6.3",
|
"@testing-library/jest-dom": "^6.6.3",
|
||||||
"@testing-library/react": "^16.1.0",
|
"@testing-library/react": "^16.1.0",
|
||||||
"@testing-library/user-event": "^14.5.2",
|
"@testing-library/user-event": "^14.5.2",
|
||||||
@@ -26,6 +29,7 @@
|
|||||||
"@types/react-dom": "^19.0.2",
|
"@types/react-dom": "^19.0.2",
|
||||||
"@vitejs/plugin-react": "^4.3.4",
|
"@vitejs/plugin-react": "^4.3.4",
|
||||||
"jsdom": "^25.0.1",
|
"jsdom": "^25.0.1",
|
||||||
|
"tailwindcss": "^4.3.0",
|
||||||
"typescript": "~5.7.2",
|
"typescript": "~5.7.2",
|
||||||
"vite": "^6.0.5",
|
"vite": "^6.0.5",
|
||||||
"vitest": "^2.1.8"
|
"vitest": "^2.1.8"
|
||||||
|
|||||||
+19
-2
@@ -1,7 +1,24 @@
|
|||||||
|
import { Navigate, Route, Routes } from "react-router-dom";
|
||||||
|
import { MonthlyView } from "./components/MonthlyView";
|
||||||
|
import { ProjectsView } from "./components/ProjectsView";
|
||||||
|
import { TopBar } from "./components/TopBar";
|
||||||
|
import { WeeklyView } from "./components/WeeklyView";
|
||||||
|
import { UserProvider } from "./state/UserContext";
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
return (
|
return (
|
||||||
<main>
|
<UserProvider>
|
||||||
<h1>App</h1>
|
<div className="min-h-screen bg-gray-100">
|
||||||
|
<TopBar />
|
||||||
|
<main className="mx-auto max-w-5xl px-4 py-6">
|
||||||
|
<Routes>
|
||||||
|
<Route path="/" element={<Navigate to="/weekly" replace />} />
|
||||||
|
<Route path="/weekly" element={<WeeklyView />} />
|
||||||
|
<Route path="/monthly" element={<MonthlyView />} />
|
||||||
|
<Route path="/projects" element={<ProjectsView />} />
|
||||||
|
</Routes>
|
||||||
</main>
|
</main>
|
||||||
|
</div>
|
||||||
|
</UserProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
@import "tailwindcss";
|
||||||
@@ -0,0 +1,153 @@
|
|||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
// --- Zod schemas ---
|
||||||
|
|
||||||
|
export const UserSchema = z.object({
|
||||||
|
id: z.number(),
|
||||||
|
name: z.string(),
|
||||||
|
defaultHourlyRateCents: z.number(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const ProjectSchema = z.object({
|
||||||
|
id: z.number(),
|
||||||
|
name: z.string(),
|
||||||
|
timeBudgetMinutes: z.number(),
|
||||||
|
costBudgetCents: z.number(),
|
||||||
|
hourlyRateCents: z.number().nullable(),
|
||||||
|
minutesLogged: z.number(),
|
||||||
|
costLoggedCents: z.number(),
|
||||||
|
timeExceeded: z.boolean(),
|
||||||
|
costExceeded: z.boolean(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const TimeEntrySchema = z.object({
|
||||||
|
id: z.number(),
|
||||||
|
userId: z.number(),
|
||||||
|
userName: z.string(),
|
||||||
|
projectId: z.number(),
|
||||||
|
projectName: z.string(),
|
||||||
|
entryDate: z.string(),
|
||||||
|
durationMinutes: z.number(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const DayReportSchema = z.object({
|
||||||
|
date: z.string(),
|
||||||
|
totalMinutes: z.number(),
|
||||||
|
totalCostCents: z.number(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const WeeklyReportSchema = z.object({
|
||||||
|
days: z.array(DayReportSchema),
|
||||||
|
weekTotalMinutes: z.number(),
|
||||||
|
weekTotalCostCents: z.number(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const ProjectMonthReportSchema = z.object({
|
||||||
|
projectId: z.number(),
|
||||||
|
name: z.string(),
|
||||||
|
minutes: z.number(),
|
||||||
|
costCents: z.number(),
|
||||||
|
timeBudgetMinutes: z.number(),
|
||||||
|
costBudgetCents: z.number(),
|
||||||
|
timeBudgetPct: z.number(),
|
||||||
|
costBudgetPct: z.number(),
|
||||||
|
breached: z.boolean(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const MonthlyReportSchema = z.object({
|
||||||
|
projects: z.array(ProjectMonthReportSchema),
|
||||||
|
monthTotalMinutes: z.number(),
|
||||||
|
monthTotalCostCents: z.number(),
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- Inferred types ---
|
||||||
|
|
||||||
|
export type User = z.infer<typeof UserSchema>;
|
||||||
|
export type Project = z.infer<typeof ProjectSchema>;
|
||||||
|
export type TimeEntry = z.infer<typeof TimeEntrySchema>;
|
||||||
|
export type DayReport = z.infer<typeof DayReportSchema>;
|
||||||
|
export type WeeklyReport = z.infer<typeof WeeklyReportSchema>;
|
||||||
|
export type ProjectMonthReport = z.infer<typeof ProjectMonthReportSchema>;
|
||||||
|
export type MonthlyReport = z.infer<typeof MonthlyReportSchema>;
|
||||||
|
|
||||||
|
// --- Fetch helpers ---
|
||||||
|
|
||||||
|
async function fetchJson<T>(
|
||||||
|
schema: z.ZodType<T>,
|
||||||
|
url: string,
|
||||||
|
init?: RequestInit,
|
||||||
|
): Promise<T> {
|
||||||
|
const res = await fetch(url, init);
|
||||||
|
if (!res.ok) {
|
||||||
|
const text = await res.text().catch(() => res.statusText);
|
||||||
|
throw new Error(`${res.status} ${text}`);
|
||||||
|
}
|
||||||
|
const raw = await res.json();
|
||||||
|
return schema.parse(raw);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function sendJson<T>(
|
||||||
|
schema: z.ZodType<T>,
|
||||||
|
method: string,
|
||||||
|
url: string,
|
||||||
|
body: unknown,
|
||||||
|
): Promise<T> {
|
||||||
|
return fetchJson(schema, url, {
|
||||||
|
method,
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- API functions ---
|
||||||
|
|
||||||
|
export const api = {
|
||||||
|
users: {
|
||||||
|
list: () => fetchJson(z.array(UserSchema), "/api/users"),
|
||||||
|
},
|
||||||
|
|
||||||
|
projects: {
|
||||||
|
list: () => fetchJson(z.array(ProjectSchema), "/api/projects"),
|
||||||
|
create: (body: {
|
||||||
|
name: string;
|
||||||
|
timeBudgetMinutes: number;
|
||||||
|
costBudgetCents: number;
|
||||||
|
hourlyRateCents: number | null;
|
||||||
|
}) => sendJson(ProjectSchema, "POST", "/api/projects", body),
|
||||||
|
},
|
||||||
|
|
||||||
|
timeEntries: {
|
||||||
|
list: (userId: number, from: string, to: string) =>
|
||||||
|
fetchJson(
|
||||||
|
z.array(TimeEntrySchema),
|
||||||
|
`/api/time-entries?userId=${userId}&from=${from}&to=${to}`,
|
||||||
|
),
|
||||||
|
create: (body: {
|
||||||
|
userId: number;
|
||||||
|
projectId: number;
|
||||||
|
entryDate: string;
|
||||||
|
durationMinutes: number;
|
||||||
|
}) => sendJson(TimeEntrySchema, "POST", "/api/time-entries", body),
|
||||||
|
update: (
|
||||||
|
id: number,
|
||||||
|
body: { projectId: number; entryDate: string; durationMinutes: number },
|
||||||
|
) => sendJson(TimeEntrySchema, "PUT", `/api/time-entries/${id}`, body),
|
||||||
|
delete: async (id: number): Promise<void> => {
|
||||||
|
const res = await fetch(`/api/time-entries/${id}`, { method: "DELETE" });
|
||||||
|
if (!res.ok) throw new Error(`Delete failed: ${res.status}`);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
reports: {
|
||||||
|
weekly: (userId: number, week: string) =>
|
||||||
|
fetchJson(
|
||||||
|
WeeklyReportSchema,
|
||||||
|
`/api/reports/weekly?userId=${userId}&week=${encodeURIComponent(week)}`,
|
||||||
|
),
|
||||||
|
monthly: (userId: number, month: string) =>
|
||||||
|
fetchJson(
|
||||||
|
MonthlyReportSchema,
|
||||||
|
`/api/reports/monthly?userId=${userId}&month=${encodeURIComponent(month)}`,
|
||||||
|
),
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
const eurFormatter = new Intl.NumberFormat("de-DE", {
|
||||||
|
style: "currency",
|
||||||
|
currency: "EUR",
|
||||||
|
});
|
||||||
|
|
||||||
|
export function centsToEur(cents: number): string {
|
||||||
|
return eurFormatter.format(cents / 100);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function minutesToHmm(minutes: number): string {
|
||||||
|
const h = Math.floor(minutes / 60);
|
||||||
|
const m = minutes % 60;
|
||||||
|
return `${h}:${String(m).padStart(2, "0")}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function hmmToMinutes(hmm: string): number | null {
|
||||||
|
const match = /^(\d+):([0-5]\d)$/.exec(hmm.trim());
|
||||||
|
if (!match) return null;
|
||||||
|
return (
|
||||||
|
Number.parseInt(match[1] ?? "0", 10) * 60 +
|
||||||
|
Number.parseInt(match[2] ?? "0", 10)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isoWeekLabel(isoWeek: string): string {
|
||||||
|
const parts = isoWeek.split("-W");
|
||||||
|
const year = parts[0] ?? "";
|
||||||
|
const weekPart = parts[1] ?? "";
|
||||||
|
return `Week ${weekPart}, ${year}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function currentIsoWeek(date: Date = new Date()): string {
|
||||||
|
// ISO week: Thursday-based week numbering
|
||||||
|
const d = new Date(date);
|
||||||
|
d.setHours(0, 0, 0, 0);
|
||||||
|
const dayNum = d.getDay() === 0 ? 7 : d.getDay();
|
||||||
|
d.setDate(d.getDate() + 4 - dayNum);
|
||||||
|
const yearStart = new Date(d.getFullYear(), 0, 1);
|
||||||
|
const weekNo = Math.ceil(
|
||||||
|
((d.getTime() - yearStart.getTime()) / 86400000 + 1) / 7,
|
||||||
|
);
|
||||||
|
const year = d.getFullYear();
|
||||||
|
return `${year}-W${String(weekNo).padStart(2, "0")}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function stepIsoWeek(isoWeek: string, delta: number): string {
|
||||||
|
const monday = isoWeekToMonday(isoWeek);
|
||||||
|
monday.setDate(monday.getDate() + delta * 7);
|
||||||
|
return currentIsoWeek(monday);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isoWeekToMonday(isoWeek: string): Date {
|
||||||
|
const parts = isoWeek.split("-W");
|
||||||
|
const year = Number.parseInt(parts[0] ?? "0", 10);
|
||||||
|
const week = Number.parseInt(parts[1] ?? "0", 10);
|
||||||
|
const jan4 = new Date(year, 0, 4);
|
||||||
|
const jan4Day = jan4.getDay() === 0 ? 7 : jan4.getDay();
|
||||||
|
const mondayOfWeek1 = new Date(jan4);
|
||||||
|
mondayOfWeek1.setDate(jan4.getDate() - (jan4Day - 1));
|
||||||
|
const monday = new Date(mondayOfWeek1);
|
||||||
|
monday.setDate(monday.getDate() + (week - 1) * 7);
|
||||||
|
return monday;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function currentYearMonth(date: Date = new Date()): string {
|
||||||
|
const y = date.getFullYear();
|
||||||
|
const m = String(date.getMonth() + 1).padStart(2, "0");
|
||||||
|
return `${y}-${m}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function stepYearMonth(ym: string, delta: number): string {
|
||||||
|
const parts = ym.split("-");
|
||||||
|
const y = Number.parseInt(parts[0] ?? "0", 10);
|
||||||
|
const m = Number.parseInt(parts[1] ?? "0", 10);
|
||||||
|
const d = new Date(y, m - 1 + delta, 1);
|
||||||
|
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatDate(isoDate: string): string {
|
||||||
|
return new Date(`${isoDate}T00:00:00`).toLocaleDateString("de-DE", {
|
||||||
|
weekday: "short",
|
||||||
|
month: "short",
|
||||||
|
day: "numeric",
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -1,12 +1,16 @@
|
|||||||
import { StrictMode } from "react";
|
import { StrictMode } from "react";
|
||||||
import { createRoot } from "react-dom/client";
|
import { createRoot } from "react-dom/client";
|
||||||
|
import { BrowserRouter } from "react-router-dom";
|
||||||
import App from "./App";
|
import App from "./App";
|
||||||
|
import "./index.css";
|
||||||
|
|
||||||
const rootElement = document.getElementById("root");
|
const rootElement = document.getElementById("root");
|
||||||
if (!rootElement) throw new Error("Root element not found");
|
if (!rootElement) throw new Error("Root element not found");
|
||||||
|
|
||||||
createRoot(rootElement).render(
|
createRoot(rootElement).render(
|
||||||
<StrictMode>
|
<StrictMode>
|
||||||
|
<BrowserRouter>
|
||||||
<App />
|
<App />
|
||||||
|
</BrowserRouter>
|
||||||
</StrictMode>,
|
</StrictMode>,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,62 @@
|
|||||||
|
import {
|
||||||
|
type ReactNode,
|
||||||
|
createContext,
|
||||||
|
useContext,
|
||||||
|
useEffect,
|
||||||
|
useState,
|
||||||
|
} from "react";
|
||||||
|
import { type User, api } from "../lib/api";
|
||||||
|
|
||||||
|
interface UserContextValue {
|
||||||
|
users: User[];
|
||||||
|
selectedUserId: number | null;
|
||||||
|
setSelectedUserId: (id: number) => void;
|
||||||
|
selectedUser: User | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const UserContext = createContext<UserContextValue | null>(null);
|
||||||
|
|
||||||
|
const STORAGE_KEY = "selectedUserId";
|
||||||
|
|
||||||
|
export function UserProvider({ children }: { children: ReactNode }) {
|
||||||
|
const [users, setUsers] = useState<User[]>([]);
|
||||||
|
const [selectedUserId, setSelectedUserIdState] = useState<number | null>(
|
||||||
|
() => {
|
||||||
|
const stored = localStorage.getItem(STORAGE_KEY);
|
||||||
|
return stored ? Number.parseInt(stored, 10) : null;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
api.users.list().then(setUsers);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// set default user once users are loaded and none is selected
|
||||||
|
useEffect(() => {
|
||||||
|
const first = users[0];
|
||||||
|
if (selectedUserId === null && first !== undefined) {
|
||||||
|
setSelectedUserIdState(first.id);
|
||||||
|
}
|
||||||
|
}, [users, selectedUserId]);
|
||||||
|
|
||||||
|
const setSelectedUserId = (id: number) => {
|
||||||
|
setSelectedUserIdState(id);
|
||||||
|
localStorage.setItem(STORAGE_KEY, String(id));
|
||||||
|
};
|
||||||
|
|
||||||
|
const selectedUser = users.find((u) => u.id === selectedUserId) ?? null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<UserContext.Provider
|
||||||
|
value={{ users, selectedUserId, setSelectedUserId, selectedUser }}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</UserContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useUser(): UserContextValue {
|
||||||
|
const ctx = useContext(UserContext);
|
||||||
|
if (!ctx) throw new Error("useUser must be used within UserProvider");
|
||||||
|
return ctx;
|
||||||
|
}
|
||||||
@@ -1,9 +1,10 @@
|
|||||||
/// <reference types="vitest" />
|
/// <reference types="vitest" />
|
||||||
|
import tailwindcss from "@tailwindcss/vite";
|
||||||
import { defineConfig } from "vite";
|
import { defineConfig } from "vite";
|
||||||
import react from "@vitejs/plugin-react";
|
import react from "@vitejs/plugin-react";
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [react()],
|
plugins: [tailwindcss(), react()],
|
||||||
server: {
|
server: {
|
||||||
proxy: {
|
proxy: {
|
||||||
"/api": "http://localhost:8080",
|
"/api": "http://localhost:8080",
|
||||||
|
|||||||
Reference in New Issue
Block a user