Add 11 major features for caregiver health management

Features added:
- Emergency Info Card: Full-screen emergency view with patient info
- Refill Tracker: Track pill counts with auto-decrement on dose
- Activity Feed: View caregiver activity with filtering
- Symptom Tracker: Log symptoms with severity and offline sync
- Print Views: Daily meds, appointments, doctor visit summaries
- iCal Export: Calendar subscription for appointments
- PDF Export: Medical summary for doctor visits
- Calendar View: Monthly calendar for appointments
- Appointment Preparation: Checklist for upcoming appointments
- Medication Reminders: PWA push notifications with quiet hours

Bug fixes:
- Fix invite workflow: Register/login now properly redirect back
- Add undo for doctor questions (can unmark "asked" questions)
- Fix API route type annotations for Next.js 14 compatibility
- Add Suspense boundary for useSearchParams in login/register

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Gemini Agent
2026-01-23 09:42:46 +00:00
parent 515376e126
commit dd4ef2c4cd
70 changed files with 7322 additions and 79 deletions

View File

@@ -45,10 +45,9 @@ services:
timeout: 5s
retries: 5
start_period: 10s
# Do not expose PostgreSQL to the host - only accessible within the network
# If you need direct access for migrations, uncomment below:
# ports:
# - "127.0.0.1:5432:5432"
# Expose PostgreSQL to localhost for migrations
ports:
- "127.0.0.1:5432:5432"
volumes:
postgres_data:

7
firebase-debug.log Normal file
View File

@@ -0,0 +1,7 @@
[debug] [2026-01-19T13:24:37.682Z] > command requires scopes: ["email","openid","https://www.googleapis.com/auth/cloudplatformprojects.readonly","https://www.googleapis.com/auth/firebase","https://www.googleapis.com/auth/cloud-platform"]
[debug] [2026-01-19T13:24:37.718Z] > command requires scopes: ["email","openid","https://www.googleapis.com/auth/cloudplatformprojects.readonly","https://www.googleapis.com/auth/firebase","https://www.googleapis.com/auth/cloud-platform"]
[debug] [2026-01-19T13:24:37.720Z] > command requires scopes: ["email","openid","https://www.googleapis.com/auth/cloudplatformprojects.readonly","https://www.googleapis.com/auth/firebase","https://www.googleapis.com/auth/cloud-platform"]
[debug] [2026-01-19T13:24:37.965Z] > command requires scopes: ["email","openid","https://www.googleapis.com/auth/cloudplatformprojects.readonly","https://www.googleapis.com/auth/firebase","https://www.googleapis.com/auth/cloud-platform"]
[debug] [2026-01-19T13:24:37.967Z] > command requires scopes: ["email","openid","https://www.googleapis.com/auth/cloudplatformprojects.readonly","https://www.googleapis.com/auth/firebase","https://www.googleapis.com/auth/cloud-platform"]
[debug] [2026-01-19T13:24:37.969Z] > command requires scopes: ["email","openid","https://www.googleapis.com/auth/cloudplatformprojects.readonly","https://www.googleapis.com/auth/firebase","https://www.googleapis.com/auth/cloud-platform"]
[debug] [2026-01-19T13:24:37.970Z] > command requires scopes: ["email","openid","https://www.googleapis.com/auth/cloudplatformprojects.readonly","https://www.googleapis.com/auth/firebase","https://www.googleapis.com/auth/cloud-platform"]

323
package-lock.json generated
View File

@@ -19,16 +19,20 @@
"lucide-react": "^0.468.0",
"nanoid": "^5.0.9",
"next": "^14.2.21",
"pdfkit": "^0.17.2",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-hot-toast": "^2.4.1",
"web-push": "^3.6.7",
"zod": "^3.24.1"
},
"devDependencies": {
"@tailwindcss/forms": "^0.5.9",
"@types/node": "^22.10.5",
"@types/pdfkit": "^0.17.4",
"@types/react": "^18.3.18",
"@types/react-dom": "^18.3.5",
"@types/web-push": "^3.6.4",
"autoprefixer": "^10.4.20",
"eslint": "^8.57.1",
"eslint-config-next": "^14.2.21",
@@ -1456,6 +1460,16 @@
"undici-types": "~6.21.0"
}
},
"node_modules/@types/pdfkit": {
"version": "0.17.4",
"resolved": "https://registry.npmjs.org/@types/pdfkit/-/pdfkit-0.17.4.tgz",
"integrity": "sha512-odAmVuuguRxKh1X4pbMrJMp8ecwNqHRw6lweupvzK+wuyNmi6wzlUlGVZ9EqMvp3Bs2+L9Ty0sRlrvKL+gsQZg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@types/prop-types": {
"version": "15.7.15",
"resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz",
@@ -1482,6 +1496,16 @@
"@types/react": "^18.0.0"
}
},
"node_modules/@types/web-push": {
"version": "3.6.4",
"resolved": "https://registry.npmjs.org/@types/web-push/-/web-push-3.6.4.tgz",
"integrity": "sha512-GnJmSr40H3RAnj0s34FNTcJi1hmWFV5KXugE0mYWnYhgTAHLJ/dJKAwDmvPJYMke0RplY2XE9LnM4hqSqKIjhQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@typescript-eslint/eslint-plugin": {
"version": "8.53.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.53.0.tgz",
@@ -2163,6 +2187,15 @@
"acorn": "^6.0.0 || ^7.0.0 || ^8.0.0"
}
},
"node_modules/agent-base": {
"version": "7.1.4",
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz",
"integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==",
"license": "MIT",
"engines": {
"node": ">= 14"
}
},
"node_modules/ajv": {
"version": "6.12.6",
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz",
@@ -2426,6 +2459,18 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/asn1.js": {
"version": "5.4.1",
"resolved": "https://registry.npmjs.org/asn1.js/-/asn1.js-5.4.1.tgz",
"integrity": "sha512-+I//4cYPccV8LdmBLiX8CYvf9Sp3vQsrqu2QNXRcrbiWvcx/UdlFiqUJJzxRQxgsZmvhXhn4cSKeSmoFjVdupA==",
"license": "MIT",
"dependencies": {
"bn.js": "^4.0.0",
"inherits": "^2.0.1",
"minimalistic-assert": "^1.0.0",
"safer-buffer": "^2.1.0"
}
},
"node_modules/assertion-error": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz",
@@ -2533,6 +2578,26 @@
"dev": true,
"license": "MIT"
},
"node_modules/base64-js": {
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
"integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT"
},
"node_modules/baseline-browser-mapping": {
"version": "2.9.15",
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.15.tgz",
@@ -2556,6 +2621,12 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/bn.js": {
"version": "4.12.2",
"resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.2.tgz",
"integrity": "sha512-n4DSx829VRTRByMRGdjQ9iqsN0Bh4OolPsFnaZBLcbi8iXcB+kJ9s7EnRt4wILZNV3kPLHkRVfOc/HvhC3ovDw==",
"license": "MIT"
},
"node_modules/brace-expansion": {
"version": "1.1.12",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz",
@@ -2580,6 +2651,15 @@
"node": ">=8"
}
},
"node_modules/brotli": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/brotli/-/brotli-1.3.3.tgz",
"integrity": "sha512-oTKjJdShmDuGW94SyyaoQvAjf30dZaHnjJ8uAF+u2/vGJkJbJPJAT1gDiOJP5v1Zb6f9KEyW/1HpuaWIXtGHPg==",
"license": "MIT",
"dependencies": {
"base64-js": "^1.1.2"
}
},
"node_modules/browserslist": {
"version": "4.28.1",
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz",
@@ -2614,6 +2694,12 @@
"node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7"
}
},
"node_modules/buffer-equal-constant-time": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz",
"integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==",
"license": "BSD-3-Clause"
},
"node_modules/busboy": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz",
@@ -2813,6 +2899,15 @@
"integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==",
"license": "MIT"
},
"node_modules/clone": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/clone/-/clone-2.1.2.tgz",
"integrity": "sha512-3Pe/CF1Nn94hyhIYpjtiLhdCoEoz0DqQ+988E9gmeEdQZlojxnOb74wctFyuwWQHzqyf9X7C7MG8juUpqBJT8w==",
"license": "MIT",
"engines": {
"node": ">=0.8"
}
},
"node_modules/clsx": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
@@ -2874,6 +2969,12 @@
"node": ">= 8"
}
},
"node_modules/crypto-js": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/crypto-js/-/crypto-js-4.2.0.tgz",
"integrity": "sha512-KALDyEYgpY+Rlob/iriUtjV6d5Eq+Y191A5g4UqLAi8CyGP9N1+FdVbkc1SxKc2r4YAYqG8JzO2KGL+AizD70Q==",
"license": "MIT"
},
"node_modules/cssesc": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz",
@@ -2977,7 +3078,6 @@
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
"dev": true,
"license": "MIT",
"dependencies": {
"ms": "^2.1.3"
@@ -3061,6 +3161,12 @@
"react": ">=16"
}
},
"node_modules/dfa": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/dfa/-/dfa-1.2.0.tgz",
"integrity": "sha512-ED3jP8saaweFTjeGX8HQPjeC1YYyZs98jGNZx6IiBvxW7JG5v492kamAQB3m2wop07CvU/RQmzcKr6bgcC5D/Q==",
"license": "MIT"
},
"node_modules/didyoumean": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz",
@@ -3110,6 +3216,15 @@
"dev": true,
"license": "MIT"
},
"node_modules/ecdsa-sig-formatter": {
"version": "1.0.11",
"resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz",
"integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==",
"license": "Apache-2.0",
"dependencies": {
"safe-buffer": "^5.0.1"
}
},
"node_modules/electron-to-chromium": {
"version": "1.5.267",
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.267.tgz",
@@ -3845,7 +3960,6 @@
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
"integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
"dev": true,
"license": "MIT"
},
"node_modules/fast-glob": {
@@ -3967,6 +4081,32 @@
"dev": true,
"license": "ISC"
},
"node_modules/fontkit": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/fontkit/-/fontkit-2.0.4.tgz",
"integrity": "sha512-syetQadaUEDNdxdugga9CpEYVaQIxOwk7GlwZWWZ19//qW4zE5bknOKeMBDYAASwnpaSHKJITRLMF9m1fp3s6g==",
"license": "MIT",
"dependencies": {
"@swc/helpers": "^0.5.12",
"brotli": "^1.3.2",
"clone": "^2.1.2",
"dfa": "^1.2.0",
"fast-deep-equal": "^3.1.3",
"restructure": "^3.0.0",
"tiny-inflate": "^1.0.3",
"unicode-properties": "^1.4.0",
"unicode-trie": "^2.0.0"
}
},
"node_modules/fontkit/node_modules/@swc/helpers": {
"version": "0.5.18",
"resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.18.tgz",
"integrity": "sha512-TXTnIcNJQEKwThMMqBXsZ4VGAza6bvN4pa41Rkqoio6QBKMvo+5lexeTMScGCIxtzgQJzElcvIltani+adC5PQ==",
"license": "Apache-2.0",
"dependencies": {
"tslib": "^2.8.0"
}
},
"node_modules/for-each": {
"version": "0.3.5",
"resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz",
@@ -4381,6 +4521,28 @@
"node": ">= 0.4"
}
},
"node_modules/http_ece": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/http_ece/-/http_ece-1.2.0.tgz",
"integrity": "sha512-JrF8SSLVmcvc5NducxgyOrKXe3EsyHMgBFgSaIUGmArKe+rwr0uphRkRXvwiom3I+fpIfoItveHrfudL8/rxuA==",
"license": "MIT",
"engines": {
"node": ">=16"
}
},
"node_modules/https-proxy-agent": {
"version": "7.0.6",
"resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz",
"integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==",
"license": "MIT",
"dependencies": {
"agent-base": "^7.1.2",
"debug": "4"
},
"engines": {
"node": ">= 14"
}
},
"node_modules/ignore": {
"version": "5.3.2",
"resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz",
@@ -4434,7 +4596,6 @@
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
"dev": true,
"license": "ISC"
},
"node_modules/internal-slot": {
@@ -4948,6 +5109,13 @@
"jiti": "bin/jiti.js"
}
},
"node_modules/jpeg-exif": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/jpeg-exif/-/jpeg-exif-1.1.4.tgz",
"integrity": "sha512-a+bKEcCjtuW5WTdgeXFzswSrdqi0jk4XlEtZlx5A94wCoBpFjfFTbo/Tra5SpNCl/YFZPvcV1dJc+TAYeg6ROQ==",
"deprecated": "Package no longer supported. Contact Support at https://www.npmjs.com/support for more info.",
"license": "MIT"
},
"node_modules/js-tokens": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
@@ -5017,6 +5185,27 @@
"node": ">=4.0"
}
},
"node_modules/jwa": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz",
"integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==",
"license": "MIT",
"dependencies": {
"buffer-equal-constant-time": "^1.0.1",
"ecdsa-sig-formatter": "1.0.11",
"safe-buffer": "^5.0.1"
}
},
"node_modules/jws": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz",
"integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==",
"license": "MIT",
"dependencies": {
"jwa": "^2.0.1",
"safe-buffer": "^5.0.1"
}
},
"node_modules/keyv": {
"version": "4.5.4",
"resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz",
@@ -5074,6 +5263,25 @@
"url": "https://github.com/sponsors/antonk52"
}
},
"node_modules/linebreak": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/linebreak/-/linebreak-1.1.0.tgz",
"integrity": "sha512-MHp03UImeVhB7XZtjd0E4n6+3xr5Dq/9xI/5FptGk5FrbDR3zagPa2DS6U8ks/3HjbKWG9Q1M2ufOzxV2qLYSQ==",
"license": "MIT",
"dependencies": {
"base64-js": "0.0.8",
"unicode-trie": "^2.0.0"
}
},
"node_modules/linebreak/node_modules/base64-js": {
"version": "0.0.8",
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-0.0.8.tgz",
"integrity": "sha512-3XSA2cR/h/73EzlXXdU6YNycmYI7+kicTxks4eJg2g39biHR84slg2+des+p7iHYhbRg/udIS4TD53WabcOUkw==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
}
},
"node_modules/lines-and-columns": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz",
@@ -5193,6 +5401,12 @@
"mini-svg-data-uri": "cli.js"
}
},
"node_modules/minimalistic-assert": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz",
"integrity": "sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==",
"license": "ISC"
},
"node_modules/minimatch": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
@@ -5210,7 +5424,6 @@
"version": "1.2.8",
"resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz",
"integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==",
"dev": true,
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/ljharb"
@@ -5230,7 +5443,6 @@
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"dev": true,
"license": "MIT"
},
"node_modules/mz": {
@@ -5630,6 +5842,12 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/pako": {
"version": "0.2.9",
"resolved": "https://registry.npmjs.org/pako/-/pako-0.2.9.tgz",
"integrity": "sha512-NUcwaKxUxWrZLpDG+z/xZaCgQITkA/Dv4V/T6bw7VON6l1Xz/VnrBqrYjZQ12TamKHzITTfOEIYUj48y2KXImA==",
"license": "MIT"
},
"node_modules/parent-module": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz",
@@ -5714,6 +5932,19 @@
"node": ">= 14.16"
}
},
"node_modules/pdfkit": {
"version": "0.17.2",
"resolved": "https://registry.npmjs.org/pdfkit/-/pdfkit-0.17.2.tgz",
"integrity": "sha512-UnwF5fXy08f0dnp4jchFYAROKMNTaPqb/xgR8GtCzIcqoTnbOqtp3bwKvO4688oHI6vzEEs8Q6vqqEnC5IUELw==",
"license": "MIT",
"dependencies": {
"crypto-js": "^4.2.0",
"fontkit": "^2.0.4",
"jpeg-exif": "^1.1.4",
"linebreak": "^1.1.0",
"png-js": "^1.0.0"
}
},
"node_modules/picocolors": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
@@ -5753,6 +5984,11 @@
"node": ">= 6"
}
},
"node_modules/png-js": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/png-js/-/png-js-1.0.0.tgz",
"integrity": "sha512-k+YsbhpA9e+EFfKjTCH3VW6aoKlyNYI6NYdTfDL4CIvFnvsuO84ttonmZE7rc+v23SLTH8XX+5w/Ak9v0xGY4g=="
},
"node_modules/possible-typed-array-names": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz",
@@ -6175,6 +6411,12 @@
"url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1"
}
},
"node_modules/restructure": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/restructure/-/restructure-3.0.2.tgz",
"integrity": "sha512-gSfoiOEA0VPE6Tukkrr7I0RBdE0s7H1eFCDBk05l1KIQT1UIKNc5JZy6jdyW6eYH3aR3g5b3PuL77rq0hvwtAw==",
"license": "MIT"
},
"node_modules/reusify": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz",
@@ -6314,6 +6556,26 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/safe-buffer": {
"version": "5.2.1",
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
"integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT"
},
"node_modules/safe-push-apply": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/safe-push-apply/-/safe-push-apply-1.0.0.tgz",
@@ -6349,6 +6611,12 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/safer-buffer": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
"license": "MIT"
},
"node_modules/scheduler": {
"version": "0.23.2",
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz",
@@ -6964,6 +7232,12 @@
"node": ">=0.8"
}
},
"node_modules/tiny-inflate": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/tiny-inflate/-/tiny-inflate-1.0.3.tgz",
"integrity": "sha512-pkY1fj1cKHb2seWDy0B16HeWyczlJA9/WW3u3c4z/NiWDsO3DOU5D7nhTLE9CF0yXv/QZFY7sEJmj24dK+Rrqw==",
"license": "MIT"
},
"node_modules/tinybench": {
"version": "2.9.0",
"resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz",
@@ -7272,6 +7546,26 @@
"dev": true,
"license": "MIT"
},
"node_modules/unicode-properties": {
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/unicode-properties/-/unicode-properties-1.4.1.tgz",
"integrity": "sha512-CLjCCLQ6UuMxWnbIylkisbRj31qxHPAurvena/0iwSVbQ2G1VY5/HjV0IRabOEbDHlzZlRdCrD4NhB0JtU40Pg==",
"license": "MIT",
"dependencies": {
"base64-js": "^1.3.0",
"unicode-trie": "^2.0.0"
}
},
"node_modules/unicode-trie": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/unicode-trie/-/unicode-trie-2.0.0.tgz",
"integrity": "sha512-x7bc76x0bm4prf1VLg79uhAzKw8DVboClSN5VxJuQ+LKDOVEW9CdH+VY7SP+vX7xCYQqzzgQpFqz15zeLvAtZQ==",
"license": "MIT",
"dependencies": {
"pako": "^0.2.5",
"tiny-inflate": "^1.0.0"
}
},
"node_modules/unrs-resolver": {
"version": "1.11.1",
"resolved": "https://registry.npmjs.org/unrs-resolver/-/unrs-resolver-1.11.1.tgz",
@@ -7934,6 +8228,25 @@
}
}
},
"node_modules/web-push": {
"version": "3.6.7",
"resolved": "https://registry.npmjs.org/web-push/-/web-push-3.6.7.tgz",
"integrity": "sha512-OpiIUe8cuGjrj3mMBFWY+e4MMIkW3SVT+7vEIjvD9kejGUypv8GPDf84JdPWskK8zMRIJ6xYGm+Kxr8YkPyA0A==",
"license": "MPL-2.0",
"dependencies": {
"asn1.js": "^5.3.0",
"http_ece": "1.2.0",
"https-proxy-agent": "^7.0.0",
"jws": "^4.0.0",
"minimist": "^1.2.5"
},
"bin": {
"web-push": "src/cli.js"
},
"engines": {
"node": ">= 16"
}
},
"node_modules/which": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",

View File

@@ -28,16 +28,20 @@
"lucide-react": "^0.468.0",
"nanoid": "^5.0.9",
"next": "^14.2.21",
"pdfkit": "^0.17.2",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-hot-toast": "^2.4.1",
"web-push": "^3.6.7",
"zod": "^3.24.1"
},
"devDependencies": {
"@tailwindcss/forms": "^0.5.9",
"@types/node": "^22.10.5",
"@types/pdfkit": "^0.17.4",
"@types/react": "^18.3.18",
"@types/react-dom": "^18.3.5",
"@types/web-push": "^3.6.4",
"autoprefixer": "^10.4.20",
"eslint": "^8.57.1",
"eslint-config-next": "^14.2.21",

View File

@@ -0,0 +1,102 @@
-- CreateEnum
CREATE TYPE "SymptomType" AS ENUM ('FATIGUE', 'NAUSEA', 'PAIN', 'APPETITE', 'SLEEP', 'MOOD', 'CUSTOM');
-- AlterTable
ALTER TABLE "Medication" ADD COLUMN "lastRefillDate" TIMESTAMP(3),
ADD COLUMN "pillCount" INTEGER,
ADD COLUMN "pillsPerDose" INTEGER DEFAULT 1,
ADD COLUMN "refillThreshold" INTEGER DEFAULT 7;
-- AlterTable
ALTER TABLE "Workspace" ADD COLUMN "allergies" TEXT,
ADD COLUMN "bloodType" TEXT,
ADD COLUMN "medicalConditions" TEXT,
ADD COLUMN "patientDOB" TIMESTAMP(3),
ADD COLUMN "patientName" TEXT,
ADD COLUMN "physicianPhone" TEXT,
ADD COLUMN "primaryPhysician" TEXT;
-- CreateTable
CREATE TABLE "Symptom" (
"id" TEXT NOT NULL,
"workspaceId" TEXT NOT NULL,
"type" "SymptomType" NOT NULL,
"customName" TEXT,
"severity" INTEGER NOT NULL,
"notes" TEXT,
"recordedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"deletedAt" TIMESTAMP(3),
"createdById" TEXT NOT NULL,
"version" INTEGER NOT NULL DEFAULT 1,
"syncedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "Symptom_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "AppointmentChecklist" (
"id" TEXT NOT NULL,
"workspaceId" TEXT NOT NULL,
"appointmentId" TEXT NOT NULL,
"item" TEXT NOT NULL,
"isReady" BOOLEAN NOT NULL DEFAULT false,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "AppointmentChecklist_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "PushSubscription" (
"id" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"workspaceId" TEXT NOT NULL,
"endpoint" TEXT NOT NULL,
"p256dh" TEXT NOT NULL,
"auth" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "PushSubscription_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "Symptom_workspaceId_recordedAt_idx" ON "Symptom"("workspaceId", "recordedAt");
-- CreateIndex
CREATE INDEX "Symptom_workspaceId_type_idx" ON "Symptom"("workspaceId", "type");
-- CreateIndex
CREATE INDEX "Symptom_workspaceId_deletedAt_idx" ON "Symptom"("workspaceId", "deletedAt");
-- CreateIndex
CREATE INDEX "Symptom_syncedAt_idx" ON "Symptom"("syncedAt");
-- CreateIndex
CREATE INDEX "AppointmentChecklist_appointmentId_idx" ON "AppointmentChecklist"("appointmentId");
-- CreateIndex
CREATE INDEX "AppointmentChecklist_workspaceId_idx" ON "AppointmentChecklist"("workspaceId");
-- CreateIndex
CREATE INDEX "PushSubscription_workspaceId_idx" ON "PushSubscription"("workspaceId");
-- CreateIndex
CREATE UNIQUE INDEX "PushSubscription_userId_endpoint_key" ON "PushSubscription"("userId", "endpoint");
-- AddForeignKey
ALTER TABLE "Symptom" ADD CONSTRAINT "Symptom_workspaceId_fkey" FOREIGN KEY ("workspaceId") REFERENCES "Workspace"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Symptom" ADD CONSTRAINT "Symptom_createdById_fkey" FOREIGN KEY ("createdById") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "AppointmentChecklist" ADD CONSTRAINT "AppointmentChecklist_workspaceId_fkey" FOREIGN KEY ("workspaceId") REFERENCES "Workspace"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "AppointmentChecklist" ADD CONSTRAINT "AppointmentChecklist_appointmentId_fkey" FOREIGN KEY ("appointmentId") REFERENCES "Appointment"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "PushSubscription" ADD CONSTRAINT "PushSubscription_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "PushSubscription" ADD CONSTRAINT "PushSubscription_workspaceId_fkey" FOREIGN KEY ("workspaceId") REFERENCES "Workspace"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View File

@@ -31,6 +31,8 @@ model User {
loggedDoses DoseLog[] @relation("DoseLoggedBy")
undoneDoses DoseLog[] @relation("DoseUndoneBy")
auditLogs AuditLog[]
symptoms Symptom[]
pushSubscriptions PushSubscription[]
@@index([email])
}
@@ -78,6 +80,15 @@ model Workspace {
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
// Emergency info fields
patientName String?
patientDOB DateTime?
bloodType String?
allergies String? // Comma-separated or free text
medicalConditions String? // Comma-separated or free text
primaryPhysician String?
physicianPhone String?
// Relations
members WorkspaceMember[]
inviteTokens InviteToken[]
@@ -87,6 +98,9 @@ model Workspace {
doseLogs DoseLog[]
auditLogs AuditLog[]
syncCursors SyncCursor[]
symptoms Symptom[]
appointmentChecklists AppointmentChecklist[]
pushSubscriptions PushSubscription[]
@@index([name])
}
@@ -155,6 +169,7 @@ model Appointment {
workspace Workspace @relation(fields: [workspaceId], references: [id], onDelete: Cascade)
createdBy User @relation("AppointmentCreatedBy", fields: [createdById], references: [id])
updatedBy User @relation("AppointmentUpdatedBy", fields: [updatedById], references: [id])
checklists AppointmentChecklist[]
@@index([workspaceId, datetime])
@@index([workspaceId, deletedAt])
@@ -188,6 +203,12 @@ model Medication {
createdById String
updatedById String
// Refill tracking fields
pillCount Int?
pillsPerDose Int? @default(1)
refillThreshold Int? @default(7)
lastRefillDate DateTime?
// Sync tracking
version Int @default(1)
syncedAt DateTime @default(now())
@@ -290,6 +311,88 @@ model AuditLog {
@@index([entityType, entityId])
}
// ============================================
// SYMPTOMS
// ============================================
enum SymptomType {
FATIGUE
NAUSEA
PAIN
APPETITE
SLEEP
MOOD
CUSTOM
}
model Symptom {
id String @id @default(cuid())
workspaceId String
type SymptomType
customName String? // Only used when type is CUSTOM
severity Int // 1-5 scale
notes String?
recordedAt DateTime @default(now())
deletedAt DateTime?
createdById String
// Sync tracking
version Int @default(1)
syncedAt DateTime @default(now())
// Relations
workspace Workspace @relation(fields: [workspaceId], references: [id], onDelete: Cascade)
createdBy User @relation(fields: [createdById], references: [id])
@@index([workspaceId, recordedAt])
@@index([workspaceId, type])
@@index([workspaceId, deletedAt])
@@index([syncedAt])
}
// ============================================
// APPOINTMENT CHECKLIST
// ============================================
model AppointmentChecklist {
id String @id @default(cuid())
workspaceId String
appointmentId String
item String
isReady Boolean @default(false)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
// Relations
workspace Workspace @relation(fields: [workspaceId], references: [id], onDelete: Cascade)
appointment Appointment @relation(fields: [appointmentId], references: [id], onDelete: Cascade)
@@unique([workspaceId, appointmentId, item])
@@index([appointmentId])
@@index([workspaceId])
}
// ============================================
// PUSH NOTIFICATIONS
// ============================================
model PushSubscription {
id String @id @default(cuid())
userId String
workspaceId String
endpoint String
p256dh String
auth String
createdAt DateTime @default(now())
// Relations
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
workspace Workspace @relation(fields: [workspaceId], references: [id], onDelete: Cascade)
@@unique([userId, endpoint])
@@index([workspaceId])
}
// ============================================
// SYNC
// ============================================

148
public/sw.js Normal file
View File

@@ -0,0 +1,148 @@
// NextStep Service Worker for Push Notifications
const CACHE_NAME = 'nextstep-v1'
// Install event - cache critical assets
self.addEventListener('install', (event) => {
console.log('Service Worker: Installing...')
event.waitUntil(
caches.open(CACHE_NAME).then((cache) => {
return cache.addAll([
'/',
'/today',
'/meds',
'/icon-192.png',
'/icon-512.png',
])
})
)
self.skipWaiting()
})
// Activate event - clean up old caches
self.addEventListener('activate', (event) => {
console.log('Service Worker: Activating...')
event.waitUntil(
caches.keys().then((cacheNames) => {
return Promise.all(
cacheNames
.filter((name) => name !== CACHE_NAME)
.map((name) => caches.delete(name))
)
})
)
self.clients.claim()
})
// Fetch event - serve from cache when offline
self.addEventListener('fetch', (event) => {
// Only cache GET requests
if (event.request.method !== 'GET') return
event.respondWith(
caches.match(event.request).then((cached) => {
// Return cached version or fetch from network
return (
cached ||
fetch(event.request).then((response) => {
// Don't cache API responses
if (event.request.url.includes('/api/')) {
return response
}
// Cache successful responses
if (response.status === 200) {
const clone = response.clone()
caches.open(CACHE_NAME).then((cache) => {
cache.put(event.request, clone)
})
}
return response
})
)
})
)
})
// Push event - handle incoming push notifications
self.addEventListener('push', (event) => {
console.log('Service Worker: Push received')
let data = {
title: 'Medication Reminder',
body: 'Time to take your medication',
icon: '/icon-192.png',
badge: '/badge-72.png',
tag: 'medication-reminder',
data: {
url: '/meds',
},
}
if (event.data) {
try {
data = { ...data, ...event.data.json() }
} catch (e) {
console.error('Failed to parse push data:', e)
}
}
const options = {
body: data.body,
icon: data.icon || '/icon-192.png',
badge: data.badge || '/badge-72.png',
tag: data.tag || 'default',
vibrate: [100, 50, 100],
data: data.data || {},
actions: data.actions || [
{ action: 'take', title: 'Taken' },
{ action: 'snooze', title: 'Snooze' },
],
requireInteraction: true,
}
event.waitUntil(self.registration.showNotification(data.title, options))
})
// Notification click event - handle user interaction
self.addEventListener('notificationclick', (event) => {
console.log('Service Worker: Notification clicked', event.action)
event.notification.close()
const data = event.notification.data || {}
let url = data.url || '/meds'
// Handle different actions
if (event.action === 'take' && data.medicationId) {
// Open meds page with action to log dose
url = `/meds?action=take&id=${data.medicationId}`
} else if (event.action === 'snooze') {
// Schedule a new notification in 15 minutes
// This would require server-side logic
console.log('Snooze requested')
}
event.waitUntil(
clients.matchAll({ type: 'window', includeUncontrolled: true }).then((clientList) => {
// Try to focus an existing window
for (const client of clientList) {
if (client.url.includes('/') && 'focus' in client) {
client.navigate(url)
return client.focus()
}
}
// Open new window if none exists
if (clients.openWindow) {
return clients.openWindow(url)
}
})
)
})
// Background sync event - for offline dose logging
self.addEventListener('sync', (event) => {
if (event.tag === 'sync-doses') {
console.log('Service Worker: Syncing doses...')
// This would sync offline dose logs when connection is restored
}
})

View File

@@ -0,0 +1,133 @@
'use client'
import { useState, useEffect, useCallback } from 'react'
import { Loader2 } from 'lucide-react'
import { Card, LoadingState, Button } from '@/components/ui'
import { Header, PageContainer } from '@/components/layout/header'
import { ActivityItem } from '@/components/activity/ActivityItem'
import { ActivityFilter } from '@/components/activity/ActivityFilter'
import { useApp } from '../provider'
interface Activity {
id: string
action: string
entityType: string
entityId: string
details: Record<string, unknown> | null
createdAt: string
user: { id: string; name: string }
}
export default function ActivityPage() {
const { currentWorkspace } = useApp()
const [activities, setActivities] = useState<Activity[]>([])
const [loading, setLoading] = useState(true)
const [loadingMore, setLoadingMore] = useState(false)
const [hasMore, setHasMore] = useState(false)
const [entityType, setEntityType] = useState('')
const [offset, setOffset] = useState(0)
const fetchActivities = useCallback(async (reset = false) => {
const currentOffset = reset ? 0 : offset
if (reset) {
setLoading(true)
} else {
setLoadingMore(true)
}
try {
const params = new URLSearchParams({
limit: '50',
offset: String(currentOffset),
...(entityType && { entityType }),
})
const response = await fetch(
`/api/workspaces/${currentWorkspace.id}/activity?${params}`
)
if (!response.ok) throw new Error('Failed to fetch')
const data = await response.json()
if (reset) {
setActivities(data.activities)
setOffset(data.activities.length)
} else {
setActivities((prev) => [...prev, ...data.activities])
setOffset(currentOffset + data.activities.length)
}
setHasMore(data.hasMore)
} catch (err) {
console.error('Failed to fetch activities:', err)
} finally {
setLoading(false)
setLoadingMore(false)
}
}, [currentWorkspace.id, entityType, offset])
useEffect(() => {
fetchActivities(true)
}, [currentWorkspace.id, entityType])
const handleLoadMore = () => {
fetchActivities(false)
}
const handleEntityTypeChange = (type: string) => {
setEntityType(type)
setOffset(0)
}
if (loading) {
return (
<>
<Header title="Activity" showBack />
<PageContainer>
<LoadingState message="Loading activity..." />
</PageContainer>
</>
)
}
return (
<>
<Header title="Activity" showBack />
<PageContainer className="pt-4 space-y-4">
<ActivityFilter
entityType={entityType}
onEntityTypeChange={handleEntityTypeChange}
/>
{activities.length === 0 ? (
<Card variant="outline" className="text-center py-8">
<p className="text-secondary-500">No activity yet</p>
</Card>
) : (
<Card padding="none">
<div className="divide-y divide-border">
{activities.map((activity) => (
<div key={activity.id} className="px-4">
<ActivityItem activity={activity} />
</div>
))}
</div>
</Card>
)}
{hasMore && (
<div className="text-center pb-4">
<Button
variant="secondary"
onClick={handleLoadMore}
loading={loadingMore}
>
{loadingMore ? 'Loading...' : 'Load more'}
</Button>
</div>
)}
</PageContainer>
</>
)
}

View File

@@ -0,0 +1,262 @@
'use client'
import { useState, useEffect } from 'react'
import { useRouter, useParams } from 'next/navigation'
import { format, parseISO, isPast, isTomorrow, isToday } from 'date-fns'
import { toZonedTime } from 'date-fns-tz'
import {
Calendar,
MapPin,
Clock,
Edit,
Trash2,
ExternalLink,
ClipboardCheck,
ChevronRight,
} from 'lucide-react'
import { Card, Button, LoadingState, Modal, showToast } from '@/components/ui'
import { Header, PageContainer } from '@/components/layout/header'
import { useApp } from '../../provider'
const TIMEZONE = 'Australia/Perth'
interface Appointment {
id: string
title: string
datetime: string
location: string | null
mapUrl: string | null
notes: string | null
}
export default function AppointmentDetailPage() {
const router = useRouter()
const params = useParams()
const appointmentId = params.id as string
const { currentWorkspace, refreshData } = useApp()
const [appointment, setAppointment] = useState<Appointment | null>(null)
const [loading, setLoading] = useState(true)
const [showDelete, setShowDelete] = useState(false)
const [deleting, setDeleting] = useState(false)
useEffect(() => {
async function fetchAppointment() {
try {
const response = await fetch(
`/api/workspaces/${currentWorkspace.id}/appointments/${appointmentId}`
)
if (response.ok) {
const data = await response.json()
setAppointment(data.appointment)
}
} catch (err) {
console.error('Failed to fetch appointment:', err)
} finally {
setLoading(false)
}
}
fetchAppointment()
}, [currentWorkspace.id, appointmentId])
const handleDelete = async () => {
setDeleting(true)
try {
const response = await fetch(
`/api/workspaces/${currentWorkspace.id}/appointments/${appointmentId}`,
{ method: 'DELETE' }
)
if (!response.ok) throw new Error('Failed to delete')
showToast('Appointment deleted', 'success')
refreshData()
router.push('/appointments')
} catch {
showToast('Failed to delete', 'error')
} finally {
setDeleting(false)
setShowDelete(false)
}
}
if (loading) {
return (
<>
<Header title="Appointment" showBack />
<PageContainer>
<LoadingState message="Loading..." />
</PageContainer>
</>
)
}
if (!appointment) {
return (
<>
<Header title="Appointment" showBack />
<PageContainer className="pt-4">
<Card className="text-center py-8">
<p className="text-secondary-500">Appointment not found</p>
</Card>
</PageContainer>
</>
)
}
const apptDate = toZonedTime(parseISO(appointment.datetime), TIMEZONE)
const isInPast = isPast(apptDate)
const showPrepButton = !isInPast && (isToday(apptDate) || isTomorrow(apptDate) || !isPast(apptDate))
return (
<>
<Header
title="Appointment"
showBack
rightAction={{
icon: <Edit className="w-6 h-6 text-secondary-700" />,
label: 'Edit',
onClick: () => router.push(`/appointments/${appointmentId}/edit`),
}}
/>
<PageContainer className="pt-4 space-y-4">
{/* Main details */}
<Card>
<div className="flex items-start gap-4">
<div
className={`w-14 h-14 rounded-full flex items-center justify-center flex-shrink-0 ${
isInPast ? 'bg-secondary-100' : 'bg-primary-100'
}`}
>
<Calendar
className={`w-7 h-7 ${
isInPast ? 'text-secondary-400' : 'text-primary-600'
}`}
/>
</div>
<div className="flex-1">
<h1 className="text-xl font-bold text-secondary-900">
{appointment.title}
</h1>
<div className="mt-3 space-y-2">
<p
className={`flex items-center gap-2 ${
isInPast ? 'text-secondary-400' : 'text-secondary-700'
}`}
>
<Clock className="w-5 h-5" />
<span>
{format(apptDate, 'EEEE, MMMM d, yyyy')} at{' '}
{format(apptDate, 'h:mm a')}
</span>
</p>
{appointment.location && (
<div className="flex items-start gap-2 text-secondary-600">
<MapPin className="w-5 h-5 mt-0.5" />
<span>{appointment.location}</span>
</div>
)}
</div>
</div>
</div>
</Card>
{/* Map link */}
{appointment.mapUrl && (
<a
href={appointment.mapUrl}
target="_blank"
rel="noopener noreferrer"
className="block"
>
<Card className="hover:bg-secondary-50">
<div className="flex items-center gap-3">
<ExternalLink className="w-5 h-5 text-primary-600" />
<span className="font-medium text-primary-600">Open in Maps</span>
</div>
</Card>
</a>
)}
{/* Preparation checklist */}
{showPrepButton && (
<Card
onClick={() => router.push(`/appointments/${appointmentId}/prep`)}
className="hover:bg-secondary-50 cursor-pointer"
>
<div className="flex items-center gap-3">
<div className="p-2 bg-green-100 rounded-lg">
<ClipboardCheck className="w-5 h-5 text-green-600" />
</div>
<div className="flex-1">
<p className="font-medium text-secondary-900">
Prepare for this appointment
</p>
<p className="text-sm text-secondary-500">
Checklist to help you get ready
</p>
</div>
<ChevronRight className="w-5 h-5 text-secondary-300" />
</div>
</Card>
)}
{/* Notes */}
{appointment.notes && (
<Card>
<h2 className="font-semibold text-secondary-700 mb-2">Notes</h2>
<p className="text-secondary-600 whitespace-pre-wrap">
{appointment.notes}
</p>
</Card>
)}
{/* Delete button */}
<div className="pt-4">
<Button
variant="ghost"
className="text-red-600 hover:bg-red-50 w-full"
onClick={() => setShowDelete(true)}
>
<Trash2 className="w-4 h-4 mr-2" />
Delete Appointment
</Button>
</div>
</PageContainer>
{/* Delete confirmation modal */}
<Modal
isOpen={showDelete}
onClose={() => setShowDelete(false)}
title="Delete Appointment"
>
<div className="space-y-4">
<p className="text-secondary-600">
Are you sure you want to delete "{appointment.title}"? This action
cannot be undone.
</p>
<div className="flex gap-3">
<Button
variant="secondary"
onClick={() => setShowDelete(false)}
className="flex-1"
>
Cancel
</Button>
<Button
variant="danger"
onClick={handleDelete}
loading={deleting}
className="flex-1"
>
Delete
</Button>
</div>
</div>
</Modal>
</>
)
}

View File

@@ -0,0 +1,142 @@
'use client'
import { useState, useEffect } from 'react'
import { useParams } from 'next/navigation'
import { format, parseISO } from 'date-fns'
import { Calendar, MapPin, Clock, Printer } from 'lucide-react'
import { Card, LoadingState, Button } from '@/components/ui'
import { Header, PageContainer } from '@/components/layout/header'
import { PrepChecklist } from '@/components/appointments/PrepChecklist'
import { useApp } from '../../../provider'
interface Appointment {
id: string
title: string
datetime: string
location: string | null
notes: string | null
}
export default function AppointmentPrepPage() {
const params = useParams()
const appointmentId = params.id as string
const { currentWorkspace } = useApp()
const [appointment, setAppointment] = useState<Appointment | null>(null)
const [loading, setLoading] = useState(true)
useEffect(() => {
async function fetchAppointment() {
try {
const response = await fetch(
`/api/workspaces/${currentWorkspace.id}/appointments/${appointmentId}`
)
if (response.ok) {
const data = await response.json()
setAppointment(data.appointment)
}
} catch (err) {
console.error('Failed to fetch appointment:', err)
} finally {
setLoading(false)
}
}
fetchAppointment()
}, [currentWorkspace.id, appointmentId])
if (loading) {
return (
<>
<Header title="Prepare" showBack />
<PageContainer>
<LoadingState message="Loading..." />
</PageContainer>
</>
)
}
if (!appointment) {
return (
<>
<Header title="Prepare" showBack />
<PageContainer className="pt-4">
<Card className="text-center py-8">
<p className="text-secondary-500">Appointment not found</p>
</Card>
</PageContainer>
</>
)
}
const apptDate = parseISO(appointment.datetime)
return (
<>
<Header
title="Prepare for Appointment"
showBack
rightAction={{
icon: <Printer className="w-6 h-6 text-secondary-700" />,
label: 'Print',
onClick: () => window.print(),
}}
/>
<PageContainer className="pt-4 space-y-6">
{/* Appointment summary */}
<Card className="bg-primary-50 border border-primary-200">
<div className="flex items-start gap-3">
<div className="w-12 h-12 rounded-full bg-primary-100 flex items-center justify-center flex-shrink-0">
<Calendar className="w-6 h-6 text-primary-600" />
</div>
<div className="flex-1">
<h2 className="font-semibold text-lg text-secondary-900">
{appointment.title}
</h2>
<p className="text-primary-700 flex items-center gap-1 mt-1">
<Clock className="w-4 h-4" />
{format(apptDate, 'EEEE, MMMM d')} at {format(apptDate, 'h:mm a')}
</p>
{appointment.location && (
<p className="text-secondary-600 flex items-center gap-1 mt-1">
<MapPin className="w-4 h-4" />
{appointment.location}
</p>
)}
</div>
</div>
</Card>
{/* Tips */}
<div className="bg-blue-50 rounded-lg p-4">
<h3 className="font-medium text-blue-900 mb-2">Preparation Tips</h3>
<ul className="text-sm text-blue-800 list-disc list-inside space-y-1">
<li>Arrive 15 minutes early</li>
<li>Bring all items on your checklist</li>
<li>Have someone drive you if needed</li>
<li>Eat a light meal beforehand unless fasting</li>
</ul>
</div>
{/* Checklist */}
<div>
<h3 className="text-lg font-semibold text-secondary-900 mb-4">
Preparation Checklist
</h3>
<PrepChecklist
workspaceId={currentWorkspace.id}
appointmentId={appointmentId}
/>
</div>
{/* Notes */}
{appointment.notes && (
<Card>
<h3 className="font-medium text-secondary-700 mb-2">Notes</h3>
<p className="text-secondary-600 whitespace-pre-wrap">{appointment.notes}</p>
</Card>
)}
</PageContainer>
</>
)
}

View File

@@ -0,0 +1,115 @@
'use client'
import { useState, useEffect } from 'react'
import { useRouter } from 'next/navigation'
import { List, Plus } from 'lucide-react'
import { useLiveQuery } from 'dexie-react-hooks'
import { db } from '@/lib/sync'
import { Card, LoadingState, Button } from '@/components/ui'
import { Header, PageContainer } from '@/components/layout/header'
import { CalendarMonth } from '@/components/calendar/CalendarMonth'
import { useApp } from '../../provider'
export default function CalendarPage() {
const router = useRouter()
const { currentWorkspace } = useApp()
const [currentMonth, setCurrentMonth] = useState(new Date())
const [selectedDate, setSelectedDate] = useState(new Date())
const [loading, setLoading] = useState(true)
const [serverAppointments, setServerAppointments] = useState<any[]>([])
// Fetch from IndexedDB for offline support
const localAppointments = useLiveQuery(
() =>
db.appointments
.where('workspaceId')
.equals(currentWorkspace.id)
.and((a) => !a.deletedAt)
.toArray(),
[currentWorkspace.id]
)
// Also fetch from server for latest data
useEffect(() => {
async function fetchAppointments() {
try {
const response = await fetch(
`/api/workspaces/${currentWorkspace.id}/appointments`
)
if (response.ok) {
const data = await response.json()
setServerAppointments(data.appointments)
}
} catch (err) {
console.error('Failed to fetch appointments:', err)
} finally {
setLoading(false)
}
}
fetchAppointments()
}, [currentWorkspace.id])
// Prefer server data if available
const appointments = serverAppointments.length > 0 ? serverAppointments : localAppointments || []
if (loading && !localAppointments) {
return (
<>
<Header
title="Calendar"
showBack
rightAction={{
icon: <List className="w-6 h-6 text-secondary-700" />,
label: 'List view',
onClick: () => router.push('/appointments'),
}}
/>
<PageContainer>
<LoadingState message="Loading appointments..." />
</PageContainer>
</>
)
}
return (
<>
<Header
title="Calendar"
showBack
rightAction={{
icon: <List className="w-6 h-6 text-secondary-700" />,
label: 'List view',
onClick: () => router.push('/appointments'),
}}
/>
<PageContainer className="pt-4 space-y-4">
<Card>
<CalendarMonth
appointments={appointments.map((a: any) => ({
id: a.id,
title: a.title,
datetime: a.datetime,
location: a.location,
}))}
selectedDate={selectedDate}
onDateSelect={setSelectedDate}
onMonthChange={setCurrentMonth}
currentMonth={currentMonth}
/>
</Card>
{/* Add appointment FAB */}
<div className="fixed bottom-20 right-4 z-30">
<Button
onClick={() => router.push('/appointments/new')}
className="w-14 h-14 rounded-full shadow-lg flex items-center justify-center"
>
<Plus className="w-6 h-6" />
</Button>
</div>
</PageContainer>
</>
)
}

View File

@@ -3,11 +3,11 @@
import { useRouter } from 'next/navigation'
import { format, isToday, isTomorrow, parseISO, startOfDay } from 'date-fns'
import { toZonedTime } from 'date-fns-tz'
import { Plus, Calendar, MapPin, Clock, ChevronRight } from 'lucide-react'
import { Plus, Calendar, MapPin, Clock, ChevronRight, CalendarDays } from 'lucide-react'
import { useLiveQuery } from 'dexie-react-hooks'
import { db } from '@/lib/sync'
import { Card, LoadingState, EmptyState } from '@/components/ui'
import { Card, LoadingState, EmptyState, Button } from '@/components/ui'
import { Header, PageContainer } from '@/components/layout/header'
import { useApp } from '../provider'
@@ -59,9 +59,9 @@ export default function AppointmentsPage() {
<Header
title="Appointments"
rightAction={{
icon: <Plus className="w-6 h-6 text-secondary-700" />,
label: 'Add appointment',
onClick: () => router.push('/appointments/new'),
icon: <CalendarDays className="w-6 h-6 text-secondary-700" />,
label: 'Calendar view',
onClick: () => router.push('/appointments/calendar'),
}}
/>
<PageContainer>
@@ -76,9 +76,9 @@ export default function AppointmentsPage() {
<Header
title="Appointments"
rightAction={{
icon: <Plus className="w-6 h-6 text-secondary-700" />,
label: 'Add appointment',
onClick: () => router.push('/appointments/new'),
icon: <CalendarDays className="w-6 h-6 text-secondary-700" />,
label: 'Calendar view',
onClick: () => router.push('/appointments/calendar'),
}}
/>
<PageContainer className="pt-4">
@@ -158,6 +158,16 @@ export default function AppointmentsPage() {
})}
</div>
)}
{/* Add appointment FAB */}
<div className="fixed bottom-20 right-4 z-30">
<Button
onClick={() => router.push('/appointments/new')}
className="w-14 h-14 rounded-full shadow-lg flex items-center justify-center"
>
<Plus className="w-6 h-6" />
</Button>
</div>
</PageContainer>
</>
)

View File

@@ -0,0 +1,115 @@
'use client'
import { useEffect, useState } from 'react'
import { ArrowLeft, Edit2 } from 'lucide-react'
import { useRouter } from 'next/navigation'
import { useLiveQuery } from 'dexie-react-hooks'
import { db } from '@/lib/sync'
import { EmergencyCard } from '@/components/emergency/EmergencyCard'
import { Button, LoadingState } from '@/components/ui'
import { useApp } from '../provider'
export default function EmergencyPage() {
const router = useRouter()
const { currentWorkspace } = useApp()
// Fetch workspace from IndexedDB for offline access
const workspace = useLiveQuery(
() => db.workspaces.get(currentWorkspace.id),
[currentWorkspace.id]
)
// Fetch active medications
const medications = useLiveQuery(
() =>
db.medications
.where('workspaceId')
.equals(currentWorkspace.id)
.and((m) => m.active && !m.deletedAt)
.toArray(),
[currentWorkspace.id]
)
if (!workspace) {
return <LoadingState message="Loading emergency info..." />
}
const emergencyInfo = {
patientName: workspace.patientName,
patientDOB: workspace.patientDOB,
bloodType: workspace.bloodType,
allergies: workspace.allergies,
medicalConditions: workspace.medicalConditions,
primaryPhysician: workspace.primaryPhysician,
physicianPhone: workspace.physicianPhone,
clinicPhone: workspace.clinicPhone,
emergencyPhone: workspace.emergencyPhone,
}
const hasInfo = emergencyInfo.patientName || emergencyInfo.bloodType ||
emergencyInfo.allergies || emergencyInfo.medicalConditions
const medsList = medications?.map(m => ({
name: m.name,
instructions: m.instructions,
})) || []
return (
<div className="min-h-screen bg-red-50">
{/* Header */}
<div className="bg-red-600 text-white safe-top">
<div className="flex items-center justify-between px-4 py-3">
<button
onClick={() => router.back()}
className="flex items-center gap-2 text-white/90 hover:text-white"
>
<ArrowLeft className="w-5 h-5" />
<span>Back</span>
</button>
{currentWorkspace.role !== 'VIEWER' && (
<button
onClick={() => router.push('/settings/emergency')}
className="flex items-center gap-2 text-white/90 hover:text-white"
>
<Edit2 className="w-4 h-4" />
<span>Edit</span>
</button>
)}
</div>
</div>
<div className="p-4">
{hasInfo ? (
<EmergencyCard info={emergencyInfo} medications={medsList} />
) : (
<div className="text-center py-12">
<div className="w-16 h-16 rounded-full bg-red-100 flex items-center justify-center mx-auto mb-4">
<Edit2 className="w-8 h-8 text-red-400" />
</div>
<h2 className="text-lg font-semibold text-secondary-900 mb-2">
No Emergency Info Set
</h2>
<p className="text-secondary-600 mb-4">
Add important medical information for emergencies.
</p>
{currentWorkspace.role !== 'VIEWER' && (
<Button onClick={() => router.push('/settings/emergency')}>
Add Emergency Info
</Button>
)}
</div>
)}
</div>
{/* Offline indicator */}
<div className="fixed bottom-4 left-4 right-4">
<div className="bg-green-100 border border-green-300 rounded-lg p-3 text-center">
<p className="text-sm text-green-800 font-medium">
This information is available offline
</p>
</div>
</div>
</div>
)
}

View File

@@ -2,6 +2,7 @@ import { redirect } from 'next/navigation'
import { getSession } from '@/lib/auth'
import { prisma } from '@/lib/db/prisma'
import { BottomNav } from '@/components/layout/bottom-nav'
import { ServiceWorkerRegistrar } from '@/components/notifications/ServiceWorkerRegistrar'
import { AppProvider } from './provider'
export default async function AppLayout({
@@ -36,6 +37,8 @@ export default async function AppLayout({
clinicPhone: m.workspace.clinicPhone,
emergencyPhone: m.workspace.emergencyPhone,
largeTextMode: m.workspace.largeTextMode,
quietHoursStart: m.workspace.quietHoursStart,
quietHoursEnd: m.workspace.quietHoursEnd,
}))
return (
@@ -44,6 +47,7 @@ export default async function AppLayout({
workspaces={workspaces}
initialWorkspaceId={workspaces[0].id}
>
<ServiceWorkerRegistrar />
<div className={workspaces[0].largeTextMode ? 'large-text' : ''}>
{children}
<BottomNav />

View File

@@ -0,0 +1,247 @@
'use client'
import { use, useEffect, useState, useCallback } from 'react'
import { useRouter } from 'next/navigation'
import { format } from 'date-fns'
import { Pill, Clock, Edit2, Trash2, History } from 'lucide-react'
import { useLiveQuery } from 'dexie-react-hooks'
import { db, logDose, undoDose } from '@/lib/sync'
import { Card, Button, LoadingState, Modal, showToast, showUndoToast } from '@/components/ui'
import { Header, PageContainer } from '@/components/layout/header'
import { RefillTracker } from '@/components/medications/RefillTracker'
import { useApp } from '../../provider'
export default function MedicationDetailPage({ params }: { params: Promise<{ id: string }> }) {
const { id: medicationId } = use(params)
const router = useRouter()
const { currentWorkspace, refreshData } = useApp()
const [showDeleteModal, setShowDeleteModal] = useState(false)
const [deleting, setDeleting] = useState(false)
// Fetch medication from IndexedDB
const medication = useLiveQuery(
() => db.medications.get(medicationId),
[medicationId]
)
// Fetch recent dose logs
const doseLogs = useLiveQuery(
() =>
db.doseLogs
.where('medicationId')
.equals(medicationId)
.reverse()
.limit(10)
.toArray(),
[medicationId]
)
const handleRefresh = useCallback(async () => {
await refreshData()
}, [refreshData])
const handleTakeDose = useCallback(async () => {
if (!medication) return
try {
const doseLog = await logDose(
currentWorkspace.id,
medication.id,
{ id: medication.id, name: medication.name }
)
showUndoToast(`Took ${medication.name}`, async () => {
await undoDose(doseLog)
showToast('Dose undone', 'info')
})
} catch {
showToast('Failed to log dose', 'error')
}
}, [medication, currentWorkspace.id])
const handleDelete = async () => {
if (!medication) return
setDeleting(true)
try {
const response = await fetch(
`/api/workspaces/${currentWorkspace.id}/medications/${medication.id}`,
{ method: 'DELETE' }
)
if (!response.ok) throw new Error('Failed to delete')
await refreshData()
showToast('Medication deleted', 'success')
router.push('/meds')
} catch {
showToast('Failed to delete medication', 'error')
} finally {
setDeleting(false)
setShowDeleteModal(false)
}
}
const formatSchedule = () => {
if (!medication) return ''
const data = medication.scheduleData as Record<string, unknown>
switch (medication.scheduleType) {
case 'FIXED_TIMES':
return `Daily at ${(data.times as string[]).join(', ')}`
case 'INTERVAL':
return `Every ${data.hours} hours (starting ${data.startTime})`
case 'WEEKDAYS':
const days = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']
const selectedDays = (data.days as number[]).map(d => days[d]).join(', ')
return `${selectedDays} at ${data.time}`
case 'PRN':
return `As needed (min ${data.minHoursBetween}h between doses)`
default:
return medication.scheduleType
}
}
if (!medication) {
return (
<>
<Header title="Medication" showBack />
<PageContainer>
<LoadingState message="Loading medication..." />
</PageContainer>
</>
)
}
const recentDoses = doseLogs?.filter(d => !d.undoneAt) || []
return (
<>
<Header
title={medication.name}
showBack
rightAction={
currentWorkspace.role !== 'VIEWER'
? {
icon: <Trash2 className="w-6 h-6 text-red-600" />,
label: 'Delete',
onClick: () => setShowDeleteModal(true),
}
: undefined
}
/>
<PageContainer className="pt-4 space-y-6">
{/* Status Card */}
<Card className={medication.active ? '' : 'opacity-60'}>
<div className="flex items-start gap-4">
<div className="w-12 h-12 rounded-full bg-primary-100 flex items-center justify-center">
<Pill className="w-6 h-6 text-primary-600" />
</div>
<div className="flex-1">
<h2 className="text-lg font-semibold text-secondary-900">
{medication.name}
</h2>
<p className="text-sm text-secondary-600 flex items-center gap-1 mt-1">
<Clock className="w-4 h-4" />
{formatSchedule()}
</p>
{medication.instructions && (
<p className="text-sm text-secondary-500 mt-2">
{medication.instructions}
</p>
)}
{!medication.active && (
<p className="text-sm text-orange-600 font-medium mt-2">
Inactive
</p>
)}
</div>
</div>
{currentWorkspace.role !== 'VIEWER' && medication.active && (
<Button
onClick={handleTakeDose}
variant="success"
fullWidth
className="mt-4"
>
Mark as Taken
</Button>
)}
</Card>
{/* Refill Tracker */}
{medication.pillCount !== null && (
<RefillTracker
medicationId={medication.id}
workspaceId={currentWorkspace.id}
medicationName={medication.name}
pillCount={medication.pillCount}
pillsPerDose={medication.pillsPerDose}
refillThreshold={medication.refillThreshold}
onRefill={handleRefresh}
/>
)}
{/* Recent Doses */}
<section>
<div className="flex items-center justify-between mb-3">
<h3 className="text-sm font-semibold text-secondary-600">Recent Doses</h3>
<button
onClick={() => router.push('/meds/history')}
className="text-sm text-primary-600 font-medium flex items-center gap-1"
>
<History className="w-4 h-4" />
Full history
</button>
</div>
{recentDoses.length > 0 ? (
<Card padding="none">
<ul className="divide-y divide-border">
{recentDoses.map((dose) => (
<li key={dose.id} className="px-4 py-3 flex items-center justify-between">
<div>
<p className="text-sm font-medium text-secondary-900">
{format(new Date(dose.takenAt), 'EEEE, MMM d')}
</p>
<p className="text-xs text-secondary-500">
{format(new Date(dose.takenAt), 'h:mm a')}
{dose.loggedBy && ` by ${dose.loggedBy.name}`}
</p>
</div>
</li>
))}
</ul>
</Card>
) : (
<Card variant="outline" className="text-center py-6">
<p className="text-secondary-500">No doses logged yet</p>
</Card>
)}
</section>
</PageContainer>
{/* Delete Confirmation Modal */}
<Modal
isOpen={showDeleteModal}
onClose={() => setShowDeleteModal(false)}
title="Delete Medication"
>
<p className="text-secondary-600 mb-4">
Are you sure you want to delete "{medication.name}"? This action cannot be undone.
</p>
<div className="flex gap-3">
<Button
variant="secondary"
fullWidth
onClick={() => setShowDeleteModal(false)}
>
Cancel
</Button>
<Button
variant="danger"
fullWidth
onClick={handleDelete}
loading={deleting}
>
Delete
</Button>
</div>
</Modal>
</>
)
}

View File

@@ -47,6 +47,12 @@ export default function NewMedicationPage() {
// PRN
const [minHoursBetween, setMinHoursBetween] = useState(4)
// Refill tracking (optional)
const [trackRefills, setTrackRefills] = useState(false)
const [pillCount, setPillCount] = useState<number | ''>('')
const [pillsPerDose, setPillsPerDose] = useState(1)
const [refillThreshold, setRefillThreshold] = useState(7)
const [loading, setLoading] = useState(false)
const [error, setError] = useState('')
@@ -102,6 +108,12 @@ export default function NewMedicationPage() {
scheduleType,
scheduleData: buildScheduleData(),
active: true,
// Refill tracking (optional)
...(trackRefills && pillCount !== '' && {
pillCount: Number(pillCount),
pillsPerDose,
refillThreshold,
}),
}),
}
)
@@ -248,6 +260,53 @@ export default function NewMedicationPage() {
/>
)}
{/* Refill Tracking (optional) */}
<div className="border-t border-border pt-5">
<div className="flex items-center gap-3 mb-4">
<input
type="checkbox"
id="trackRefills"
checked={trackRefills}
onChange={(e) => setTrackRefills(e.target.checked)}
className="w-5 h-5 rounded border-border text-primary-600 focus:ring-primary-500"
/>
<label htmlFor="trackRefills" className="text-sm font-medium text-secondary-700">
Track pill count for refill reminders (optional)
</label>
</div>
{trackRefills && (
<div className="space-y-4 pl-8">
<Input
label="Current pill count"
type="number"
min={0}
value={pillCount}
onChange={(e) => setPillCount(e.target.value === '' ? '' : parseInt(e.target.value))}
placeholder="e.g., 30"
helperText="How many pills do you have now?"
/>
<div className="grid grid-cols-2 gap-3">
<Input
label="Pills per dose"
type="number"
min={1}
value={pillsPerDose}
onChange={(e) => setPillsPerDose(parseInt(e.target.value) || 1)}
/>
<Input
label="Alert when below"
type="number"
min={0}
value={refillThreshold}
onChange={(e) => setRefillThreshold(parseInt(e.target.value) || 7)}
helperText="pills"
/>
</div>
</div>
)}
</div>
{error && (
<p className="text-sm text-red-600 bg-red-50 px-3 py-2 rounded-button">
{error}

View File

@@ -2,7 +2,7 @@
import { useEffect, useState, useCallback } from 'react'
import { useRouter } from 'next/navigation'
import { Plus, Pill, Clock, ChevronRight, History } from 'lucide-react'
import { Plus, Pill, Clock, ChevronRight, History, AlertTriangle } from 'lucide-react'
import { useLiveQuery } from 'dexie-react-hooks'
import { db, logDose, undoDose } from '@/lib/sync'
@@ -10,6 +10,7 @@ import { calculateAllMedicationsDue, formatTimeUntil } from '@/lib/schedule'
import type { Medication, DoseLog, MedicationDueStatus } from '@/lib/schedule'
import { Card, Button, LoadingState, EmptyState, showUndoToast, showToast } from '@/components/ui'
import { Header, PageContainer } from '@/components/layout/header'
import { RefillAlert } from '@/components/medications/RefillAlert'
import { useApp } from '../provider'
export default function MedsPage() {
@@ -119,16 +120,26 @@ export default function MedsPage() {
onClick: () => router.push('/meds/new'),
}}
/>
<PageContainer className="pt-4">
<PageContainer className="pt-4 space-y-4">
{/* History link */}
<button
onClick={() => router.push('/meds/history')}
className="flex items-center gap-2 text-primary-600 font-medium mb-4"
className="flex items-center gap-2 text-primary-600 font-medium"
>
<History className="w-4 h-4" />
View dose history
</button>
{/* Refill Alerts */}
<RefillAlert
medications={medications.map(m => ({
id: m.id,
name: m.name,
pillCount: m.pillCount,
refillThreshold: m.refillThreshold,
}))}
/>
{medications.length === 0 ? (
<EmptyState
type="medications"
@@ -206,6 +217,12 @@ interface MedicationCardProps {
function MedicationCard({ status, now, onTake, onClick }: MedicationCardProps) {
const { medication, isOverdue, isPRN, prnAvailable, prnAvailableAt, nextDueAt } = status
// Check for low pill count
const med = medication as unknown as { pillCount?: number | null; refillThreshold?: number | null }
const isLowOnPills = med.pillCount !== undefined && med.pillCount !== null &&
med.refillThreshold !== undefined && med.refillThreshold !== null &&
med.pillCount <= med.refillThreshold
const getTimeLabel = () => {
if (isOverdue && nextDueAt) {
return formatTimeUntil(nextDueAt, now)
@@ -230,11 +247,18 @@ function MedicationCard({ status, now, onTake, onClick }: MedicationCardProps) {
return (
<Card className={isOverdue ? 'overdue' : ''} onClick={onClick}>
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-full bg-primary-100 flex items-center justify-center flex-shrink-0">
<Pill className="w-5 h-5 text-primary-600" />
<div className={`w-10 h-10 rounded-full flex items-center justify-center flex-shrink-0 ${isLowOnPills ? 'bg-orange-100' : 'bg-primary-100'}`}>
<Pill className={`w-5 h-5 ${isLowOnPills ? 'text-orange-600' : 'text-primary-600'}`} />
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<h3 className="font-semibold text-secondary-900">{medication.name}</h3>
{isLowOnPills && (
<span className="px-1.5 py-0.5 text-xs font-medium bg-orange-100 text-orange-700 rounded">
{med.pillCount} left
</span>
)}
</div>
<p className={`text-sm flex items-center gap-1 ${isOverdue ? 'text-red-600 font-medium' : 'text-secondary-500'}`}>
<Clock className="w-3.5 h-3.5" />
{getTimeLabel()}

View File

@@ -6,7 +6,7 @@ import { toZonedTime } from 'date-fns-tz'
import { HelpCircle, CheckCircle, Copy } from 'lucide-react'
import { useLiveQuery } from 'dexie-react-hooks'
import { db, markQuestionAsked } from '@/lib/sync'
import { db, markQuestionAsked, unmarkQuestionAsked } from '@/lib/sync'
import { Card, Button, LoadingState, EmptyState, showToast } from '@/components/ui'
import { Header, PageContainer } from '@/components/layout/header'
import { useApp } from '../../provider'
@@ -46,6 +46,22 @@ export default function QuestionsPage() {
[questions, refreshData]
)
const handleUnmarkAsked = useCallback(
async (noteId: string) => {
const note = questions?.find((n) => n.id === noteId)
if (!note) return
try {
await unmarkQuestionAsked(note)
await refreshData()
showToast('Moved back to "To Ask"', 'success')
} catch {
showToast('Failed to update', 'error')
}
},
[questions, refreshData]
)
const copyAllQuestions = () => {
const text = unanswered.map((q) => `${q.content}`).join('\n')
navigator.clipboard.writeText(text)
@@ -129,7 +145,7 @@ export default function QuestionsPage() {
</h2>
<div className="space-y-2">
{answered.map((note) => (
<Card key={note.id} className="opacity-60">
<Card key={note.id} className="opacity-75">
<div className="flex items-start gap-3">
<div className="w-8 h-8 rounded-full bg-green-100 flex items-center justify-center flex-shrink-0">
<CheckCircle className="w-5 h-5 text-green-600" />
@@ -144,6 +160,13 @@ export default function QuestionsPage() {
)}
</p>
</div>
<Button
variant="secondary"
size="sm"
onClick={() => handleUnmarkAsked(note.id)}
>
Undo
</Button>
</div>
</Card>
))}

View File

@@ -0,0 +1,157 @@
'use client'
import { useState, useEffect } from 'react'
import { format, isAfter, parseISO, addMonths } from 'date-fns'
import { Printer, MapPin } from 'lucide-react'
import { Button, LoadingState } from '@/components/ui'
import { Header, PageContainer } from '@/components/layout/header'
import { useApp } from '../../provider'
import '@/styles/print.css'
interface Appointment {
id: string
title: string
datetime: string
location: string | null
mapUrl: string | null
notes: string | null
}
export default function AppointmentsPrintPage() {
const { currentWorkspace } = useApp()
const [appointments, setAppointments] = useState<Appointment[]>([])
const [loading, setLoading] = useState(true)
useEffect(() => {
async function fetchAppointments() {
try {
const response = await fetch(`/api/workspaces/${currentWorkspace.id}/appointments`)
if (response.ok) {
const data = await response.json()
// Filter to upcoming appointments within next 3 months
const now = new Date()
const threeMonthsFromNow = addMonths(now, 3)
const upcoming = data.appointments
.filter((a: Appointment) => {
const apptDate = parseISO(a.datetime)
return isAfter(apptDate, now) && isAfter(threeMonthsFromNow, apptDate)
})
.sort(
(a: Appointment, b: Appointment) =>
parseISO(a.datetime).getTime() - parseISO(b.datetime).getTime()
)
setAppointments(upcoming)
}
} catch (err) {
console.error('Failed to fetch appointments:', err)
} finally {
setLoading(false)
}
}
fetchAppointments()
}, [currentWorkspace.id])
const handlePrint = () => {
window.print()
}
if (loading) {
return (
<>
<Header title="Upcoming Appointments" showBack />
<PageContainer>
<LoadingState message="Loading appointments..." />
</PageContainer>
</>
)
}
const today = format(new Date(), 'MMMM d, yyyy')
return (
<>
<div className="screen-only">
<Header title="Upcoming Appointments" showBack />
<PageContainer className="pt-4">
<div className="flex justify-between items-center mb-4">
<p className="text-secondary-600">Preview your printable appointments list</p>
<Button onClick={handlePrint} className="flex items-center gap-2">
<Printer className="w-4 h-4" />
Print
</Button>
</div>
</PageContainer>
</div>
<div className="print-preview p-8">
<div className="print-title text-2xl font-bold mb-2">Upcoming Appointments</div>
<div className="print-date text-gray-600 mb-6">Generated: {today}</div>
{currentWorkspace.name && (
<div className="print-subtitle text-lg font-semibold mb-4">
Patient: {currentWorkspace.name}
</div>
)}
{appointments.length === 0 ? (
<p className="text-gray-500">No upcoming appointments scheduled</p>
) : (
<table className="print-table w-full border-collapse">
<thead>
<tr className="bg-gray-100">
<th className="border border-gray-300 p-3 text-left font-semibold">Date & Time</th>
<th className="border border-gray-300 p-3 text-left font-semibold">Appointment</th>
<th className="border border-gray-300 p-3 text-left font-semibold">Location</th>
<th className="border border-gray-300 p-3 text-left font-semibold">Notes</th>
</tr>
</thead>
<tbody>
{appointments.map((appt) => (
<tr key={appt.id} className="print-no-break">
<td className="border border-gray-300 p-3 whitespace-nowrap">
<div className="font-semibold">
{format(parseISO(appt.datetime), 'EEE, MMM d')}
</div>
<div className="text-gray-600">
{format(parseISO(appt.datetime), 'h:mm a')}
</div>
</td>
<td className="border border-gray-300 p-3">
<div className="font-medium">{appt.title}</div>
</td>
<td className="border border-gray-300 p-3">
{appt.location && (
<div className="flex items-start gap-1">
<MapPin className="w-4 h-4 mt-0.5 text-gray-500 flex-shrink-0" />
<span>{appt.location}</span>
</div>
)}
</td>
<td className="border border-gray-300 p-3 text-gray-600 text-sm">
{appt.notes || '-'}
</td>
</tr>
))}
</tbody>
</table>
)}
<div className="mt-8 pt-4 border-t border-gray-300">
<div className="print-subtitle font-semibold mb-2">Reminders:</div>
<ul className="list-disc list-inside text-sm text-gray-600 space-y-1">
<li>Bring insurance card and photo ID</li>
<li>Arrive 15 minutes early for new appointments</li>
<li>Bring a list of current medications</li>
<li>Prepare questions for your doctor</li>
</ul>
</div>
<div className="print-footer mt-8 text-center text-sm text-gray-500">
Generated by NextStep - {today}
</div>
</div>
</>
)
}

View File

@@ -0,0 +1,208 @@
'use client'
import { useState, useEffect } from 'react'
import { format } from 'date-fns'
import { Printer } from 'lucide-react'
import { Button, LoadingState } from '@/components/ui'
import { Header, PageContainer } from '@/components/layout/header'
import { useApp } from '../../provider'
import '@/styles/print.css'
interface Medication {
id: string
name: string
instructions: string | null
scheduleType: string
scheduleData: {
type: string
times?: string[]
hours?: number
startTime?: string
time?: string
days?: number[]
}
active: boolean
}
interface MedTime {
time: string
medications: { id: string; name: string; instructions: string | null }[]
}
function parseScheduleTimes(med: Medication): string[] {
const { scheduleType, scheduleData } = med
switch (scheduleType) {
case 'FIXED_TIMES':
return scheduleData.times || []
case 'INTERVAL':
// Generate times based on interval
const times: string[] = []
const startHour = parseInt(scheduleData.startTime?.split(':')[0] || '8')
const hours = scheduleData.hours || 4
for (let h = startHour; h < 24; h += hours) {
const hourStr = h.toString().padStart(2, '0')
times.push(`${hourStr}:00`)
}
return times
case 'WEEKDAYS':
return scheduleData.time ? [scheduleData.time] : []
case 'PRN':
return ['As needed']
default:
return []
}
}
function groupMedicationsByTime(medications: Medication[]): MedTime[] {
const timeMap = new Map<string, { id: string; name: string; instructions: string | null }[]>()
for (const med of medications) {
if (!med.active) continue
const times = parseScheduleTimes(med)
for (const time of times) {
if (!timeMap.has(time)) {
timeMap.set(time, [])
}
timeMap.get(time)!.push({
id: med.id,
name: med.name,
instructions: med.instructions,
})
}
}
// Sort by time
const sorted = Array.from(timeMap.entries())
.filter(([time]) => time !== 'As needed')
.sort(([a], [b]) => a.localeCompare(b))
.map(([time, medications]) => ({ time, medications }))
// Add PRN meds at the end
const prnMeds = timeMap.get('As needed')
if (prnMeds && prnMeds.length > 0) {
sorted.push({ time: 'As needed', medications: prnMeds })
}
return sorted
}
function formatTime(time: string): string {
if (time === 'As needed') return time
const [hours, minutes] = time.split(':').map(Number)
const ampm = hours >= 12 ? 'PM' : 'AM'
const displayHours = hours % 12 || 12
return `${displayHours}:${minutes.toString().padStart(2, '0')} ${ampm}`
}
export default function DailyMedsPrintPage() {
const { currentWorkspace } = useApp()
const [medications, setMedications] = useState<Medication[]>([])
const [loading, setLoading] = useState(true)
useEffect(() => {
async function fetchMedications() {
try {
const response = await fetch(`/api/workspaces/${currentWorkspace.id}/medications`)
if (response.ok) {
const data = await response.json()
setMedications(data.medications.filter((m: Medication) => m.active))
}
} catch (err) {
console.error('Failed to fetch medications:', err)
} finally {
setLoading(false)
}
}
fetchMedications()
}, [currentWorkspace.id])
const handlePrint = () => {
window.print()
}
if (loading) {
return (
<>
<Header title="Daily Medications" showBack />
<PageContainer>
<LoadingState message="Loading medications..." />
</PageContainer>
</>
)
}
const medTimes = groupMedicationsByTime(medications)
const today = format(new Date(), 'EEEE, MMMM d, yyyy')
return (
<>
<div className="screen-only">
<Header title="Daily Medications" showBack />
<PageContainer className="pt-4">
<div className="flex justify-between items-center mb-4">
<p className="text-secondary-600">Preview your printable medication schedule</p>
<Button onClick={handlePrint} className="flex items-center gap-2">
<Printer className="w-4 h-4" />
Print
</Button>
</div>
</PageContainer>
</div>
<div className="print-preview p-8">
<div className="print-title text-2xl font-bold mb-2">Daily Medication Schedule</div>
<div className="print-date text-gray-600 mb-6">{today}</div>
{currentWorkspace.name && (
<div className="print-subtitle text-lg font-semibold mb-4">
Patient: {currentWorkspace.name}
</div>
)}
{medTimes.length === 0 ? (
<p className="text-gray-500">No medications scheduled</p>
) : (
<div className="space-y-6">
{medTimes.map((medTime, idx) => (
<div key={idx} className="print-section print-no-break">
<div className="flex items-center gap-3 mb-3 pb-2 border-b-2 border-gray-300">
<span className="print-subtitle text-xl font-bold">
{formatTime(medTime.time)}
</span>
</div>
<div className="space-y-3">
{medTime.medications.map((med) => (
<div key={med.id} className="print-med-item flex items-start gap-3 py-2">
<div className="print-checkbox w-6 h-6 border-2 border-black flex-shrink-0 mt-1" />
<div className="flex-1">
<div className="print-med-name text-lg font-semibold">{med.name}</div>
{med.instructions && (
<div className="print-text text-gray-600 text-sm mt-1">
{med.instructions}
</div>
)}
</div>
</div>
))}
</div>
</div>
))}
</div>
)}
<div className="mt-8 pt-4 border-t border-gray-300">
<div className="print-subtitle font-semibold mb-2">Notes:</div>
<div className="print-notes border border-gray-300 rounded p-3 min-h-[100px] bg-gray-50" />
</div>
<div className="print-footer mt-8 text-center text-sm text-gray-500">
Generated by NextStep - {today}
</div>
</div>
</>
)
}

View File

@@ -0,0 +1,323 @@
'use client'
import { useState, useEffect } from 'react'
import { format, subDays } from 'date-fns'
import { Printer } from 'lucide-react'
import { Button, LoadingState } from '@/components/ui'
import { Header, PageContainer } from '@/components/layout/header'
import { useApp } from '../../provider'
import '@/styles/print.css'
interface Medication {
id: string
name: string
instructions: string | null
scheduleType: string
active: boolean
}
interface Symptom {
id: string
type: string
customName: string | null
severity: number
notes: string | null
recordedAt: string
}
interface Note {
id: string
type: string
content: string
askedAt: string | null
}
const SYMPTOM_LABELS: Record<string, string> = {
FATIGUE: 'Fatigue',
NAUSEA: 'Nausea',
PAIN: 'Pain',
APPETITE: 'Appetite Changes',
SLEEP: 'Sleep Issues',
MOOD: 'Mood Changes',
CUSTOM: 'Other',
}
const SEVERITY_LABELS = ['Minimal', 'Mild', 'Moderate', 'Severe', 'Extreme']
export default function DoctorVisitPrintPage() {
const { currentWorkspace } = useApp()
const [medications, setMedications] = useState<Medication[]>([])
const [symptoms, setSymptoms] = useState<Symptom[]>([])
const [questions, setQuestions] = useState<Note[]>([])
const [loading, setLoading] = useState(true)
useEffect(() => {
async function fetchData() {
try {
// Fetch medications
const medsResponse = await fetch(
`/api/workspaces/${currentWorkspace.id}/medications`
)
if (medsResponse.ok) {
const data = await medsResponse.json()
setMedications(data.medications.filter((m: Medication) => m.active))
}
// Fetch symptoms from last 30 days
const symptomsResponse = await fetch(
`/api/workspaces/${currentWorkspace.id}/symptoms?limit=50`
)
if (symptomsResponse.ok) {
const data = await symptomsResponse.json()
const thirtyDaysAgo = subDays(new Date(), 30)
setSymptoms(
data.symptoms.filter(
(s: Symptom) => new Date(s.recordedAt) >= thirtyDaysAgo
)
)
}
// Fetch questions (unasked notes)
const notesResponse = await fetch(
`/api/workspaces/${currentWorkspace.id}/notes`
)
if (notesResponse.ok) {
const data = await notesResponse.json()
setQuestions(
data.notes.filter(
(n: Note) => n.type === 'QUESTION' && !n.askedAt
)
)
}
} catch (err) {
console.error('Failed to fetch data:', err)
} finally {
setLoading(false)
}
}
fetchData()
}, [currentWorkspace.id])
const handlePrint = () => {
window.print()
}
if (loading) {
return (
<>
<Header title="Doctor's Visit Summary" showBack />
<PageContainer>
<LoadingState message="Loading summary..." />
</PageContainer>
</>
)
}
const today = format(new Date(), 'MMMM d, yyyy')
// Group symptoms by type for summary
const symptomSummary = symptoms.reduce(
(acc, s) => {
const key = s.type
if (!acc[key]) {
acc[key] = { count: 0, totalSeverity: 0, maxSeverity: 0 }
}
acc[key].count++
acc[key].totalSeverity += s.severity
acc[key].maxSeverity = Math.max(acc[key].maxSeverity, s.severity)
return acc
},
{} as Record<string, { count: number; totalSeverity: number; maxSeverity: number }>
)
return (
<>
<div className="screen-only">
<Header title="Doctor's Visit Summary" showBack />
<PageContainer className="pt-4">
<div className="flex justify-between items-center mb-4">
<p className="text-secondary-600">
Complete summary for your doctor appointment
</p>
<Button onClick={handlePrint} className="flex items-center gap-2">
<Printer className="w-4 h-4" />
Print
</Button>
</div>
</PageContainer>
</div>
<div className="print-preview p-8">
<div className="print-title text-2xl font-bold mb-2">
Doctor's Visit Summary
</div>
<div className="print-date text-gray-600 mb-6">Prepared: {today}</div>
{/* Patient Info */}
<div className="print-section print-no-break mb-6 p-4 bg-gray-50 rounded-lg">
<div className="print-subtitle font-semibold mb-3">Patient Information</div>
<div className="grid grid-cols-2 gap-4 text-sm">
<div>
<span className="text-gray-600">Name:</span>{' '}
<span className="font-medium">
{(currentWorkspace as any).patientName || currentWorkspace.name || '_______________'}
</span>
</div>
<div>
<span className="text-gray-600">DOB:</span>{' '}
<span className="font-medium">
{(currentWorkspace as any).patientDOB
? format(new Date((currentWorkspace as any).patientDOB), 'MM/dd/yyyy')
: '_______________'}
</span>
</div>
<div>
<span className="text-gray-600">Blood Type:</span>{' '}
<span className="font-medium">
{(currentWorkspace as any).bloodType || '_______________'}
</span>
</div>
</div>
{(currentWorkspace as any).allergies && (
<div className="mt-3">
<span className="text-gray-600">Allergies:</span>{' '}
<span className="font-medium text-red-600">
{(currentWorkspace as any).allergies}
</span>
</div>
)}
{(currentWorkspace as any).medicalConditions && (
<div className="mt-2">
<span className="text-gray-600">Medical Conditions:</span>{' '}
<span className="font-medium">
{(currentWorkspace as any).medicalConditions}
</span>
</div>
)}
</div>
{/* Current Medications */}
<div className="print-section print-no-break mb-6">
<div className="print-subtitle font-semibold mb-3">
Current Medications ({medications.length})
</div>
{medications.length === 0 ? (
<p className="text-gray-500 text-sm">No active medications</p>
) : (
<table className="print-table w-full border-collapse text-sm">
<thead>
<tr className="bg-gray-100">
<th className="border border-gray-300 p-2 text-left">Medication</th>
<th className="border border-gray-300 p-2 text-left">Instructions</th>
<th className="border border-gray-300 p-2 text-left">Schedule</th>
</tr>
</thead>
<tbody>
{medications.map((med) => (
<tr key={med.id}>
<td className="border border-gray-300 p-2 font-medium">
{med.name}
</td>
<td className="border border-gray-300 p-2">
{med.instructions || '-'}
</td>
<td className="border border-gray-300 p-2">
{med.scheduleType.replace('_', ' ')}
</td>
</tr>
))}
</tbody>
</table>
)}
</div>
{/* Symptom Summary */}
<div className="print-section print-no-break mb-6">
<div className="print-subtitle font-semibold mb-3">
Symptoms (Last 30 Days)
</div>
{Object.keys(symptomSummary).length === 0 ? (
<p className="text-gray-500 text-sm">No symptoms recorded</p>
) : (
<table className="print-table w-full border-collapse text-sm">
<thead>
<tr className="bg-gray-100">
<th className="border border-gray-300 p-2 text-left">Symptom</th>
<th className="border border-gray-300 p-2 text-left">Occurrences</th>
<th className="border border-gray-300 p-2 text-left">Avg Severity</th>
<th className="border border-gray-300 p-2 text-left">Max Severity</th>
</tr>
</thead>
<tbody>
{Object.entries(symptomSummary)
.sort((a, b) => b[1].count - a[1].count)
.map(([type, data]) => (
<tr key={type}>
<td className="border border-gray-300 p-2 font-medium">
{SYMPTOM_LABELS[type] || type}
</td>
<td className="border border-gray-300 p-2">{data.count}x</td>
<td className="border border-gray-300 p-2">
{SEVERITY_LABELS[Math.round(data.totalSeverity / data.count) - 1]}
</td>
<td className="border border-gray-300 p-2">
{SEVERITY_LABELS[data.maxSeverity - 1]}
</td>
</tr>
))}
</tbody>
</table>
)}
</div>
{/* Questions to Ask */}
<div className="print-section mb-6">
<div className="print-subtitle font-semibold mb-3">
Questions to Ask ({questions.length})
</div>
{questions.length === 0 ? (
<p className="text-gray-500 text-sm">No questions prepared</p>
) : (
<div className="space-y-2">
{questions.map((q, idx) => (
<div key={q.id} className="print-question flex items-start gap-2 py-2">
<div className="print-question-checkbox w-4 h-4 border-2 border-black flex-shrink-0 mt-0.5" />
<span>
{idx + 1}. {q.content}
</span>
</div>
))}
</div>
)}
</div>
{/* Doctor's Notes Section */}
<div className="print-section print-page-break">
<div className="print-subtitle font-semibold mb-3">Doctor's Notes</div>
<div className="print-notes border border-gray-300 rounded p-3 min-h-[200px] bg-gray-50" />
</div>
{/* Follow-up */}
<div className="print-section mt-6">
<div className="print-subtitle font-semibold mb-3">Follow-up</div>
<div className="grid grid-cols-2 gap-4 text-sm">
<div>
<span className="text-gray-600">Next Appointment:</span>{' '}
<span>________________________</span>
</div>
<div>
<span className="text-gray-600">Labs Ordered:</span>{' '}
<span>________________________</span>
</div>
</div>
</div>
<div className="print-footer mt-8 text-center text-sm text-gray-500">
Generated by NextStep - {today}
</div>
</div>
</>
)
}

View File

@@ -0,0 +1,74 @@
'use client'
import { Printer, Pill, Calendar, Stethoscope } from 'lucide-react'
import Link from 'next/link'
import { Card } from '@/components/ui'
import { Header, PageContainer } from '@/components/layout/header'
const printOptions = [
{
href: '/print/daily-meds',
icon: Pill,
title: 'Daily Medication Schedule',
description: 'Large checkboxes for tracking daily doses. Great for posting on the fridge.',
},
{
href: '/print/appointments',
icon: Calendar,
title: 'Upcoming Appointments',
description: 'List of upcoming appointments with dates, times, and locations.',
},
{
href: '/print/doctor-visit',
icon: Stethoscope,
title: "Doctor's Visit Summary",
description: 'Complete summary with medications, symptoms, and questions to ask.',
},
]
export default function PrintPage() {
return (
<>
<Header title="Print" showBack />
<PageContainer className="pt-4">
<div className="mb-6">
<div className="flex items-center gap-3 mb-2">
<div className="p-2 bg-primary-100 rounded-lg">
<Printer className="w-6 h-6 text-primary-600" />
</div>
<h2 className="text-lg font-semibold text-secondary-900">Print Documents</h2>
</div>
<p className="text-secondary-600 text-sm">
Generate printable documents for caregiving, appointments, and medication tracking.
</p>
</div>
<div className="space-y-3">
{printOptions.map((option) => (
<Link key={option.href} href={option.href}>
<Card className="hover:bg-secondary-50 transition-colors">
<div className="flex items-start gap-4">
<div className="p-2 bg-secondary-100 rounded-lg">
<option.icon className="w-5 h-5 text-secondary-600" />
</div>
<div className="flex-1">
<h3 className="font-medium text-secondary-900">{option.title}</h3>
<p className="text-sm text-secondary-500 mt-0.5">{option.description}</p>
</div>
</div>
</Card>
</Link>
))}
</div>
<div className="mt-6 p-4 bg-blue-50 rounded-lg">
<p className="text-sm text-blue-800">
<strong>Tip:</strong> After opening a print page, use your browser's print function
(Ctrl/Cmd + P) to print or save as PDF.
</p>
</div>
</PageContainer>
</>
)
}

View File

@@ -16,6 +16,8 @@ interface Workspace {
clinicPhone: string | null
emergencyPhone: string | null
largeTextMode: boolean
quietHoursStart: string | null
quietHoursEnd: string | null
}
interface AppContextType {

View File

@@ -0,0 +1,219 @@
'use client'
import { useState, useEffect } from 'react'
import { useRouter } from 'next/navigation'
import { format } from 'date-fns'
import { Input, Textarea, Select, Button, showToast } from '@/components/ui'
import { Header, PageContainer } from '@/components/layout/header'
import { useApp } from '../../provider'
const BLOOD_TYPES = [
{ value: '', label: 'Select blood type' },
{ value: 'A+', label: 'A+' },
{ value: 'A-', label: 'A-' },
{ value: 'B+', label: 'B+' },
{ value: 'B-', label: 'B-' },
{ value: 'AB+', label: 'AB+' },
{ value: 'AB-', label: 'AB-' },
{ value: 'O+', label: 'O+' },
{ value: 'O-', label: 'O-' },
{ value: 'Unknown', label: 'Unknown' },
]
export default function EmergencySettingsPage() {
const router = useRouter()
const { currentWorkspace, refreshData } = useApp()
const [saving, setSaving] = useState(false)
const [loading, setLoading] = useState(true)
const [patientName, setPatientName] = useState('')
const [patientDOB, setPatientDOB] = useState('')
const [bloodType, setBloodType] = useState('')
const [allergies, setAllergies] = useState('')
const [medicalConditions, setMedicalConditions] = useState('')
const [primaryPhysician, setPrimaryPhysician] = useState('')
const [physicianPhone, setPhysicianPhone] = useState('')
const [clinicPhone, setClinicPhone] = useState('')
const [emergencyPhone, setEmergencyPhone] = useState('')
useEffect(() => {
const loadData = async () => {
try {
const response = await fetch(`/api/workspaces/${currentWorkspace.id}/emergency-info`)
if (response.ok) {
const data = await response.json()
setPatientName(data.patientName || '')
setPatientDOB(data.patientDOB ? format(new Date(data.patientDOB), 'yyyy-MM-dd') : '')
setBloodType(data.bloodType || '')
setAllergies(data.allergies || '')
setMedicalConditions(data.medicalConditions || '')
setPrimaryPhysician(data.primaryPhysician || '')
setPhysicianPhone(data.physicianPhone || '')
setClinicPhone(data.clinicPhone || '')
setEmergencyPhone(data.emergencyPhone || '')
}
} catch (err) {
console.error('Failed to load emergency info:', err)
} finally {
setLoading(false)
}
}
loadData()
}, [currentWorkspace.id])
const handleSave = async () => {
setSaving(true)
try {
const response = await fetch(`/api/workspaces/${currentWorkspace.id}/emergency-info`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
patientName: patientName.trim() || null,
patientDOB: patientDOB ? new Date(patientDOB).toISOString() : null,
bloodType: bloodType || null,
allergies: allergies.trim() || null,
medicalConditions: medicalConditions.trim() || null,
primaryPhysician: primaryPhysician.trim() || null,
physicianPhone: physicianPhone.trim() || null,
clinicPhone: clinicPhone.trim() || null,
emergencyPhone: emergencyPhone.trim() || null,
}),
})
if (!response.ok) throw new Error('Failed to save')
await refreshData()
showToast('Emergency information saved', 'success')
router.back()
} catch (err) {
showToast('Failed to save', 'error')
} finally {
setSaving(false)
}
}
return (
<>
<Header title="Emergency Info" showBack />
<PageContainer className="pt-4 space-y-6 pb-24">
<p className="text-secondary-600">
This information will be available offline in emergencies. Fill in what you know.
</p>
{/* Patient Information */}
<section>
<h2 className="text-sm font-semibold text-secondary-600 mb-3">
Patient Information
</h2>
<div className="space-y-4">
<Input
label="Patient Name"
value={patientName}
onChange={(e) => setPatientName(e.target.value)}
placeholder="Full name"
disabled={loading}
/>
<Input
label="Date of Birth"
type="date"
value={patientDOB}
onChange={(e) => setPatientDOB(e.target.value)}
disabled={loading}
/>
<Select
label="Blood Type"
value={bloodType}
onChange={(e) => setBloodType(e.target.value)}
options={BLOOD_TYPES}
disabled={loading}
/>
</div>
</section>
{/* Medical Information */}
<section>
<h2 className="text-sm font-semibold text-secondary-600 mb-3">
Medical Information
</h2>
<div className="space-y-4">
<Textarea
label="Allergies"
value={allergies}
onChange={(e) => setAllergies(e.target.value)}
placeholder="List any allergies (medications, food, environmental)"
rows={3}
disabled={loading}
/>
<Textarea
label="Medical Conditions"
value={medicalConditions}
onChange={(e) => setMedicalConditions(e.target.value)}
placeholder="List ongoing conditions (e.g., Cancer - receiving chemotherapy)"
rows={3}
disabled={loading}
/>
</div>
</section>
{/* Healthcare Provider */}
<section>
<h2 className="text-sm font-semibold text-secondary-600 mb-3">
Healthcare Provider
</h2>
<div className="space-y-4">
<Input
label="Primary Physician"
value={primaryPhysician}
onChange={(e) => setPrimaryPhysician(e.target.value)}
placeholder="Doctor's name"
disabled={loading}
/>
<Input
label="Physician Phone"
type="tel"
value={physicianPhone}
onChange={(e) => setPhysicianPhone(e.target.value)}
placeholder="e.g., 08 9400 1234"
disabled={loading}
/>
</div>
</section>
{/* Emergency Contacts */}
<section>
<h2 className="text-sm font-semibold text-secondary-600 mb-3">
Emergency Contacts
</h2>
<div className="space-y-4">
<Input
label="Clinic Phone"
type="tel"
value={clinicPhone}
onChange={(e) => setClinicPhone(e.target.value)}
placeholder="e.g., 08 9400 1234"
disabled={loading}
/>
<Input
label="Emergency Contact (Family)"
type="tel"
value={emergencyPhone}
onChange={(e) => setEmergencyPhone(e.target.value)}
placeholder="e.g., 0412 345 678"
disabled={loading}
/>
</div>
</section>
</PageContainer>
{/* Fixed Save Button */}
<div className="fixed bottom-0 left-0 right-0 p-4 bg-surface border-t border-border safe-bottom">
<Button onClick={handleSave} fullWidth loading={saving} disabled={loading}>
Save Emergency Info
</Button>
</div>
</>
)
}

View File

@@ -0,0 +1,144 @@
'use client'
import { useState } from 'react'
import { Bell, Clock, Moon } from 'lucide-react'
import { Card, Button, Input, showToast } from '@/components/ui'
import { Header, PageContainer } from '@/components/layout/header'
import { NotificationPermission } from '@/components/notifications/NotificationPermission'
import { useApp } from '../../provider'
export default function NotificationsSettingsPage() {
const { currentWorkspace, refreshData } = useApp()
const [quietStart, setQuietStart] = useState(currentWorkspace.quietHoursStart || '22:00')
const [quietEnd, setQuietEnd] = useState(currentWorkspace.quietHoursEnd || '07:00')
const [saving, setSaving] = useState(false)
const handleSaveQuietHours = async () => {
setSaving(true)
try {
const response = await fetch(`/api/workspaces/${currentWorkspace.id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
quietHoursStart: quietStart,
quietHoursEnd: quietEnd,
}),
})
if (!response.ok) throw new Error('Failed to save')
await refreshData()
showToast('Quiet hours updated', 'success')
} catch {
showToast('Failed to save', 'error')
} finally {
setSaving(false)
}
}
return (
<>
<Header title="Notifications" showBack />
<PageContainer className="pt-4 space-y-6">
{/* Push Notifications */}
<section>
<h2 className="text-sm font-semibold text-secondary-600 mb-3">
Push Notifications
</h2>
<Card>
<NotificationPermission workspaceId={currentWorkspace.id} />
</Card>
</section>
{/* Quiet Hours */}
<section>
<h2 className="text-sm font-semibold text-secondary-600 mb-3">
Quiet Hours
</h2>
<Card>
<div className="flex items-start gap-3 mb-4">
<div className="p-2 bg-indigo-100 rounded-lg">
<Moon className="w-5 h-5 text-indigo-600" />
</div>
<div>
<p className="font-medium text-secondary-900">
Do Not Disturb
</p>
<p className="text-sm text-secondary-500">
No reminders will be sent during quiet hours.
</p>
</div>
</div>
<div className="grid grid-cols-2 gap-4 mb-4">
<div>
<label className="block text-sm font-medium text-secondary-700 mb-1">
Start Time
</label>
<input
type="time"
value={quietStart}
onChange={(e) => setQuietStart(e.target.value)}
className="w-full px-3 py-2 border border-border rounded-button text-base focus:outline-none focus:ring-2 focus:ring-primary-200 focus:border-primary-500"
/>
</div>
<div>
<label className="block text-sm font-medium text-secondary-700 mb-1">
End Time
</label>
<input
type="time"
value={quietEnd}
onChange={(e) => setQuietEnd(e.target.value)}
className="w-full px-3 py-2 border border-border rounded-button text-base focus:outline-none focus:ring-2 focus:ring-primary-200 focus:border-primary-500"
/>
</div>
</div>
<Button
onClick={handleSaveQuietHours}
loading={saving}
fullWidth
variant="secondary"
>
Save Quiet Hours
</Button>
</Card>
</section>
{/* How it works */}
<section>
<h2 className="text-sm font-semibold text-secondary-600 mb-3">
How It Works
</h2>
<Card variant="outline">
<div className="space-y-4 text-sm text-secondary-600">
<div className="flex items-start gap-3">
<Bell className="w-5 h-5 text-primary-500 flex-shrink-0 mt-0.5" />
<p>
When enabled, you'll receive push notifications when it's time
to take your medications.
</p>
</div>
<div className="flex items-start gap-3">
<Clock className="w-5 h-5 text-primary-500 flex-shrink-0 mt-0.5" />
<p>
Reminders are sent based on your medication schedules.
PRN (as-needed) medications don't trigger reminders.
</p>
</div>
<div className="flex items-start gap-3">
<Moon className="w-5 h-5 text-primary-500 flex-shrink-0 mt-0.5" />
<p>
Quiet hours prevent notifications from being sent during
sleep or rest times.
</p>
</div>
</div>
</Card>
</section>
</PageContainer>
</>
)
}

View File

@@ -12,6 +12,12 @@ import {
Shield,
ExternalLink,
Copy,
AlertTriangle,
Activity,
Printer,
Calendar,
FileText,
Bell,
} from 'lucide-react'
import { useLiveQuery } from 'dexie-react-hooks'
@@ -33,6 +39,7 @@ export default function SettingsPage() {
const [inviteLoading, setInviteLoading] = useState(false)
const [inviteUrl, setInviteUrl] = useState('')
const [inviteRole, setInviteRole] = useState<'EDITOR' | 'VIEWER'>('VIEWER')
const [showCalendarUrl, setShowCalendarUrl] = useState(false)
// Get workspace from IndexedDB for large text mode
const workspace = useLiveQuery(
@@ -153,6 +160,29 @@ export default function SettingsPage() {
}
}
const handleExportPDF = async () => {
try {
showToast('Generating PDF...', 'info')
const response = await fetch(`/api/workspaces/${currentWorkspace.id}/export/summary.pdf`)
if (!response.ok) {
throw new Error('Failed to generate PDF')
}
const blob = await response.blob()
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `medical-summary-${new Date().toISOString().split('T')[0]}.pdf`
a.click()
URL.revokeObjectURL(url)
showToast('PDF downloaded', 'success')
} catch {
showToast('PDF export failed', 'error')
}
}
return (
<>
<Header title="Settings" />
@@ -207,6 +237,50 @@ export default function SettingsPage() {
</Card>
</section>
{/* Emergency Info */}
<section>
<h2 className="text-sm font-semibold text-secondary-600 mb-3">
Emergency Information
</h2>
<Card padding="none">
<button
onClick={() => router.push('/settings/emergency')}
className="w-full flex items-center gap-3 p-4 hover:bg-muted transition-colors"
>
<AlertTriangle className="w-5 h-5 text-red-500" />
<div className="flex-1 text-left">
<p className="font-medium text-secondary-900">Medical Emergency Info</p>
<p className="text-sm text-secondary-500">
Blood type, allergies, conditions
</p>
</div>
<ChevronRight className="w-5 h-5 text-secondary-300" />
</button>
</Card>
</section>
{/* Activity Feed */}
<section>
<h2 className="text-sm font-semibold text-secondary-600 mb-3">
History
</h2>
<Card padding="none">
<button
onClick={() => router.push('/activity')}
className="w-full flex items-center gap-3 p-4 hover:bg-muted transition-colors"
>
<Activity className="w-5 h-5 text-secondary-500" />
<div className="flex-1 text-left">
<p className="font-medium text-secondary-900">Activity Log</p>
<p className="text-sm text-secondary-500">
View all changes and actions
</p>
</div>
<ChevronRight className="w-5 h-5 text-secondary-300" />
</button>
</Card>
</section>
{/* Family members */}
{currentWorkspace.role === 'OWNER' && (
<section>
@@ -261,10 +335,78 @@ export default function SettingsPage() {
</Card>
</section>
{/* Notifications */}
<section>
<h2 className="text-sm font-semibold text-secondary-600 mb-3">
Notifications
</h2>
<Card padding="none">
<button
onClick={() => router.push('/settings/notifications')}
className="w-full flex items-center gap-3 p-4 hover:bg-muted transition-colors"
>
<Bell className="w-5 h-5 text-secondary-500" />
<div className="flex-1 text-left">
<p className="font-medium text-secondary-900">Medication Reminders</p>
<p className="text-sm text-secondary-500">Push notifications & quiet hours</p>
</div>
<ChevronRight className="w-5 h-5 text-secondary-300" />
</button>
</Card>
</section>
{/* Print */}
<section>
<h2 className="text-sm font-semibold text-secondary-600 mb-3">Print</h2>
<Card padding="none">
<button
onClick={() => router.push('/print')}
className="w-full flex items-center gap-3 p-4 hover:bg-muted transition-colors"
>
<Printer className="w-5 h-5 text-secondary-500" />
<div className="flex-1 text-left">
<p className="font-medium text-secondary-900">Print Documents</p>
<p className="text-sm text-secondary-500">Medication schedules, appointments</p>
</div>
<ChevronRight className="w-5 h-5 text-secondary-300" />
</button>
</Card>
</section>
{/* Calendar Sync */}
<section>
<h2 className="text-sm font-semibold text-secondary-600 mb-3">Calendar</h2>
<Card padding="none">
<button
onClick={() => setShowCalendarUrl(true)}
className="w-full flex items-center gap-3 p-4 hover:bg-muted transition-colors"
>
<Calendar className="w-5 h-5 text-secondary-500" />
<div className="flex-1 text-left">
<p className="font-medium text-secondary-900">Subscribe to Calendar</p>
<p className="text-sm text-secondary-500">Sync appointments to iPhone, Google</p>
</div>
<ChevronRight className="w-5 h-5 text-secondary-300" />
</button>
</Card>
</section>
{/* Data */}
<section>
<h2 className="text-sm font-semibold text-secondary-600 mb-3">Data</h2>
<Card padding="none">
<button
onClick={handleExportPDF}
className="w-full flex items-center gap-3 p-4 hover:bg-muted transition-colors"
>
<FileText className="w-5 h-5 text-secondary-500" />
<div className="flex-1 text-left">
<p className="font-medium text-secondary-900">Medical Summary PDF</p>
<p className="text-sm text-secondary-500">For doctor appointments</p>
</div>
<ChevronRight className="w-5 h-5 text-secondary-300" />
</button>
<div className="border-t border-border">
<button
onClick={handleExportJSON}
className="w-full flex items-center gap-3 p-4 hover:bg-muted transition-colors"
@@ -276,6 +418,7 @@ export default function SettingsPage() {
</div>
<ChevronRight className="w-5 h-5 text-secondary-300" />
</button>
</div>
</Card>
</section>
@@ -412,6 +555,54 @@ export default function SettingsPage() {
</div>
)}
</Modal>
{/* Calendar URL modal */}
<Modal
isOpen={showCalendarUrl}
onClose={() => setShowCalendarUrl(false)}
title="Subscribe to Calendar"
>
<div className="space-y-4">
<p className="text-secondary-600">
Add your appointments to your phone's calendar app. This creates a subscription
that stays up to date automatically.
</p>
<div className="space-y-3">
<div>
<label className="block text-sm font-medium text-secondary-700 mb-2">
Calendar URL
</label>
<div className="bg-muted p-3 rounded-button break-all text-sm text-secondary-700 font-mono">
{typeof window !== 'undefined' &&
`${window.location.origin}/api/workspaces/${currentWorkspace.id}/calendar.ics?token=${user.id}`}
</div>
</div>
<Button
onClick={() => {
const url = `${window.location.origin}/api/workspaces/${currentWorkspace.id}/calendar.ics?token=${user.id}`
navigator.clipboard.writeText(url)
showToast('URL copied!', 'success')
}}
fullWidth
variant="secondary"
>
<Copy className="w-4 h-4 mr-2" />
Copy URL
</Button>
</div>
<div className="bg-blue-50 p-3 rounded-lg">
<p className="text-sm text-blue-800 font-medium mb-2">How to subscribe:</p>
<ul className="text-sm text-blue-700 list-disc list-inside space-y-1">
<li><strong>iPhone:</strong> Settings Calendar Accounts Add Other Add Subscribed Calendar</li>
<li><strong>Google Calendar:</strong> Settings Add calendar From URL</li>
<li><strong>Outlook:</strong> Add calendar Subscribe from web</li>
</ul>
</div>
</div>
</Modal>
</>
)
}

View File

@@ -0,0 +1,159 @@
'use client'
import { useState, useEffect, useCallback } from 'react'
import { subDays, format } from 'date-fns'
import { Card, LoadingState, Button } from '@/components/ui'
import { Header, PageContainer } from '@/components/layout/header'
import { SymptomCard } from '@/components/symptoms/SymptomCard'
import { SymptomChart } from '@/components/symptoms/SymptomChart'
import { useApp } from '../../provider'
const SYMPTOM_TYPES = [
{ value: '', label: 'All' },
{ value: 'FATIGUE', label: 'Fatigue' },
{ value: 'NAUSEA', label: 'Nausea' },
{ value: 'PAIN', label: 'Pain' },
{ value: 'APPETITE', label: 'Appetite' },
{ value: 'SLEEP', label: 'Sleep' },
{ value: 'MOOD', label: 'Mood' },
]
export default function SymptomsHistoryPage() {
const { currentWorkspace } = useApp()
const [symptoms, setSymptoms] = useState<any[]>([])
const [loading, setLoading] = useState(true)
const [loadingMore, setLoadingMore] = useState(false)
const [hasMore, setHasMore] = useState(false)
const [filterType, setFilterType] = useState('')
const [offset, setOffset] = useState(0)
const fetchSymptoms = useCallback(async (reset = false) => {
const currentOffset = reset ? 0 : offset
if (reset) {
setLoading(true)
} else {
setLoadingMore(true)
}
try {
const params = new URLSearchParams({
limit: '50',
offset: String(currentOffset),
...(filterType && { type: filterType }),
})
const response = await fetch(
`/api/workspaces/${currentWorkspace.id}/symptoms?${params}`
)
if (!response.ok) throw new Error('Failed to fetch')
const data = await response.json()
if (reset) {
setSymptoms(data.symptoms)
setOffset(data.symptoms.length)
} else {
setSymptoms((prev) => [...prev, ...data.symptoms])
setOffset(currentOffset + data.symptoms.length)
}
setHasMore(data.symptoms.length === 50)
} catch (err) {
console.error('Failed to fetch symptoms:', err)
} finally {
setLoading(false)
setLoadingMore(false)
}
}, [currentWorkspace.id, filterType, offset])
useEffect(() => {
fetchSymptoms(true)
}, [currentWorkspace.id, filterType])
const handleFilterChange = (type: string) => {
setFilterType(type)
setOffset(0)
}
if (loading) {
return (
<>
<Header title="Symptom History" showBack />
<PageContainer>
<LoadingState message="Loading history..." />
</PageContainer>
</>
)
}
return (
<>
<Header title="Symptom History" showBack />
<PageContainer className="pt-4 space-y-6">
{/* Filter */}
<div className="flex gap-2 overflow-x-auto pb-2 scrollbar-hide">
{SYMPTOM_TYPES.map((type) => (
<button
key={type.value}
onClick={() => handleFilterChange(type.value)}
className={`px-3 py-1.5 rounded-full text-sm font-medium whitespace-nowrap transition-colors ${
filterType === type.value
? 'bg-primary-500 text-white'
: 'bg-secondary-100 text-secondary-600 hover:bg-secondary-200'
}`}
>
{type.label}
</button>
))}
</div>
{/* 30-Day Chart */}
{symptoms.length > 0 && (
<section>
<h2 className="text-sm font-semibold text-secondary-600 mb-3">
Last 30 Days
</h2>
<Card>
<SymptomChart
symptoms={symptoms}
days={30}
type={filterType || undefined}
/>
</Card>
</section>
)}
{/* Symptoms List */}
<section>
<h2 className="text-sm font-semibold text-secondary-600 mb-3">
All Entries
</h2>
{symptoms.length === 0 ? (
<Card variant="outline" className="text-center py-8">
<p className="text-secondary-500">No symptoms found</p>
</Card>
) : (
<div className="space-y-3">
{symptoms.map((symptom) => (
<SymptomCard key={symptom.id} symptom={symptom} />
))}
</div>
)}
</section>
{hasMore && (
<div className="text-center pb-4">
<Button
variant="secondary"
onClick={() => fetchSymptoms(false)}
loading={loadingMore}
>
Load more
</Button>
</div>
)}
</PageContainer>
</>
)
}

View File

@@ -0,0 +1,150 @@
'use client'
import { useState, useEffect, useCallback } from 'react'
import { useRouter } from 'next/navigation'
import { BarChart2, History } from 'lucide-react'
import { useLiveQuery } from 'dexie-react-hooks'
import { db } from '@/lib/sync'
import { Card, LoadingState, EmptyState } from '@/components/ui'
import { Header, PageContainer } from '@/components/layout/header'
import { SymptomQuickLog } from '@/components/symptoms/SymptomQuickLog'
import { SymptomCard } from '@/components/symptoms/SymptomCard'
import { SymptomChart } from '@/components/symptoms/SymptomChart'
import { useApp } from '../provider'
export default function SymptomsPage() {
const router = useRouter()
const { currentWorkspace, refreshData } = useApp()
const [recentSymptoms, setRecentSymptoms] = useState<any[]>([])
const [loading, setLoading] = useState(true)
// Fetch symptoms from IndexedDB
const localSymptoms = useLiveQuery(
() =>
db.symptoms
.where('workspaceId')
.equals(currentWorkspace.id)
.and((s) => !s.deletedAt)
.reverse()
.limit(50)
.toArray(),
[currentWorkspace.id]
)
// Also fetch from server to get the latest
const fetchSymptoms = useCallback(async () => {
try {
const response = await fetch(
`/api/workspaces/${currentWorkspace.id}/symptoms?limit=20`
)
if (response.ok) {
const data = await response.json()
setRecentSymptoms(data.symptoms)
}
} catch (err) {
console.error('Failed to fetch symptoms:', err)
} finally {
setLoading(false)
}
}, [currentWorkspace.id])
useEffect(() => {
fetchSymptoms()
}, [fetchSymptoms])
const handleLogged = () => {
fetchSymptoms()
refreshData()
}
// Combine local and server data, preferring server
const symptoms = recentSymptoms.length > 0 ? recentSymptoms : localSymptoms || []
if (loading && !localSymptoms) {
return (
<>
<Header title="Symptoms" />
<PageContainer>
<LoadingState message="Loading symptoms..." />
</PageContainer>
</>
)
}
return (
<>
<Header
title="Symptoms"
rightAction={{
icon: <History className="w-6 h-6 text-secondary-700" />,
label: 'History',
onClick: () => router.push('/symptoms/history'),
}}
/>
<PageContainer className="pt-4 space-y-6">
{/* Quick Log */}
<section>
<h2 className="text-lg font-semibold text-secondary-900 mb-3">Log a Symptom</h2>
<Card>
<SymptomQuickLog
workspaceId={currentWorkspace.id}
onLogged={handleLogged}
/>
</Card>
</section>
{/* 7-Day Chart */}
{symptoms.length > 0 && (
<section>
<h2 className="text-lg font-semibold text-secondary-900 mb-3">
Last 7 Days
</h2>
<Card>
<SymptomChart
symptoms={symptoms.map((s: any) => ({
id: s.id,
type: s.type,
severity: s.severity,
recordedAt: s.recordedAt,
}))}
days={7}
/>
</Card>
</section>
)}
{/* Recent Symptoms */}
<section>
<div className="flex items-center justify-between mb-3">
<h2 className="text-lg font-semibold text-secondary-900">Recent</h2>
{symptoms.length > 5 && (
<button
onClick={() => router.push('/symptoms/history')}
className="text-sm text-primary-600 font-medium"
>
View all
</button>
)}
</div>
{symptoms.length === 0 ? (
<Card variant="outline" className="text-center py-8">
<BarChart2 className="w-10 h-10 text-secondary-300 mx-auto mb-3" />
<p className="text-secondary-500">No symptoms logged yet</p>
<p className="text-sm text-secondary-400 mt-1">
Use the form above to track how you're feeling
</p>
</Card>
) : (
<div className="space-y-3">
{symptoms.slice(0, 5).map((symptom: any) => (
<SymptomCard key={symptom.id} symptom={symptom} />
))}
</div>
)}
</section>
</PageContainer>
</>
)
}

View File

@@ -4,7 +4,7 @@ import { useEffect, useState, useCallback } from 'react'
import { useRouter } from 'next/navigation'
import { format, isToday, isTomorrow } from 'date-fns'
import { toZonedTime } from 'date-fns-tz'
import { Phone, MapPin, Clock, ChevronRight, Pill, Calendar, Plus } from 'lucide-react'
import { Phone, MapPin, Clock, ChevronRight, Pill, Calendar, Plus, AlertTriangle, ClipboardCheck } from 'lucide-react'
import { useLiveQuery } from 'dexie-react-hooks'
import { db, logDose, undoDose } from '@/lib/sync'
@@ -12,6 +12,7 @@ import { calculateAllMedicationsDue, formatTimeUntil } from '@/lib/schedule'
import type { Medication, DoseLog, MedicationDueStatus } from '@/lib/schedule'
import { Card, CardTitle, Button, LoadingState, EmptyState, showUndoToast, showToast } from '@/components/ui'
import { Header, PageContainer } from '@/components/layout/header'
import { RefillAlert } from '@/components/medications/RefillAlert'
import { useApp } from '../provider'
const TIMEZONE = 'Australia/Perth'
@@ -173,21 +174,38 @@ export default function TodayPage() {
</p>
</div>
{/* Emergency & Call Clinic Buttons */}
<div className="flex gap-3">
{/* Emergency Info Button */}
<button
onClick={() => router.push('/emergency')}
className="flex items-center gap-3 p-4 bg-red-50 rounded-card border border-red-200 hover:bg-red-100 transition-colors flex-1"
>
<div className="w-10 h-10 rounded-full bg-red-600 flex items-center justify-center">
<AlertTriangle className="w-5 h-5 text-white" />
</div>
<div className="text-left">
<p className="font-medium text-red-800">Emergency</p>
<p className="text-sm text-red-600">Medical info</p>
</div>
</button>
{/* Call Clinic Button */}
{currentWorkspace.clinicPhone && (
<a
href={`tel:${currentWorkspace.clinicPhone}`}
className="flex items-center gap-3 p-4 bg-primary-50 rounded-card border border-primary-100 hover:bg-primary-100 transition-colors"
className="flex items-center gap-3 p-4 bg-primary-50 rounded-card border border-primary-100 hover:bg-primary-100 transition-colors flex-1"
>
<div className="w-10 h-10 rounded-full bg-primary-500 flex items-center justify-center">
<Phone className="w-5 h-5 text-white" />
</div>
<div>
<div className="text-left">
<p className="font-medium text-primary-800">Call Clinic</p>
<p className="text-sm text-primary-600">{currentWorkspace.clinicPhone}</p>
<p className="text-sm text-primary-600 truncate">{currentWorkspace.clinicPhone}</p>
</div>
</a>
)}
</div>
{/* Next Appointment */}
<section>
@@ -258,6 +276,51 @@ export default function TodayPage() {
)}
</section>
{/* Prep Reminder for Tomorrow's Appointment */}
{appointments && appointments.length > 0 && (() => {
const tomorrowAppt = appointments.find((appt) =>
isTomorrow(toZonedTime(new Date(appt.datetime), TIMEZONE))
)
if (tomorrowAppt) {
return (
<section>
<Card
className="bg-green-50 border border-green-200 cursor-pointer hover:bg-green-100 transition-colors"
onClick={() => router.push(`/appointments/${tomorrowAppt.id}/prep`)}
>
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-full bg-green-500 flex items-center justify-center flex-shrink-0">
<ClipboardCheck className="w-5 h-5 text-white" />
</div>
<div className="flex-1">
<p className="font-medium text-green-800">
Prepare for tomorrow
</p>
<p className="text-sm text-green-600">
{tomorrowAppt.title} at {format(toZonedTime(new Date(tomorrowAppt.datetime), TIMEZONE), 'h:mm a')}
</p>
</div>
<ChevronRight className="w-5 h-5 text-green-500" />
</div>
</Card>
</section>
)
}
return null
})()}
{/* Refill Alerts */}
{medications && medications.length > 0 && (
<RefillAlert
medications={medications.map(m => ({
id: m.id,
name: m.name,
pillCount: m.pillCount,
refillThreshold: m.refillThreshold,
}))}
/>
)}
{/* Meds Due */}
<section>
<div className="flex items-center justify-between mb-3">

View File

@@ -0,0 +1,48 @@
import { NextResponse } from 'next/server'
import { sendDueNotifications } from '@/lib/notifications/scheduler'
// This endpoint should be called by a cron job every minute
// You can set up a cron service like:
// - Vercel Cron Jobs
// - AWS EventBridge
// - A simple setInterval in a long-running process
// POST /api/notifications/send - Trigger sending due notifications
export async function POST(req: Request) {
try {
// Verify cron secret to prevent unauthorized access
const authHeader = req.headers.get('authorization')
const cronSecret = process.env.CRON_SECRET
// If CRON_SECRET is set, verify it
if (cronSecret && authHeader !== `Bearer ${cronSecret}`) {
return NextResponse.json(
{ error: 'Unauthorized' },
{ status: 401 }
)
}
const { sent, failed } = await sendDueNotifications()
return NextResponse.json({
success: true,
sent,
failed,
timestamp: new Date().toISOString(),
})
} catch (error) {
console.error('Send notifications error:', error)
return NextResponse.json(
{ error: 'Failed to send notifications' },
{ status: 500 }
)
}
}
// GET endpoint for health checks
export async function GET() {
return NextResponse.json({
status: 'ok',
message: 'Notification sender is ready',
})
}

View File

@@ -0,0 +1,106 @@
import { NextResponse } from 'next/server'
import { prisma } from '@/lib/db/prisma'
import { checkWorkspaceAccess } from '@/lib/db/workspace-access'
import { withAuth, type AuthenticatedRequest } from '@/lib/auth'
import { getPublicVAPIDKey } from '@/lib/notifications/push'
// GET /api/notifications/subscribe - Get VAPID public key
export const GET = withAuth(async () => {
const publicKey = getPublicVAPIDKey()
if (!publicKey) {
return NextResponse.json(
{ error: 'Push notifications not configured' },
{ status: 503 }
)
}
return NextResponse.json({ publicKey })
})
// POST /api/notifications/subscribe - Subscribe to push notifications
export const POST = withAuth(async (req: AuthenticatedRequest) => {
try {
const body = await req.json()
const { subscription, workspaceId } = body
if (!subscription || !subscription.endpoint || !subscription.keys) {
return NextResponse.json(
{ error: 'Invalid subscription data' },
{ status: 400 }
)
}
const access = await checkWorkspaceAccess(workspaceId, req.session.user.id)
if (!access) {
return NextResponse.json({ error: 'Access denied' }, { status: 403 })
}
// Upsert the subscription (update if exists, create if not)
const existing = await prisma.pushSubscription.findFirst({
where: {
endpoint: subscription.endpoint,
userId: req.session.user.id,
},
})
if (existing) {
await prisma.pushSubscription.update({
where: { id: existing.id },
data: {
p256dh: subscription.keys.p256dh,
auth: subscription.keys.auth,
workspaceId,
},
})
} else {
await prisma.pushSubscription.create({
data: {
userId: req.session.user.id,
workspaceId,
endpoint: subscription.endpoint,
p256dh: subscription.keys.p256dh,
auth: subscription.keys.auth,
},
})
}
return NextResponse.json({ success: true })
} catch (error) {
console.error('Subscribe error:', error)
return NextResponse.json(
{ error: 'Failed to subscribe' },
{ status: 500 }
)
}
})
// DELETE /api/notifications/subscribe - Unsubscribe from push notifications
export const DELETE = withAuth(async (req: AuthenticatedRequest) => {
try {
const { searchParams } = new URL(req.url)
const endpoint = searchParams.get('endpoint')
if (!endpoint) {
return NextResponse.json(
{ error: 'Endpoint required' },
{ status: 400 }
)
}
await prisma.pushSubscription.deleteMany({
where: {
endpoint,
userId: req.session.user.id,
},
})
return NextResponse.json({ success: true })
} catch (error) {
console.error('Unsubscribe error:', error)
return NextResponse.json(
{ error: 'Failed to unsubscribe' },
{ status: 500 }
)
}
})

View File

@@ -33,7 +33,7 @@ export const GET = withAuth(async (req: AuthenticatedRequest) => {
const sinceDate = new Date(since)
// Fetch all changed entities
const [appointments, medications, notes, doseLogs, workspace] = await Promise.all([
const [appointments, medications, notes, doseLogs, symptoms, workspace] = await Promise.all([
prisma.appointment.findMany({
where: { workspaceId, syncedAt: { gt: sinceDate } },
include: {
@@ -63,6 +63,12 @@ export const GET = withAuth(async (req: AuthenticatedRequest) => {
undoneBy: { select: { id: true, name: true } },
},
}),
prisma.symptom.findMany({
where: { workspaceId, syncedAt: { gt: sinceDate } },
include: {
createdBy: { select: { id: true, name: true } },
},
}),
prisma.workspace.findUnique({
where: { id: workspaceId },
select: {
@@ -74,13 +80,21 @@ export const GET = withAuth(async (req: AuthenticatedRequest) => {
quietHoursEnd: true,
largeTextMode: true,
updatedAt: true,
// Emergency info fields
patientName: true,
patientDOB: true,
bloodType: true,
allergies: true,
medicalConditions: true,
primaryPhysician: true,
physicianPhone: true,
},
}),
])
// Calculate new cursor (latest syncedAt timestamp)
let cursor = since
const allItems = [...appointments, ...medications, ...notes, ...doseLogs]
const allItems = [...appointments, ...medications, ...notes, ...doseLogs, ...symptoms]
for (const item of allItems) {
const itemTime = (item as { syncedAt: Date }).syncedAt.getTime()
if (itemTime > cursor) {
@@ -94,6 +108,7 @@ export const GET = withAuth(async (req: AuthenticatedRequest) => {
medications,
notes,
doseLogs,
symptoms,
cursor,
hasConflicts: false, // For now, always false - client handles conflicts
})
@@ -296,6 +311,64 @@ export const POST = withAuth(async (req: AuthenticatedRequest) => {
break
}
case 'UNMARK_ASKED': {
if (!op.entityId) {
results.push({ opId: op.id, success: false, error: 'Missing entityId' })
break
}
await prisma.note.update({
where: { id: op.entityId },
data: {
askedAt: null,
updatedById: req.session.user.id,
version: { increment: 1 },
syncedAt: new Date(),
},
})
results.push({ opId: op.id, success: true })
break
}
case 'LOG_SYMPTOM': {
if (!op.data) {
results.push({ opId: op.id, success: false, error: 'Missing symptom data' })
break
}
const symptom = await prisma.symptom.create({
data: {
workspaceId,
type: op.data.type as 'FATIGUE' | 'NAUSEA' | 'PAIN' | 'APPETITE' | 'SLEEP' | 'MOOD' | 'CUSTOM',
customName: (op.data.customName as string) || null,
severity: op.data.severity as number,
notes: (op.data.notes as string) || null,
recordedAt: op.data.recordedAt ? new Date(op.data.recordedAt as string) : new Date(),
createdById: req.session.user.id,
},
})
results.push({ opId: op.id, success: true, entityId: symptom.id })
break
}
case 'DELETE_SYMPTOM': {
if (!op.entityId) {
results.push({ opId: op.id, success: false, error: 'Missing entityId' })
break
}
await prisma.symptom.update({
where: { id: op.entityId },
data: {
deletedAt: new Date(),
version: { increment: 1 },
syncedAt: new Date(),
},
})
results.push({ opId: op.id, success: true })
break
}
default:
results.push({ opId: op.id, success: false, error: 'Unknown operation type' })
}

View File

@@ -0,0 +1,55 @@
import { NextResponse } from 'next/server'
import { prisma } from '@/lib/db/prisma'
import { checkWorkspaceAccess } from '@/lib/db/workspace-access'
import { withAuth, type AuthenticatedRequest } from '@/lib/auth'
// GET /api/workspaces/[id]/activity
export const GET = withAuth(async (
req: AuthenticatedRequest,
{ params }: { params: Promise<Record<string, string>> }
) => {
try {
const { id: workspaceId } = await params
const access = await checkWorkspaceAccess(workspaceId, req.session.user.id)
if (!access) {
return NextResponse.json({ error: 'Access denied' }, { status: 403 })
}
const { searchParams } = new URL(req.url)
const limit = Math.min(parseInt(searchParams.get('limit') || '50'), 100)
const offset = parseInt(searchParams.get('offset') || '0')
const entityType = searchParams.get('entityType')
const action = searchParams.get('action')
const where: Record<string, unknown> = {
workspaceId,
...(entityType ? { entityType } : {}),
...(action ? { action } : {}),
}
const [activities, total] = await Promise.all([
prisma.auditLog.findMany({
where,
orderBy: { createdAt: 'desc' },
take: limit,
skip: offset,
include: {
user: {
select: { id: true, name: true },
},
},
}),
prisma.auditLog.count({ where }),
])
return NextResponse.json({
activities,
total,
hasMore: offset + activities.length < total,
})
} catch (error) {
console.error('Get activity error:', error)
return NextResponse.json({ error: 'Failed to get activity' }, { status: 500 })
}
})

View File

@@ -0,0 +1,107 @@
import { NextResponse } from 'next/server'
import { prisma } from '@/lib/db/prisma'
import { checkWorkspaceAccess } from '@/lib/db/workspace-access'
import { withAuth, type AuthenticatedRequest } from '@/lib/auth'
// GET /api/workspaces/[id]/appointments/[appointmentId]/checklist
export const GET = withAuth(
async (
req: AuthenticatedRequest,
{ params }: { params: Promise<Record<string, string>> }
) => {
try {
const { id: workspaceId, appointmentId } = await params
const access = await checkWorkspaceAccess(workspaceId, req.session.user.id)
if (!access) {
return NextResponse.json({ error: 'Access denied' }, { status: 403 })
}
// Verify appointment exists
const appointment = await prisma.appointment.findFirst({
where: { id: appointmentId, workspaceId, deletedAt: null },
})
if (!appointment) {
return NextResponse.json({ error: 'Appointment not found' }, { status: 404 })
}
// Fetch checklist items
const checklists = await prisma.appointmentChecklist.findMany({
where: { workspaceId, appointmentId },
})
// Convert to a map of { itemId: isReady }
const checkedItems: Record<string, boolean> = {}
for (const item of checklists) {
checkedItems[item.item] = item.isReady
}
return NextResponse.json({
checkedItems,
customItems: [], // Future: support custom items
})
} catch (error) {
console.error('Checklist get error:', error)
return NextResponse.json({ error: 'Failed to get checklist' }, { status: 500 })
}
}
)
// POST /api/workspaces/[id]/appointments/[appointmentId]/checklist
export const POST = withAuth(
async (
req: AuthenticatedRequest,
{ params }: { params: Promise<Record<string, string>> }
) => {
try {
const { id: workspaceId, appointmentId } = await params
const body = await req.json()
const { checkedItems } = body as { checkedItems: Record<string, boolean> }
const access = await checkWorkspaceAccess(workspaceId, req.session.user.id, [
'OWNER',
'EDITOR',
])
if (!access) {
return NextResponse.json({ error: 'Access denied' }, { status: 403 })
}
// Verify appointment exists
const appointment = await prisma.appointment.findFirst({
where: { id: appointmentId, workspaceId, deletedAt: null },
})
if (!appointment) {
return NextResponse.json({ error: 'Appointment not found' }, { status: 404 })
}
// Upsert each checklist item
for (const [itemId, isReady] of Object.entries(checkedItems)) {
await prisma.appointmentChecklist.upsert({
where: {
workspaceId_appointmentId_item: {
workspaceId,
appointmentId,
item: itemId,
},
},
create: {
workspaceId,
appointmentId,
item: itemId,
isReady: isReady as boolean,
},
update: {
isReady: isReady as boolean,
},
})
}
return NextResponse.json({ success: true })
} catch (error) {
console.error('Checklist save error:', error)
return NextResponse.json({ error: 'Failed to save checklist' }, { status: 500 })
}
}
)

View File

@@ -0,0 +1,71 @@
import { NextResponse } from 'next/server'
import { prisma } from '@/lib/db/prisma'
import { generateICalendar } from '@/lib/calendar/ical-generator'
// GET /api/workspaces/[id]/calendar.ics - Get iCal feed
// This endpoint uses a token-based auth for calendar subscription
export async function GET(
req: Request,
{ params }: { params: Promise<Record<string, string>> }
) {
try {
const { id: workspaceId } = await params
const { searchParams } = new URL(req.url)
const token = searchParams.get('token')
if (!token) {
return new NextResponse('Unauthorized - token required', { status: 401 })
}
// Verify the token is a valid workspace membership
// Token format: userId (simplified for now - could be JWT in production)
const membership = await prisma.workspaceMember.findFirst({
where: {
workspaceId,
userId: token,
},
include: {
workspace: true,
},
})
if (!membership) {
return new NextResponse('Unauthorized - invalid token', { status: 401 })
}
// Fetch appointments (non-deleted, future and recent)
const appointments = await prisma.appointment.findMany({
where: {
workspaceId,
deletedAt: null,
},
orderBy: {
datetime: 'asc',
},
})
// Generate iCal
const icalContent = generateICalendar(
appointments.map((a) => ({
id: a.id,
title: a.title,
datetime: a.datetime,
location: a.location,
notes: a.notes,
})),
membership.workspace.name
)
return new NextResponse(icalContent, {
status: 200,
headers: {
'Content-Type': 'text/calendar; charset=utf-8',
'Content-Disposition': `attachment; filename="${membership.workspace.name}-appointments.ics"`,
'Cache-Control': 'no-cache, no-store, must-revalidate',
},
})
} catch (error) {
console.error('iCal generation error:', error)
return new NextResponse('Internal Server Error', { status: 500 })
}
}

View File

@@ -113,6 +113,20 @@ export const POST = withAuth(async (req: AuthenticatedRequest, { params }) => {
},
})
// Decrement pill count if tracking is enabled
if (medication.pillCount !== null && medication.pillsPerDose !== null) {
const newCount = Math.max(0, medication.pillCount - (medication.pillsPerDose || 1))
await prisma.medication.update({
where: { id: medication.id },
data: {
pillCount: newCount,
version: { increment: 1 },
syncedAt: new Date(),
updatedById: req.session.user.id,
},
})
}
// Audit log
await prisma.auditLog.create({
data: {
@@ -165,7 +179,7 @@ export const PATCH = withAuth(async (req: AuthenticatedRequest, { params }) => {
undoneAt: null,
},
include: {
medication: { select: { name: true } },
medication: { select: { id: true, name: true, pillCount: true, pillsPerDose: true } },
},
})
@@ -198,6 +212,20 @@ export const PATCH = withAuth(async (req: AuthenticatedRequest, { params }) => {
},
})
// Restore pill count if tracking is enabled
if (doseLog.medication.pillCount !== null && doseLog.medication.pillsPerDose !== null) {
const newCount = doseLog.medication.pillCount + (doseLog.medication.pillsPerDose || 1)
await prisma.medication.update({
where: { id: doseLog.medication.id },
data: {
pillCount: newCount,
version: { increment: 1 },
syncedAt: new Date(),
updatedById: req.session.user.id,
},
})
}
// Audit log
await prisma.auditLog.create({
data: {

View File

@@ -0,0 +1,132 @@
import { NextResponse } from 'next/server'
import { prisma } from '@/lib/db/prisma'
import { checkWorkspaceAccess } from '@/lib/db/workspace-access'
import { withAuth, type AuthenticatedRequest } from '@/lib/auth'
import { emergencyInfoSchema } from '@/lib/validation'
// GET /api/workspaces/[id]/emergency-info
export const GET = withAuth(async (
req: AuthenticatedRequest,
{ params }: { params: Promise<Record<string, string>> }
) => {
try {
const { id: workspaceId } = await params
const access = await checkWorkspaceAccess(workspaceId, req.session.user.id)
if (!access) {
return NextResponse.json({ error: 'Access denied' }, { status: 403 })
}
const workspace = await prisma.workspace.findUnique({
where: { id: workspaceId },
select: {
id: true,
patientName: true,
patientDOB: true,
bloodType: true,
allergies: true,
medicalConditions: true,
primaryPhysician: true,
physicianPhone: true,
clinicPhone: true,
emergencyPhone: true,
},
})
if (!workspace) {
return NextResponse.json({ error: 'Workspace not found' }, { status: 404 })
}
// Also fetch active medications for the emergency view
const medications = await prisma.medication.findMany({
where: {
workspaceId,
active: true,
deletedAt: null,
},
select: {
id: true,
name: true,
instructions: true,
},
orderBy: { name: 'asc' },
})
return NextResponse.json({
...workspace,
medications,
})
} catch (error) {
console.error('Get emergency info error:', error)
return NextResponse.json({ error: 'Failed to get emergency info' }, { status: 500 })
}
})
// PATCH /api/workspaces/[id]/emergency-info
export const PATCH = withAuth(async (
req: AuthenticatedRequest,
{ params }: { params: Promise<Record<string, string>> }
) => {
try {
const { id: workspaceId } = await params
const body = await req.json()
const access = await checkWorkspaceAccess(workspaceId, req.session.user.id, ['OWNER', 'EDITOR'])
if (!access) {
return NextResponse.json({ error: 'Access denied' }, { status: 403 })
}
const result = emergencyInfoSchema.safeParse(body)
if (!result.success) {
return NextResponse.json(
{ error: 'Invalid input', details: result.error.flatten() },
{ status: 400 }
)
}
const data = result.data
const workspace = await prisma.workspace.update({
where: { id: workspaceId },
data: {
patientName: data.patientName,
patientDOB: data.patientDOB ? new Date(data.patientDOB) : null,
bloodType: data.bloodType,
allergies: data.allergies,
medicalConditions: data.medicalConditions,
primaryPhysician: data.primaryPhysician,
physicianPhone: data.physicianPhone,
clinicPhone: data.clinicPhone,
emergencyPhone: data.emergencyPhone,
},
select: {
id: true,
patientName: true,
patientDOB: true,
bloodType: true,
allergies: true,
medicalConditions: true,
primaryPhysician: true,
physicianPhone: true,
clinicPhone: true,
emergencyPhone: true,
},
})
// Create audit log
await prisma.auditLog.create({
data: {
workspaceId,
userId: req.session.user.id,
action: 'UPDATE',
entityType: 'WORKSPACE',
entityId: workspaceId,
details: { updated: 'emergency_info' },
},
})
return NextResponse.json(workspace)
} catch (error) {
console.error('Update emergency info error:', error)
return NextResponse.json({ error: 'Failed to update emergency info' }, { status: 500 })
}
})

View File

@@ -0,0 +1,107 @@
import { NextResponse } from 'next/server'
import { prisma } from '@/lib/db/prisma'
import { checkWorkspaceAccess } from '@/lib/db/workspace-access'
import { withAuth, type AuthenticatedRequest } from '@/lib/auth'
import { generateMedicalSummaryPDF } from '@/lib/export/pdf-generator'
// GET /api/workspaces/[id]/export/summary.pdf - Generate PDF summary
export const GET = withAuth(async (req: AuthenticatedRequest, { params }: { params: Promise<Record<string, string>> }) => {
try {
const { id: workspaceId } = await params
const access = await checkWorkspaceAccess(workspaceId, req.session.user.id)
if (!access) {
return NextResponse.json({ error: 'Access denied' }, { status: 403 })
}
// Fetch all data
const [workspace, medications, appointments, symptoms] = await Promise.all([
prisma.workspace.findUnique({
where: { id: workspaceId },
select: {
name: true,
patientName: true,
patientDOB: true,
bloodType: true,
allergies: true,
medicalConditions: true,
primaryPhysician: true,
physicianPhone: true,
clinicPhone: true,
emergencyPhone: true,
},
}),
prisma.medication.findMany({
where: { workspaceId, deletedAt: null },
orderBy: { name: 'asc' },
}),
prisma.appointment.findMany({
where: { workspaceId, deletedAt: null },
orderBy: { datetime: 'asc' },
}),
prisma.symptom.findMany({
where: { workspaceId, deletedAt: null },
orderBy: { recordedAt: 'desc' },
take: 100, // Last 100 symptoms
}),
])
if (!workspace) {
return NextResponse.json({ error: 'Workspace not found' }, { status: 404 })
}
// Generate PDF
const doc = generateMedicalSummaryPDF({
patient: workspace,
medications: medications.map((m) => ({
id: m.id,
name: m.name,
instructions: m.instructions,
scheduleType: m.scheduleType,
active: m.active,
})),
appointments: appointments.map((a) => ({
id: a.id,
title: a.title,
datetime: a.datetime,
location: a.location,
notes: a.notes,
})),
symptoms: symptoms.map((s) => ({
id: s.id,
type: s.type,
customName: s.customName,
severity: s.severity,
notes: s.notes,
recordedAt: s.recordedAt,
})),
generatedAt: new Date(),
})
// Convert PDF to buffer
const chunks: Buffer[] = []
doc.on('data', (chunk) => chunks.push(chunk))
await new Promise<void>((resolve, reject) => {
doc.on('end', () => resolve())
doc.on('error', (err) => reject(err))
doc.end()
})
const pdfBuffer = Buffer.concat(chunks)
const filename = `${workspace.patientName || workspace.name || 'medical'}-summary-${new Date().toISOString().split('T')[0]}.pdf`
return new NextResponse(pdfBuffer, {
status: 200,
headers: {
'Content-Type': 'application/pdf',
'Content-Disposition': `attachment; filename="${filename}"`,
'Content-Length': String(pdfBuffer.length),
},
})
} catch (error) {
console.error('PDF generation error:', error)
return NextResponse.json({ error: 'Failed to generate PDF' }, { status: 500 })
}
})

View File

@@ -0,0 +1,86 @@
import { NextResponse } from 'next/server'
import { z } from 'zod'
import { prisma } from '@/lib/db/prisma'
import { checkWorkspaceAccess } from '@/lib/db/workspace-access'
import { withAuth, type AuthenticatedRequest } from '@/lib/auth'
const refillAmountSchema = z.object({
amount: z.number().min(1, 'Amount must be at least 1'),
})
// POST /api/workspaces/[id]/medications/[medicationId]/refill
export const POST = withAuth(async (
req: AuthenticatedRequest,
{ params }: { params: Promise<Record<string, string>> }
) => {
try {
const { id: workspaceId, medicationId } = await params
const body = await req.json()
const access = await checkWorkspaceAccess(workspaceId, req.session.user.id, ['OWNER', 'EDITOR'])
if (!access) {
return NextResponse.json({ error: 'Access denied' }, { status: 403 })
}
const result = refillAmountSchema.safeParse(body)
if (!result.success) {
return NextResponse.json(
{ error: 'Invalid input', details: result.error.flatten() },
{ status: 400 }
)
}
const { amount } = result.data
// Get current medication
const medication = await prisma.medication.findFirst({
where: {
id: medicationId,
workspaceId,
deletedAt: null,
},
})
if (!medication) {
return NextResponse.json({ error: 'Medication not found' }, { status: 404 })
}
// Update pill count
const newPillCount = (medication.pillCount ?? 0) + amount
const updated = await prisma.medication.update({
where: { id: medicationId },
data: {
pillCount: newPillCount,
lastRefillDate: new Date(),
version: { increment: 1 },
syncedAt: new Date(),
updatedById: req.session.user.id,
},
})
// Create audit log
await prisma.auditLog.create({
data: {
workspaceId,
userId: req.session.user.id,
action: 'REFILL',
entityType: 'MEDICATION',
entityId: medicationId,
details: {
amount,
previousCount: medication.pillCount,
newCount: newPillCount,
},
},
})
return NextResponse.json({
id: updated.id,
pillCount: updated.pillCount,
lastRefillDate: updated.lastRefillDate,
})
} catch (error) {
console.error('Refill error:', error)
return NextResponse.json({ error: 'Failed to record refill' }, { status: 500 })
}
})

View File

@@ -2,7 +2,7 @@ import { NextResponse } from 'next/server'
import { prisma } from '@/lib/db/prisma'
import { checkWorkspaceAccess, canEdit } from '@/lib/db/workspace-access'
import { withAuth, type AuthenticatedRequest } from '@/lib/auth'
import { medicationSchema } from '@/lib/validation'
import { medicationWithRefillSchema } from '@/lib/validation'
// GET /api/workspaces/[id]/medications/[medicationId]
export const GET = withAuth(async (req: AuthenticatedRequest, { params }) => {
@@ -75,7 +75,7 @@ export const PATCH = withAuth(async (req: AuthenticatedRequest, { params }) => {
}
const body = await req.json()
const result = medicationSchema.partial().safeParse(body)
const result = medicationWithRefillSchema.partial().safeParse(body)
if (!result.success) {
return NextResponse.json(
@@ -97,6 +97,9 @@ export const PATCH = withAuth(async (req: AuthenticatedRequest, { params }) => {
if (result.data.endDate !== undefined) {
updateData.endDate = result.data.endDate ? new Date(result.data.endDate) : null
}
if (result.data.lastRefillDate !== undefined) {
updateData.lastRefillDate = result.data.lastRefillDate ? new Date(result.data.lastRefillDate) : null
}
const medication = await prisma.medication.update({
where: { id: medicationId },

View File

@@ -2,7 +2,7 @@ import { NextResponse } from 'next/server'
import { prisma } from '@/lib/db/prisma'
import { checkWorkspaceAccess, canEdit } from '@/lib/db/workspace-access'
import { withAuth, type AuthenticatedRequest } from '@/lib/auth'
import { medicationSchema } from '@/lib/validation'
import { medicationWithRefillSchema } from '@/lib/validation'
// GET /api/workspaces/[id]/medications - List medications
export const GET = withAuth(async (req: AuthenticatedRequest, { params }) => {
@@ -60,7 +60,7 @@ export const POST = withAuth(async (req: AuthenticatedRequest, { params }) => {
}
const body = await req.json()
const result = medicationSchema.safeParse(body)
const result = medicationWithRefillSchema.safeParse(body)
if (!result.success) {
return NextResponse.json(
@@ -79,6 +79,11 @@ export const POST = withAuth(async (req: AuthenticatedRequest, { params }) => {
startDate: result.data.startDate ? new Date(result.data.startDate) : null,
endDate: result.data.endDate ? new Date(result.data.endDate) : null,
active: result.data.active ?? true,
// Refill tracking fields
pillCount: result.data.pillCount ?? null,
pillsPerDose: result.data.pillsPerDose ?? 1,
refillThreshold: result.data.refillThreshold ?? 7,
lastRefillDate: result.data.lastRefillDate ? new Date(result.data.lastRefillDate) : null,
createdById: req.session.user.id,
updatedById: req.session.user.id,
},

View File

@@ -0,0 +1,85 @@
import { NextResponse } from 'next/server'
import { prisma } from '@/lib/db/prisma'
import { checkWorkspaceAccess, canEdit } from '@/lib/db/workspace-access'
import { withAuth, type AuthenticatedRequest } from '@/lib/auth'
import { symptomSchema } from '@/lib/validation'
// GET /api/workspaces/[id]/symptoms/[symptomId]
export const GET = withAuth(async (
req: AuthenticatedRequest,
{ params }: { params: Promise<Record<string, string>> }
) => {
try {
const { id: workspaceId, symptomId } = await params
const access = await checkWorkspaceAccess(workspaceId, req.session.user.id)
if (!access) {
return NextResponse.json({ error: 'Access denied' }, { status: 403 })
}
const symptom = await prisma.symptom.findFirst({
where: { id: symptomId, workspaceId },
include: {
createdBy: { select: { id: true, name: true } },
},
})
if (!symptom) {
return NextResponse.json({ error: 'Symptom not found' }, { status: 404 })
}
return NextResponse.json({ symptom })
} catch (error) {
console.error('Get symptom error:', error)
return NextResponse.json({ error: 'Failed to get symptom' }, { status: 500 })
}
})
// DELETE /api/workspaces/[id]/symptoms/[symptomId] (soft delete)
export const DELETE = withAuth(async (
req: AuthenticatedRequest,
{ params }: { params: Promise<Record<string, string>> }
) => {
try {
const { id: workspaceId, symptomId } = await params
const access = await checkWorkspaceAccess(workspaceId, req.session.user.id)
if (!access || !canEdit(access.role)) {
return NextResponse.json({ error: 'Access denied' }, { status: 403 })
}
const existing = await prisma.symptom.findFirst({
where: { id: symptomId, workspaceId, deletedAt: null },
})
if (!existing) {
return NextResponse.json({ error: 'Symptom not found' }, { status: 404 })
}
await prisma.symptom.update({
where: { id: symptomId },
data: {
deletedAt: new Date(),
version: { increment: 1 },
syncedAt: new Date(),
},
})
// Audit log
await prisma.auditLog.create({
data: {
workspaceId,
userId: req.session.user.id,
action: 'DELETE',
entityType: 'SYMPTOM',
entityId: symptomId,
details: { type: existing.type },
},
})
return NextResponse.json({ message: 'Symptom deleted' })
} catch (error) {
console.error('Delete symptom error:', error)
return NextResponse.json({ error: 'Failed to delete symptom' }, { status: 500 })
}
})

View File

@@ -0,0 +1,110 @@
import { NextResponse } from 'next/server'
import { prisma } from '@/lib/db/prisma'
import { checkWorkspaceAccess, canEdit } from '@/lib/db/workspace-access'
import { withAuth, type AuthenticatedRequest } from '@/lib/auth'
import { symptomSchema } from '@/lib/validation'
// GET /api/workspaces/[id]/symptoms
export const GET = withAuth(async (
req: AuthenticatedRequest,
{ params }: { params: Promise<Record<string, string>> }
) => {
try {
const { id: workspaceId } = await params
const access = await checkWorkspaceAccess(workspaceId, req.session.user.id)
if (!access) {
return NextResponse.json({ error: 'Access denied' }, { status: 403 })
}
const { searchParams } = new URL(req.url)
const includeDeleted = searchParams.get('includeDeleted') === 'true'
const type = searchParams.get('type')
const from = searchParams.get('from')
const to = searchParams.get('to')
const limit = Math.min(parseInt(searchParams.get('limit') || '100'), 500)
const where: Record<string, unknown> = {
workspaceId,
...(includeDeleted ? {} : { deletedAt: null }),
...(type ? { type } : {}),
}
if (from || to) {
where.recordedAt = {}
if (from) (where.recordedAt as Record<string, unknown>).gte = new Date(from)
if (to) (where.recordedAt as Record<string, unknown>).lte = new Date(to)
}
const symptoms = await prisma.symptom.findMany({
where,
orderBy: { recordedAt: 'desc' },
take: limit,
include: {
createdBy: { select: { id: true, name: true } },
},
})
return NextResponse.json({ symptoms })
} catch (error) {
console.error('List symptoms error:', error)
return NextResponse.json({ error: 'Failed to list symptoms' }, { status: 500 })
}
})
// POST /api/workspaces/[id]/symptoms
export const POST = withAuth(async (
req: AuthenticatedRequest,
{ params }: { params: Promise<Record<string, string>> }
) => {
try {
const { id: workspaceId } = await params
const access = await checkWorkspaceAccess(workspaceId, req.session.user.id)
if (!access || !canEdit(access.role)) {
return NextResponse.json({ error: 'Access denied' }, { status: 403 })
}
const body = await req.json()
const result = symptomSchema.safeParse(body)
if (!result.success) {
return NextResponse.json(
{ error: 'Invalid input', details: result.error.flatten() },
{ status: 400 }
)
}
const symptom = await prisma.symptom.create({
data: {
workspaceId,
type: result.data.type,
customName: result.data.customName || null,
severity: result.data.severity,
notes: result.data.notes || null,
recordedAt: result.data.recordedAt ? new Date(result.data.recordedAt) : new Date(),
createdById: req.session.user.id,
},
include: {
createdBy: { select: { id: true, name: true } },
},
})
// Audit log
await prisma.auditLog.create({
data: {
workspaceId,
userId: req.session.user.id,
action: 'CREATE',
entityType: 'SYMPTOM',
entityId: symptom.id,
details: { type: symptom.type, severity: symptom.severity },
},
})
return NextResponse.json({ symptom }, { status: 201 })
} catch (error) {
console.error('Create symptom error:', error)
return NextResponse.json({ error: 'Failed to create symptom' }, { status: 500 })
}
})

View File

@@ -1,3 +1,5 @@
@import '../styles/print.css';
@tailwind base;
@tailwind components;
@tailwind utilities;

View File

@@ -1,13 +1,15 @@
'use client'
import { useState } from 'react'
import { useRouter } from 'next/navigation'
import { Suspense, useState } from 'react'
import { useRouter, useSearchParams } from 'next/navigation'
import Link from 'next/link'
import { Heart } from 'lucide-react'
import { Button, Input, Card, showToast } from '@/components/ui'
export default function LoginPage() {
function LoginForm() {
const router = useRouter()
const searchParams = useSearchParams()
const redirectTo = searchParams.get('redirect')
const [email, setEmail] = useState('')
const [password, setPassword] = useState('')
const [loading, setLoading] = useState(false)
@@ -33,7 +35,8 @@ export default function LoginPage() {
}
showToast('Welcome back!', 'success')
router.push('/today')
// If there's a redirect param (e.g., from invite link), go there
router.push(redirectTo || '/today')
router.refresh()
} catch {
setError('Something went wrong. Please try again.')
@@ -97,3 +100,15 @@ export default function LoginPage() {
</div>
)
}
export default function LoginPage() {
return (
<Suspense fallback={
<div className="min-h-screen bg-background flex items-center justify-center">
<div className="w-8 h-8 border-2 border-primary-500 border-t-transparent rounded-full animate-spin" />
</div>
}>
<LoginForm />
</Suspense>
)
}

View File

@@ -1,13 +1,15 @@
'use client'
import { useState } from 'react'
import { useRouter } from 'next/navigation'
import { Suspense, useState } from 'react'
import { useRouter, useSearchParams } from 'next/navigation'
import Link from 'next/link'
import { Heart } from 'lucide-react'
import { Button, Input, Card, showToast } from '@/components/ui'
export default function RegisterPage() {
function RegisterForm() {
const router = useRouter()
const searchParams = useSearchParams()
const redirectTo = searchParams.get('redirect')
const [name, setName] = useState('')
const [email, setEmail] = useState('')
const [password, setPassword] = useState('')
@@ -46,7 +48,8 @@ export default function RegisterPage() {
}
showToast('Account created! Let\'s get started.', 'success')
router.push('/onboarding')
// If there's a redirect param (e.g., from invite link), go there instead of onboarding
router.push(redirectTo || '/onboarding')
router.refresh()
} catch {
setError('Something went wrong. Please try again.')
@@ -128,3 +131,15 @@ export default function RegisterPage() {
</div>
)
}
export default function RegisterPage() {
return (
<Suspense fallback={
<div className="min-h-screen bg-background flex items-center justify-center">
<div className="w-8 h-8 border-2 border-primary-500 border-t-transparent rounded-full animate-spin" />
</div>
}>
<RegisterForm />
</Suspense>
)
}

View File

@@ -0,0 +1,34 @@
'use client'
interface ActivityFilterProps {
entityType: string
onEntityTypeChange: (type: string) => void
}
const ENTITY_TYPES = [
{ value: '', label: 'All' },
{ value: 'MEDICATION', label: 'Medications' },
{ value: 'APPOINTMENT', label: 'Appointments' },
{ value: 'NOTE', label: 'Notes' },
{ value: 'DOSE_LOG', label: 'Doses' },
]
export function ActivityFilter({ entityType, onEntityTypeChange }: ActivityFilterProps) {
return (
<div className="flex gap-2 overflow-x-auto pb-2 scrollbar-hide">
{ENTITY_TYPES.map((type) => (
<button
key={type.value}
onClick={() => onEntityTypeChange(type.value)}
className={`px-3 py-1.5 rounded-full text-sm font-medium whitespace-nowrap transition-colors ${
entityType === type.value
? 'bg-primary-500 text-white'
: 'bg-secondary-100 text-secondary-600 hover:bg-secondary-200'
}`}
>
{type.label}
</button>
))}
</div>
)
}

View File

@@ -0,0 +1,96 @@
'use client'
import { format, formatDistanceToNow } from 'date-fns'
import { Pill, Calendar, FileText, CheckCircle, XCircle, Plus, Edit2, Trash2, RefreshCw, HelpCircle } from 'lucide-react'
interface Activity {
id: string
action: string
entityType: string
entityId: string
details: Record<string, unknown> | null
createdAt: string
user: { id: string; name: string }
}
interface ActivityItemProps {
activity: Activity
}
const ACTION_ICONS: Record<string, React.ReactNode> = {
CREATE: <Plus className="w-4 h-4" />,
UPDATE: <Edit2 className="w-4 h-4" />,
DELETE: <Trash2 className="w-4 h-4" />,
TAKE_DOSE: <CheckCircle className="w-4 h-4" />,
UNDO_DOSE: <XCircle className="w-4 h-4" />,
MARK_ASKED: <HelpCircle className="w-4 h-4" />,
REFILL: <RefreshCw className="w-4 h-4" />,
}
const ENTITY_ICONS: Record<string, React.ReactNode> = {
MEDICATION: <Pill className="w-5 h-5" />,
APPOINTMENT: <Calendar className="w-5 h-5" />,
NOTE: <FileText className="w-5 h-5" />,
DOSE_LOG: <Pill className="w-5 h-5" />,
WORKSPACE: <Edit2 className="w-5 h-5" />,
}
const ACTION_COLORS: Record<string, string> = {
CREATE: 'bg-green-100 text-green-600',
UPDATE: 'bg-blue-100 text-blue-600',
DELETE: 'bg-red-100 text-red-600',
TAKE_DOSE: 'bg-primary-100 text-primary-600',
UNDO_DOSE: 'bg-orange-100 text-orange-600',
MARK_ASKED: 'bg-purple-100 text-purple-600',
REFILL: 'bg-teal-100 text-teal-600',
}
function getActivityDescription(activity: Activity): string {
const details = activity.details || {}
const name = details.name || details.medicationName || details.title || 'item'
switch (activity.action) {
case 'CREATE':
return `Added ${activity.entityType.toLowerCase()} "${name}"`
case 'UPDATE':
if (details.updated === 'emergency_info') {
return 'Updated emergency information'
}
return `Updated ${activity.entityType.toLowerCase()} "${name}"`
case 'DELETE':
return `Deleted ${activity.entityType.toLowerCase()} "${name}"`
case 'TAKE_DOSE':
return `Took ${name}`
case 'UNDO_DOSE':
return `Undid dose of ${name}`
case 'MARK_ASKED':
return 'Marked question as asked'
case 'REFILL':
return `Refilled ${name} (+${details.amount} pills)`
default:
return `${activity.action} ${activity.entityType.toLowerCase()}`
}
}
export function ActivityItem({ activity }: ActivityItemProps) {
const icon = ACTION_ICONS[activity.action] || <Edit2 className="w-4 h-4" />
const entityIcon = ENTITY_ICONS[activity.entityType] || <FileText className="w-5 h-5" />
const colorClass = ACTION_COLORS[activity.action] || 'bg-secondary-100 text-secondary-600'
return (
<div className="flex items-start gap-3 py-3">
<div className={`w-10 h-10 rounded-full flex items-center justify-center flex-shrink-0 ${colorClass}`}>
{entityIcon}
</div>
<div className="flex-1 min-w-0">
<p className="text-secondary-900">{getActivityDescription(activity)}</p>
<p className="text-sm text-secondary-500 mt-0.5">
{activity.user.name} {formatDistanceToNow(new Date(activity.createdAt), { addSuffix: true })}
</p>
</div>
<div className={`w-6 h-6 rounded-full flex items-center justify-center flex-shrink-0 ${colorClass}`}>
{icon}
</div>
</div>
)
}

View File

@@ -0,0 +1,164 @@
'use client'
import { useState, useEffect } from 'react'
import { Check, Square } from 'lucide-react'
import { clsx } from 'clsx'
import { Card, showToast } from '@/components/ui'
import {
DEFAULT_PREP_ITEMS,
CATEGORY_LABELS,
groupItemsByCategory,
type PrepItem,
} from '@/lib/appointments/prep-generator'
interface ChecklistState {
[itemId: string]: boolean
}
interface PrepChecklistProps {
workspaceId: string
appointmentId: string
}
export function PrepChecklist({ workspaceId, appointmentId }: PrepChecklistProps) {
const [checkedItems, setCheckedItems] = useState<ChecklistState>({})
const [loading, setLoading] = useState(true)
const [customItems, setCustomItems] = useState<PrepItem[]>([])
// Load checklist state from server
useEffect(() => {
async function loadChecklist() {
try {
const response = await fetch(
`/api/workspaces/${workspaceId}/appointments/${appointmentId}/checklist`
)
if (response.ok) {
const data = await response.json()
setCheckedItems(data.checkedItems || {})
setCustomItems(data.customItems || [])
}
} catch (err) {
console.error('Failed to load checklist:', err)
} finally {
setLoading(false)
}
}
loadChecklist()
}, [workspaceId, appointmentId])
const handleToggle = async (itemId: string) => {
const newChecked = !checkedItems[itemId]
const newState = { ...checkedItems, [itemId]: newChecked }
setCheckedItems(newState)
// Save to server
try {
await fetch(
`/api/workspaces/${workspaceId}/appointments/${appointmentId}/checklist`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ checkedItems: newState }),
}
)
} catch (err) {
console.error('Failed to save checklist:', err)
showToast('Failed to save', 'error')
}
}
const allItems = [...DEFAULT_PREP_ITEMS, ...customItems]
const groupedItems = groupItemsByCategory(allItems)
const categories = ['documents', 'health', 'comfort', 'questions']
// Calculate progress
const totalItems = allItems.length
const checkedCount = Object.values(checkedItems).filter(Boolean).length
const progressPercent = totalItems > 0 ? Math.round((checkedCount / totalItems) * 100) : 0
if (loading) {
return (
<div className="space-y-4">
{[1, 2, 3].map((i) => (
<div key={i} className="h-20 bg-secondary-100 rounded-lg animate-pulse" />
))}
</div>
)
}
return (
<div className="space-y-6">
{/* Progress bar */}
<div className="bg-secondary-100 rounded-lg p-4">
<div className="flex items-center justify-between mb-2">
<span className="text-sm font-medium text-secondary-700">Progress</span>
<span className="text-sm font-semibold text-primary-600">
{checkedCount} / {totalItems} items
</span>
</div>
<div className="w-full bg-secondary-200 rounded-full h-3">
<div
className="bg-primary-500 h-3 rounded-full transition-all duration-300"
style={{ width: `${progressPercent}%` }}
/>
</div>
{progressPercent === 100 && (
<p className="text-sm text-green-600 font-medium mt-2 text-center">
All set for your appointment!
</p>
)}
</div>
{/* Checklist items grouped by category */}
{categories.map((category) => {
const items = groupedItems[category] || []
if (items.length === 0) return null
return (
<Card key={category}>
<h3 className="text-sm font-semibold text-secondary-600 mb-3">
{CATEGORY_LABELS[category]}
</h3>
<div className="space-y-2">
{items.map((item) => (
<button
key={item.id}
onClick={() => handleToggle(item.id)}
className={clsx(
'w-full flex items-center gap-3 p-3 rounded-lg transition-colors text-left',
checkedItems[item.id]
? 'bg-green-50 hover:bg-green-100'
: 'bg-secondary-50 hover:bg-secondary-100'
)}
>
<div
className={clsx(
'w-6 h-6 rounded flex items-center justify-center flex-shrink-0',
checkedItems[item.id]
? 'bg-green-500 text-white'
: 'border-2 border-secondary-300'
)}
>
{checkedItems[item.id] && <Check className="w-4 h-4" />}
</div>
<span
className={clsx(
'flex-1',
checkedItems[item.id]
? 'text-secondary-500 line-through'
: 'text-secondary-900'
)}
>
{item.label}
</span>
</button>
))}
</div>
</Card>
)
})}
</div>
)
}

View File

@@ -0,0 +1,71 @@
'use client'
import { format, isToday, isSameMonth, isSameDay } from 'date-fns'
import { clsx } from 'clsx'
interface Appointment {
id: string
title: string
datetime: string
}
interface CalendarDayCellProps {
date: Date
currentMonth: Date
appointments: Appointment[]
selectedDate?: Date
onSelect: (date: Date) => void
}
export function CalendarDayCell({
date,
currentMonth,
appointments,
selectedDate,
onSelect,
}: CalendarDayCellProps) {
const isCurrentMonth = isSameMonth(date, currentMonth)
const isSelected = selectedDate && isSameDay(date, selectedDate)
const dayAppointments = appointments.filter((a) =>
isSameDay(new Date(a.datetime), date)
)
const hasAppointments = dayAppointments.length > 0
return (
<button
onClick={() => onSelect(date)}
className={clsx(
'flex flex-col items-center justify-start p-1 min-h-[60px] rounded-lg transition-colors',
isCurrentMonth ? 'text-secondary-900' : 'text-secondary-300',
isToday(date) && 'bg-primary-50 ring-1 ring-primary-200',
isSelected && 'bg-primary-100 ring-2 ring-primary-500',
!isSelected && isCurrentMonth && 'hover:bg-secondary-50'
)}
>
<span
className={clsx(
'text-sm font-medium w-7 h-7 flex items-center justify-center rounded-full',
isToday(date) && 'bg-primary-500 text-white'
)}
>
{format(date, 'd')}
</span>
{hasAppointments && (
<div className="flex flex-wrap gap-0.5 mt-1 justify-center">
{dayAppointments.slice(0, 3).map((_, idx) => (
<div
key={idx}
className="w-1.5 h-1.5 rounded-full bg-primary-500"
/>
))}
{dayAppointments.length > 3 && (
<span className="text-[10px] text-primary-600 font-medium">
+{dayAppointments.length - 3}
</span>
)}
</div>
)}
</button>
)
}

View File

@@ -0,0 +1,170 @@
'use client'
import {
format,
startOfMonth,
endOfMonth,
startOfWeek,
endOfWeek,
addDays,
addMonths,
subMonths,
isSameDay,
} from 'date-fns'
import { ChevronLeft, ChevronRight } from 'lucide-react'
import { CalendarDayCell } from './CalendarDayCell'
interface Appointment {
id: string
title: string
datetime: string
location: string | null
}
interface CalendarMonthProps {
appointments: Appointment[]
selectedDate: Date
onDateSelect: (date: Date) => void
onMonthChange: (date: Date) => void
currentMonth: Date
}
export function CalendarMonth({
appointments,
selectedDate,
onDateSelect,
onMonthChange,
currentMonth,
}: CalendarMonthProps) {
const monthStart = startOfMonth(currentMonth)
const monthEnd = endOfMonth(currentMonth)
const calendarStart = startOfWeek(monthStart)
const calendarEnd = endOfWeek(monthEnd)
// Generate all days in the calendar view
const days: Date[] = []
let day = calendarStart
while (day <= calendarEnd) {
days.push(day)
day = addDays(day, 1)
}
// Group into weeks
const weeks: Date[][] = []
for (let i = 0; i < days.length; i += 7) {
weeks.push(days.slice(i, i + 7))
}
const handlePrevMonth = () => {
onMonthChange(subMonths(currentMonth, 1))
}
const handleNextMonth = () => {
onMonthChange(addMonths(currentMonth, 1))
}
const handleToday = () => {
const today = new Date()
onMonthChange(today)
onDateSelect(today)
}
// Get appointments for selected date
const selectedDateAppointments = appointments
.filter((a) => isSameDay(new Date(a.datetime), selectedDate))
.sort((a, b) => new Date(a.datetime).getTime() - new Date(b.datetime).getTime())
return (
<div className="space-y-4">
{/* Month navigation */}
<div className="flex items-center justify-between">
<button
onClick={handlePrevMonth}
className="p-2 hover:bg-secondary-100 rounded-lg transition-colors"
aria-label="Previous month"
>
<ChevronLeft className="w-5 h-5 text-secondary-600" />
</button>
<div className="flex items-center gap-3">
<h2 className="text-lg font-semibold text-secondary-900">
{format(currentMonth, 'MMMM yyyy')}
</h2>
<button
onClick={handleToday}
className="text-sm text-primary-600 font-medium hover:underline"
>
Today
</button>
</div>
<button
onClick={handleNextMonth}
className="p-2 hover:bg-secondary-100 rounded-lg transition-colors"
aria-label="Next month"
>
<ChevronRight className="w-5 h-5 text-secondary-600" />
</button>
</div>
{/* Weekday headers */}
<div className="grid grid-cols-7 gap-1">
{['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'].map((dayName) => (
<div
key={dayName}
className="text-center text-xs font-medium text-secondary-500 py-2"
>
{dayName}
</div>
))}
</div>
{/* Calendar grid */}
<div className="grid grid-cols-7 gap-1">
{weeks.map((week, weekIdx) =>
week.map((date, dayIdx) => (
<CalendarDayCell
key={`${weekIdx}-${dayIdx}`}
date={date}
currentMonth={currentMonth}
appointments={appointments}
selectedDate={selectedDate}
onSelect={onDateSelect}
/>
))
)}
</div>
{/* Selected date appointments */}
<div className="pt-4 border-t border-border">
<h3 className="text-sm font-semibold text-secondary-600 mb-3">
{format(selectedDate, 'EEEE, MMMM d')}
</h3>
{selectedDateAppointments.length === 0 ? (
<p className="text-secondary-500 text-sm">No appointments</p>
) : (
<div className="space-y-2">
{selectedDateAppointments.map((appt) => (
<div
key={appt.id}
className="flex items-start gap-3 p-3 bg-secondary-50 rounded-lg"
>
<div className="text-sm font-medium text-primary-600 whitespace-nowrap">
{format(new Date(appt.datetime), 'h:mm a')}
</div>
<div className="flex-1">
<p className="font-medium text-secondary-900">{appt.title}</p>
{appt.location && (
<p className="text-sm text-secondary-500">{appt.location}</p>
)}
</div>
</div>
))}
</div>
)}
</div>
</div>
)
}

View File

@@ -0,0 +1,178 @@
'use client'
import { Phone, User, Droplets, AlertTriangle, Activity, Stethoscope } from 'lucide-react'
import { format } from 'date-fns'
interface EmergencyInfo {
patientName: string | null
patientDOB: string | null
bloodType: string | null
allergies: string | null
medicalConditions: string | null
primaryPhysician: string | null
physicianPhone: string | null
clinicPhone: string | null
emergencyPhone: string | null
}
interface EmergencyCardProps {
info: EmergencyInfo
medications?: { name: string; instructions: string | null }[]
variant?: 'full' | 'compact'
}
export function EmergencyCard({ info, medications, variant = 'full' }: EmergencyCardProps) {
const formatDate = (dateStr: string | null) => {
if (!dateStr) return null
try {
return format(new Date(dateStr), 'MMMM d, yyyy')
} catch {
return dateStr
}
}
const hasEmergencyInfo = info.patientName || info.bloodType || info.allergies ||
info.medicalConditions || info.primaryPhysician
if (!hasEmergencyInfo && variant === 'compact') {
return null
}
return (
<div className="bg-red-50 border-2 border-red-200 rounded-xl overflow-hidden">
{/* Header */}
<div className="bg-red-600 text-white px-4 py-3">
<div className="flex items-center gap-2">
<AlertTriangle className="w-6 h-6" />
<h2 className="text-xl font-bold">Emergency Information</h2>
</div>
</div>
<div className="p-4 space-y-4">
{/* Patient Info */}
{info.patientName && (
<div className="flex items-start gap-3">
<User className="w-5 h-5 text-red-600 mt-0.5" />
<div>
<p className="text-sm text-red-700 font-medium">Patient Name</p>
<p className="text-lg font-bold text-secondary-900">{info.patientName}</p>
{info.patientDOB && (
<p className="text-sm text-secondary-600">DOB: {formatDate(info.patientDOB)}</p>
)}
</div>
</div>
)}
{/* Blood Type */}
{info.bloodType && (
<div className="flex items-start gap-3">
<Droplets className="w-5 h-5 text-red-600 mt-0.5" />
<div>
<p className="text-sm text-red-700 font-medium">Blood Type</p>
<p className="text-2xl font-bold text-red-600">{info.bloodType}</p>
</div>
</div>
)}
{/* Allergies - High visibility */}
{info.allergies && (
<div className="bg-red-100 border border-red-300 rounded-lg p-3">
<div className="flex items-start gap-3">
<AlertTriangle className="w-5 h-5 text-red-600 mt-0.5 flex-shrink-0" />
<div>
<p className="text-sm text-red-700 font-bold uppercase">Allergies</p>
<p className="text-secondary-900 font-medium mt-1">{info.allergies}</p>
</div>
</div>
</div>
)}
{/* Medical Conditions */}
{info.medicalConditions && (
<div className="flex items-start gap-3">
<Activity className="w-5 h-5 text-red-600 mt-0.5" />
<div>
<p className="text-sm text-red-700 font-medium">Medical Conditions</p>
<p className="text-secondary-900">{info.medicalConditions}</p>
</div>
</div>
)}
{/* Current Medications */}
{variant === 'full' && medications && medications.length > 0 && (
<div className="border-t border-red-200 pt-4">
<p className="text-sm text-red-700 font-bold mb-2">Current Medications</p>
<ul className="space-y-1">
{medications.map((med, i) => (
<li key={i} className="text-secondary-900">
<span className="font-medium">{med.name}</span>
{med.instructions && (
<span className="text-secondary-600"> - {med.instructions}</span>
)}
</li>
))}
</ul>
</div>
)}
{/* Doctor Info */}
{info.primaryPhysician && (
<div className="border-t border-red-200 pt-4">
<div className="flex items-start gap-3">
<Stethoscope className="w-5 h-5 text-red-600 mt-0.5" />
<div>
<p className="text-sm text-red-700 font-medium">Primary Physician</p>
<p className="text-secondary-900 font-medium">{info.primaryPhysician}</p>
{info.physicianPhone && (
<a
href={`tel:${info.physicianPhone}`}
className="text-primary-600 hover:underline"
>
{info.physicianPhone}
</a>
)}
</div>
</div>
</div>
)}
{/* Emergency Contacts */}
{(info.clinicPhone || info.emergencyPhone) && (
<div className="border-t border-red-200 pt-4 space-y-3">
<p className="text-sm text-red-700 font-bold">Emergency Contacts</p>
{info.clinicPhone && (
<a
href={`tel:${info.clinicPhone}`}
className="flex items-center gap-3 p-3 bg-white rounded-lg border border-red-200 hover:bg-red-50 transition-colors"
>
<div className="w-10 h-10 rounded-full bg-red-600 flex items-center justify-center">
<Phone className="w-5 h-5 text-white" />
</div>
<div>
<p className="font-medium text-secondary-900">Call Clinic</p>
<p className="text-sm text-secondary-600">{info.clinicPhone}</p>
</div>
</a>
)}
{info.emergencyPhone && (
<a
href={`tel:${info.emergencyPhone}`}
className="flex items-center gap-3 p-3 bg-white rounded-lg border border-red-200 hover:bg-red-50 transition-colors"
>
<div className="w-10 h-10 rounded-full bg-red-600 flex items-center justify-center">
<Phone className="w-5 h-5 text-white" />
</div>
<div>
<p className="font-medium text-secondary-900">Emergency Contact</p>
<p className="text-sm text-secondary-600">{info.emergencyPhone}</p>
</div>
</a>
)}
</div>
)}
</div>
</div>
)
}

View File

@@ -3,13 +3,13 @@
import { usePathname } from 'next/navigation'
import Link from 'next/link'
import { clsx } from 'clsx'
import { Home, Calendar, Pill, FileText, MoreHorizontal } from 'lucide-react'
import { Home, Calendar, Pill, Activity, MoreHorizontal } from 'lucide-react'
const navItems = [
{ href: '/today', label: 'Today', icon: Home },
{ href: '/appointments', label: 'Appointments', icon: Calendar },
{ href: '/appointments', label: 'Appts', icon: Calendar },
{ href: '/meds', label: 'Meds', icon: Pill },
{ href: '/notes', label: 'Notes', icon: FileText },
{ href: '/symptoms', label: 'Symptoms', icon: Activity },
{ href: '/settings', label: 'More', icon: MoreHorizontal },
]

View File

@@ -0,0 +1,50 @@
'use client'
import { AlertTriangle } from 'lucide-react'
import { useRouter } from 'next/navigation'
interface RefillAlertProps {
medications: {
id: string
name: string
pillCount: number | null
refillThreshold: number | null
}[]
}
export function RefillAlert({ medications }: RefillAlertProps) {
const router = useRouter()
const lowMeds = medications.filter(
m => m.pillCount !== null && m.refillThreshold !== null && m.pillCount <= m.refillThreshold
)
if (lowMeds.length === 0) {
return null
}
return (
<div className="bg-orange-50 border border-orange-200 rounded-lg p-4">
<div className="flex items-start gap-3">
<AlertTriangle className="w-5 h-5 text-orange-600 mt-0.5 flex-shrink-0" />
<div className="flex-1">
<p className="font-semibold text-orange-800">
{lowMeds.length === 1 ? 'Medication running low' : `${lowMeds.length} medications running low`}
</p>
<ul className="mt-1 space-y-1">
{lowMeds.map(med => (
<li key={med.id}>
<button
onClick={() => router.push(`/meds/${med.id}`)}
className="text-sm text-orange-700 hover:text-orange-900 hover:underline"
>
{med.name} - {med.pillCount} pills left
</button>
</li>
))}
</ul>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,147 @@
'use client'
import { useState } from 'react'
import { Pill, Plus, Minus, RefreshCw } from 'lucide-react'
import { Button, Input, Modal, showToast } from '@/components/ui'
interface RefillTrackerProps {
medicationId: string
workspaceId: string
medicationName: string
pillCount: number | null
pillsPerDose: number | null
refillThreshold: number | null
onRefill?: () => void
}
export function RefillTracker({
medicationId,
workspaceId,
medicationName,
pillCount,
pillsPerDose = 1,
refillThreshold = 7,
onRefill,
}: RefillTrackerProps) {
const [showRefillModal, setShowRefillModal] = useState(false)
const [refillAmount, setRefillAmount] = useState('')
const [saving, setSaving] = useState(false)
const isLow = pillCount !== null && refillThreshold !== null && pillCount <= refillThreshold
const dosesRemaining = pillCount !== null && pillsPerDose !== null
? Math.floor(pillCount / pillsPerDose)
: null
const handleRefill = async () => {
const amount = parseInt(refillAmount, 10)
if (isNaN(amount) || amount <= 0) {
showToast('Please enter a valid amount', 'error')
return
}
setSaving(true)
try {
const response = await fetch(
`/api/workspaces/${workspaceId}/medications/${medicationId}/refill`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ amount }),
}
)
if (!response.ok) throw new Error('Failed to refill')
showToast(`Added ${amount} pills to ${medicationName}`, 'success')
setShowRefillModal(false)
setRefillAmount('')
onRefill?.()
} catch {
showToast('Failed to record refill', 'error')
} finally {
setSaving(false)
}
}
if (pillCount === null) {
return null
}
return (
<>
<div className={`rounded-lg p-4 ${isLow ? 'bg-orange-50 border border-orange-200' : 'bg-secondary-50'}`}>
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className={`w-10 h-10 rounded-full flex items-center justify-center ${isLow ? 'bg-orange-100' : 'bg-secondary-100'}`}>
<Pill className={`w-5 h-5 ${isLow ? 'text-orange-600' : 'text-secondary-600'}`} />
</div>
<div>
<p className="font-semibold text-secondary-900">
{pillCount} pills remaining
</p>
{dosesRemaining !== null && (
<p className={`text-sm ${isLow ? 'text-orange-600 font-medium' : 'text-secondary-500'}`}>
{dosesRemaining} doses left
{isLow && ' • Refill soon'}
</p>
)}
</div>
</div>
<Button
variant={isLow ? 'primary' : 'secondary'}
size="sm"
onClick={() => setShowRefillModal(true)}
>
<RefreshCw className="w-4 h-4 mr-1" />
Refill
</Button>
</div>
</div>
<Modal
isOpen={showRefillModal}
onClose={() => setShowRefillModal(false)}
title={`Refill ${medicationName}`}
>
<div className="space-y-4">
<p className="text-secondary-600">
Enter the number of pills you're adding.
</p>
<div className="flex items-center gap-3">
<button
type="button"
onClick={() => setRefillAmount(String(Math.max(0, (parseInt(refillAmount) || 0) - 10)))}
className="w-12 h-12 rounded-full bg-secondary-100 flex items-center justify-center hover:bg-secondary-200 transition-colors"
>
<Minus className="w-5 h-5" />
</button>
<Input
type="number"
value={refillAmount}
onChange={(e) => setRefillAmount(e.target.value)}
placeholder="0"
className="text-center text-2xl font-bold"
min={0}
/>
<button
type="button"
onClick={() => setRefillAmount(String((parseInt(refillAmount) || 0) + 10))}
className="w-12 h-12 rounded-full bg-secondary-100 flex items-center justify-center hover:bg-secondary-200 transition-colors"
>
<Plus className="w-5 h-5" />
</button>
</div>
<p className="text-sm text-secondary-500 text-center">
Current: {pillCount} pills
{refillAmount && parseInt(refillAmount) > 0 && (
<> After refill: {pillCount + parseInt(refillAmount)} pills</>
)}
</p>
<Button onClick={handleRefill} fullWidth loading={saving}>
Record Refill
</Button>
</div>
</Modal>
</>
)
}

View File

@@ -0,0 +1,227 @@
'use client'
import { useState, useEffect } from 'react'
import { Bell, BellOff, CheckCircle, AlertCircle, Loader } from 'lucide-react'
import { Button, showToast } from '@/components/ui'
interface NotificationPermissionProps {
workspaceId: string
}
type PermissionState = 'unsupported' | 'denied' | 'granted' | 'default' | 'loading'
export function NotificationPermission({ workspaceId }: NotificationPermissionProps) {
const [permission, setPermission] = useState<PermissionState>('loading')
const [isSubscribed, setIsSubscribed] = useState(false)
const [loading, setLoading] = useState(false)
useEffect(() => {
checkPermission()
}, [])
async function checkPermission() {
if (!('Notification' in window)) {
setPermission('unsupported')
return
}
if (!('serviceWorker' in navigator)) {
setPermission('unsupported')
return
}
const perm = Notification.permission as PermissionState
setPermission(perm)
if (perm === 'granted') {
// Check if already subscribed
try {
const registration = await navigator.serviceWorker.ready
const subscription = await registration.pushManager.getSubscription()
setIsSubscribed(!!subscription)
} catch (err) {
console.error('Failed to check subscription:', err)
}
}
}
async function handleEnable() {
setLoading(true)
try {
// Request notification permission
const perm = await Notification.requestPermission()
setPermission(perm as PermissionState)
if (perm !== 'granted') {
showToast('Permission denied', 'error')
return
}
// Get VAPID public key
const keyResponse = await fetch('/api/notifications/subscribe')
if (!keyResponse.ok) {
throw new Error('Push notifications not available')
}
const { publicKey } = await keyResponse.json()
// Register service worker if not already registered
const registration = await navigator.serviceWorker.ready
// Subscribe to push
const subscription = await registration.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: urlBase64ToUint8Array(publicKey),
})
// Send subscription to server
const response = await fetch('/api/notifications/subscribe', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
subscription: subscription.toJSON(),
workspaceId,
}),
})
if (!response.ok) {
throw new Error('Failed to save subscription')
}
setIsSubscribed(true)
showToast('Notifications enabled!', 'success')
} catch (err: any) {
console.error('Enable notifications error:', err)
showToast(err.message || 'Failed to enable notifications', 'error')
} finally {
setLoading(false)
}
}
async function handleDisable() {
setLoading(true)
try {
const registration = await navigator.serviceWorker.ready
const subscription = await registration.pushManager.getSubscription()
if (subscription) {
await subscription.unsubscribe()
// Remove from server
await fetch(
`/api/notifications/subscribe?endpoint=${encodeURIComponent(subscription.endpoint)}`,
{ method: 'DELETE' }
)
}
setIsSubscribed(false)
showToast('Notifications disabled', 'info')
} catch (err) {
console.error('Disable notifications error:', err)
showToast('Failed to disable notifications', 'error')
} finally {
setLoading(false)
}
}
if (permission === 'loading') {
return (
<div className="flex items-center gap-3 p-4 bg-secondary-50 rounded-lg">
<Loader className="w-5 h-5 animate-spin text-secondary-500" />
<span className="text-secondary-600">Checking notification status...</span>
</div>
)
}
if (permission === 'unsupported') {
return (
<div className="flex items-center gap-3 p-4 bg-yellow-50 rounded-lg">
<AlertCircle className="w-5 h-5 text-yellow-600" />
<div>
<p className="font-medium text-yellow-800">Not supported</p>
<p className="text-sm text-yellow-700">
Push notifications are not supported in this browser.
</p>
</div>
</div>
)
}
if (permission === 'denied') {
return (
<div className="flex items-center gap-3 p-4 bg-red-50 rounded-lg">
<BellOff className="w-5 h-5 text-red-600" />
<div>
<p className="font-medium text-red-800">Blocked</p>
<p className="text-sm text-red-700">
Notifications are blocked. Please enable them in your browser settings.
</p>
</div>
</div>
)
}
if (isSubscribed) {
return (
<div className="space-y-3">
<div className="flex items-center gap-3 p-4 bg-green-50 rounded-lg">
<CheckCircle className="w-5 h-5 text-green-600" />
<div className="flex-1">
<p className="font-medium text-green-800">Notifications enabled</p>
<p className="text-sm text-green-700">
You'll receive medication reminders on this device.
</p>
</div>
</div>
<Button
variant="secondary"
onClick={handleDisable}
loading={loading}
className="w-full"
>
<BellOff className="w-4 h-4 mr-2" />
Disable Notifications
</Button>
</div>
)
}
return (
<div className="space-y-3">
<div className="flex items-center gap-3 p-4 bg-secondary-50 rounded-lg">
<Bell className="w-5 h-5 text-secondary-600" />
<div>
<p className="font-medium text-secondary-800">Enable notifications</p>
<p className="text-sm text-secondary-600">
Get reminders when it's time to take your medications.
</p>
</div>
</div>
<Button
onClick={handleEnable}
loading={loading}
className="w-full"
>
<Bell className="w-4 h-4 mr-2" />
Enable Notifications
</Button>
</div>
)
}
// Helper function to convert VAPID key
function urlBase64ToUint8Array(base64String: string): Uint8Array<ArrayBuffer> {
const padding = '='.repeat((4 - (base64String.length % 4)) % 4)
const base64 = (base64String + padding).replace(/-/g, '+').replace(/_/g, '/')
const rawData = window.atob(base64)
const buffer = new ArrayBuffer(rawData.length)
const outputArray = new Uint8Array(buffer)
for (let i = 0; i < rawData.length; ++i) {
outputArray[i] = rawData.charCodeAt(i)
}
return outputArray
}

View File

@@ -0,0 +1,26 @@
'use client'
import { useEffect } from 'react'
export function ServiceWorkerRegistrar() {
useEffect(() => {
if ('serviceWorker' in navigator) {
// Register service worker
navigator.serviceWorker
.register('/sw.js')
.then((registration) => {
console.log('Service Worker registered with scope:', registration.scope)
// Check for updates periodically
setInterval(() => {
registration.update()
}, 60 * 60 * 1000) // Check every hour
})
.catch((error) => {
console.error('Service Worker registration failed:', error)
})
}
}, [])
return null
}

View File

@@ -0,0 +1,88 @@
'use client'
import { format, formatDistanceToNow } from 'date-fns'
const SYMPTOM_INFO: Record<string, { emoji: string; label: string }> = {
FATIGUE: { emoji: '😴', label: 'Fatigue' },
NAUSEA: { emoji: '🤢', label: 'Nausea' },
PAIN: { emoji: '😣', label: 'Pain' },
APPETITE: { emoji: '🍽️', label: 'Appetite' },
SLEEP: { emoji: '😴', label: 'Sleep' },
MOOD: { emoji: '😔', label: 'Mood' },
CUSTOM: { emoji: '📝', label: 'Custom' },
}
const SEVERITY_COLORS: Record<number, string> = {
1: 'bg-green-100 text-green-800 border-green-200',
2: 'bg-lime-100 text-lime-800 border-lime-200',
3: 'bg-yellow-100 text-yellow-800 border-yellow-200',
4: 'bg-orange-100 text-orange-800 border-orange-200',
5: 'bg-red-100 text-red-800 border-red-200',
}
const SEVERITY_LABELS: Record<number, string> = {
1: 'Minimal',
2: 'Mild',
3: 'Moderate',
4: 'Severe',
5: 'Extreme',
}
interface Symptom {
id: string
type: string
customName: string | null
severity: number
notes: string | null
recordedAt: string
createdBy?: { id: string; name: string }
}
interface SymptomCardProps {
symptom: Symptom
compact?: boolean
}
export function SymptomCard({ symptom, compact = false }: SymptomCardProps) {
const info = SYMPTOM_INFO[symptom.type] || SYMPTOM_INFO.CUSTOM
const displayName = symptom.type === 'CUSTOM' && symptom.customName
? symptom.customName
: info.label
if (compact) {
return (
<div className={`flex items-center gap-2 px-3 py-2 rounded-lg border ${SEVERITY_COLORS[symptom.severity]}`}>
<span className="text-lg">{info.emoji}</span>
<span className="font-medium">{displayName}</span>
<span className="text-xs opacity-75">
{formatDistanceToNow(new Date(symptom.recordedAt), { addSuffix: true })}
</span>
</div>
)
}
return (
<div className="bg-surface rounded-lg border border-border p-4">
<div className="flex items-start gap-3">
<div className={`w-12 h-12 rounded-full flex items-center justify-center ${SEVERITY_COLORS[symptom.severity]}`}>
<span className="text-2xl">{info.emoji}</span>
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<h3 className="font-semibold text-secondary-900">{displayName}</h3>
<span className={`px-2 py-0.5 rounded-full text-xs font-medium ${SEVERITY_COLORS[symptom.severity]}`}>
{SEVERITY_LABELS[symptom.severity]}
</span>
</div>
<p className="text-sm text-secondary-500 mt-0.5">
{format(new Date(symptom.recordedAt), 'EEEE, MMM d \'at\' h:mm a')}
{symptom.createdBy && `${symptom.createdBy.name}`}
</p>
{symptom.notes && (
<p className="text-sm text-secondary-600 mt-2">{symptom.notes}</p>
)}
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,112 @@
'use client'
import { format, subDays, startOfDay, isSameDay } from 'date-fns'
interface Symptom {
id: string
type: string
severity: number
recordedAt: string
}
interface SymptomChartProps {
symptoms: Symptom[]
days?: number
type?: string
}
const SEVERITY_COLORS = [
'#22c55e', // 1 - green
'#84cc16', // 2 - lime
'#eab308', // 3 - yellow
'#f97316', // 4 - orange
'#ef4444', // 5 - red
]
export function SymptomChart({ symptoms, days = 7, type }: SymptomChartProps) {
// Generate date range
const today = startOfDay(new Date())
const dates = Array.from({ length: days }, (_, i) => subDays(today, days - 1 - i))
// Filter by type if specified
const filtered = type
? symptoms.filter(s => s.type === type)
: symptoms
// Group symptoms by date and calculate average severity
const dataByDate = dates.map(date => {
const daySymptoms = filtered.filter(s =>
isSameDay(new Date(s.recordedAt), date)
)
const avgSeverity = daySymptoms.length > 0
? daySymptoms.reduce((sum, s) => sum + s.severity, 0) / daySymptoms.length
: 0
return {
date,
avgSeverity,
count: daySymptoms.length,
}
})
const maxHeight = 100
if (filtered.length === 0) {
return (
<div className="text-center py-8 text-secondary-500">
No symptom data to display
</div>
)
}
return (
<div className="space-y-2">
{/* Chart */}
<div className="flex items-end gap-1 h-32">
{dataByDate.map((day, i) => {
const height = day.avgSeverity > 0 ? (day.avgSeverity / 5) * maxHeight : 4
const colorIndex = day.avgSeverity > 0 ? Math.round(day.avgSeverity) - 1 : 0
return (
<div key={i} className="flex-1 flex flex-col items-center gap-1">
<div
className="w-full rounded-t transition-all"
style={{
height: `${height}%`,
backgroundColor: day.avgSeverity > 0 ? SEVERITY_COLORS[colorIndex] : '#e5e7eb',
minHeight: day.avgSeverity > 0 ? '8px' : '4px',
}}
title={day.avgSeverity > 0 ? `Avg: ${day.avgSeverity.toFixed(1)}` : 'No data'}
/>
</div>
)
})}
</div>
{/* Date labels */}
<div className="flex gap-1">
{dataByDate.map((day, i) => (
<div key={i} className="flex-1 text-center">
<span className="text-xs text-secondary-500">
{format(day.date, 'EEE')}
</span>
</div>
))}
</div>
{/* Legend */}
<div className="flex justify-center gap-3 text-xs text-secondary-500 pt-2">
<div className="flex items-center gap-1">
<div className="w-3 h-3 rounded" style={{ backgroundColor: SEVERITY_COLORS[0] }} />
<span>Minimal</span>
</div>
<div className="flex items-center gap-1">
<div className="w-3 h-3 rounded" style={{ backgroundColor: SEVERITY_COLORS[2] }} />
<span>Moderate</span>
</div>
<div className="flex items-center gap-1">
<div className="w-3 h-3 rounded" style={{ backgroundColor: SEVERITY_COLORS[4] }} />
<span>Extreme</span>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,145 @@
'use client'
import { useState } from 'react'
import { Button, showToast } from '@/components/ui'
const SYMPTOM_TYPES = [
{ type: 'FATIGUE', emoji: '😴', label: 'Fatigue' },
{ type: 'NAUSEA', emoji: '🤢', label: 'Nausea' },
{ type: 'PAIN', emoji: '😣', label: 'Pain' },
{ type: 'APPETITE', emoji: '🍽️', label: 'Appetite' },
{ type: 'SLEEP', emoji: '😴', label: 'Sleep' },
{ type: 'MOOD', emoji: '😔', label: 'Mood' },
]
const SEVERITY_LABELS = [
{ value: 1, label: 'Minimal', color: 'bg-green-500' },
{ value: 2, label: 'Mild', color: 'bg-lime-500' },
{ value: 3, label: 'Moderate', color: 'bg-yellow-500' },
{ value: 4, label: 'Severe', color: 'bg-orange-500' },
{ value: 5, label: 'Extreme', color: 'bg-red-500' },
]
interface SymptomQuickLogProps {
workspaceId: string
onLogged?: () => void
}
export function SymptomQuickLog({ workspaceId, onLogged }: SymptomQuickLogProps) {
const [selectedType, setSelectedType] = useState<string | null>(null)
const [severity, setSeverity] = useState(3)
const [notes, setNotes] = useState('')
const [saving, setSaving] = useState(false)
const handleSubmit = async () => {
if (!selectedType) {
showToast('Please select a symptom', 'error')
return
}
setSaving(true)
try {
const response = await fetch(`/api/workspaces/${workspaceId}/symptoms`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
type: selectedType,
severity,
notes: notes.trim() || null,
}),
})
if (!response.ok) throw new Error('Failed to log symptom')
showToast('Symptom logged', 'success')
setSelectedType(null)
setSeverity(3)
setNotes('')
onLogged?.()
} catch {
showToast('Failed to log symptom', 'error')
} finally {
setSaving(false)
}
}
return (
<div className="space-y-4">
{/* Symptom Type Selection */}
<div>
<label className="block text-sm font-medium text-secondary-700 mb-2">
How are you feeling?
</label>
<div className="grid grid-cols-3 gap-2">
{SYMPTOM_TYPES.map((symptom) => (
<button
key={symptom.type}
type="button"
onClick={() => setSelectedType(symptom.type)}
className={`flex flex-col items-center gap-1 p-3 rounded-lg border-2 transition-all ${
selectedType === symptom.type
? 'border-primary-500 bg-primary-50'
: 'border-border hover:border-secondary-300 hover:bg-muted'
}`}
>
<span className="text-2xl">{symptom.emoji}</span>
<span className="text-xs font-medium text-secondary-700">{symptom.label}</span>
</button>
))}
</div>
</div>
{/* Severity Slider */}
{selectedType && (
<div>
<label className="block text-sm font-medium text-secondary-700 mb-2">
Severity: <span className="font-bold">{SEVERITY_LABELS[severity - 1].label}</span>
</label>
<div className="flex items-center gap-2">
{SEVERITY_LABELS.map((level) => (
<button
key={level.value}
type="button"
onClick={() => setSeverity(level.value)}
className={`flex-1 h-10 rounded-lg transition-all ${level.color} ${
severity === level.value
? 'ring-2 ring-offset-2 ring-secondary-900 scale-110'
: 'opacity-40 hover:opacity-70'
}`}
>
<span className="sr-only">{level.label}</span>
</button>
))}
</div>
<div className="flex justify-between mt-1 text-xs text-secondary-500">
<span>Minimal</span>
<span>Extreme</span>
</div>
</div>
)}
{/* Notes */}
{selectedType && (
<div>
<label className="block text-sm font-medium text-secondary-700 mb-2">
Notes (optional)
</label>
<textarea
value={notes}
onChange={(e) => setNotes(e.target.value)}
placeholder="Any additional details..."
rows={2}
className="w-full px-3 py-2 border border-border rounded-lg text-base focus:outline-none focus:ring-2 focus:ring-primary-200 focus:border-primary-500 resize-none"
/>
</div>
)}
{/* Submit */}
{selectedType && (
<Button onClick={handleSubmit} fullWidth loading={saving}>
Log Symptom
</Button>
)}
</div>
)
}

View File

@@ -0,0 +1,55 @@
// Default checklist items for appointment preparation
export interface PrepItem {
id: string
label: string
category: 'documents' | 'health' | 'comfort' | 'questions'
}
export const DEFAULT_PREP_ITEMS: PrepItem[] = [
// Documents
{ id: 'insurance-card', label: 'Insurance card', category: 'documents' },
{ id: 'photo-id', label: 'Photo ID', category: 'documents' },
{ id: 'medication-list', label: 'Current medication list', category: 'documents' },
{ id: 'test-results', label: 'Recent test results', category: 'documents' },
{ id: 'referral', label: 'Referral letter (if needed)', category: 'documents' },
// Health
{ id: 'take-meds', label: 'Take morning medications', category: 'health' },
{ id: 'symptoms-notes', label: 'Write down recent symptoms', category: 'health' },
{ id: 'blood-pressure', label: 'Record blood pressure', category: 'health' },
// Comfort
{ id: 'snacks', label: 'Pack snacks and water', category: 'comfort' },
{ id: 'phone-charger', label: 'Phone charger', category: 'comfort' },
{ id: 'book-entertainment', label: 'Book or entertainment', category: 'comfort' },
{ id: 'comfortable-clothes', label: 'Comfortable clothing', category: 'comfort' },
// Questions
{ id: 'questions-list', label: 'Prepare questions for doctor', category: 'questions' },
{ id: 'side-effects', label: 'Note medication side effects', category: 'questions' },
{ id: 'concerns', label: 'List any concerns', category: 'questions' },
]
export const CATEGORY_LABELS: Record<string, string> = {
documents: 'Documents to Bring',
health: 'Health Preparation',
comfort: 'Comfort Items',
questions: 'Questions & Notes',
}
export function getDefaultChecklistItems(): PrepItem[] {
return [...DEFAULT_PREP_ITEMS]
}
export function groupItemsByCategory(items: PrepItem[]): Record<string, PrepItem[]> {
return items.reduce(
(acc, item) => {
if (!acc[item.category]) {
acc[item.category] = []
}
acc[item.category].push(item)
return acc
},
{} as Record<string, PrepItem[]>
)
}

View File

@@ -0,0 +1,175 @@
import { format, addHours } from 'date-fns'
interface Appointment {
id: string
title: string
datetime: Date | string
location: string | null
notes: string | null
}
interface Medication {
id: string
name: string
scheduleType: string
scheduleData: {
type: string
times?: string[]
hours?: number
startTime?: string
time?: string
days?: number[]
}
active: boolean
}
/**
* Generate an iCalendar format string for appointments
*/
export function generateICalendar(
appointments: Appointment[],
workspaceName: string
): string {
const lines: string[] = [
'BEGIN:VCALENDAR',
'VERSION:2.0',
'PRODID:-//NextStep//Health Management//EN',
`X-WR-CALNAME:${escapeICalText(workspaceName)}`,
'CALSCALE:GREGORIAN',
'METHOD:PUBLISH',
]
for (const appt of appointments) {
const startDate = new Date(appt.datetime)
const endDate = addHours(startDate, 1) // Default 1 hour duration
lines.push('BEGIN:VEVENT')
lines.push(`UID:${appt.id}@nextstep`)
lines.push(`DTSTAMP:${formatICalDate(new Date())}`)
lines.push(`DTSTART:${formatICalDate(startDate)}`)
lines.push(`DTEND:${formatICalDate(endDate)}`)
lines.push(`SUMMARY:${escapeICalText(appt.title)}`)
if (appt.location) {
lines.push(`LOCATION:${escapeICalText(appt.location)}`)
}
if (appt.notes) {
lines.push(`DESCRIPTION:${escapeICalText(appt.notes)}`)
}
// Add reminder 1 day before
lines.push('BEGIN:VALARM')
lines.push('TRIGGER:-P1D')
lines.push('ACTION:DISPLAY')
lines.push(`DESCRIPTION:Reminder: ${escapeICalText(appt.title)}`)
lines.push('END:VALARM')
// Add reminder 2 hours before
lines.push('BEGIN:VALARM')
lines.push('TRIGGER:-PT2H')
lines.push('ACTION:DISPLAY')
lines.push(`DESCRIPTION:${escapeICalText(appt.title)} in 2 hours`)
lines.push('END:VALARM')
lines.push('END:VEVENT')
}
lines.push('END:VCALENDAR')
return lines.join('\r\n')
}
/**
* Generate medication schedule events for the current day
* This is useful for daily reminders but not recommended for calendar sync
*/
export function generateMedicationEvents(
medications: Medication[],
workspaceName: string,
targetDate: Date = new Date()
): string {
const lines: string[] = [
'BEGIN:VCALENDAR',
'VERSION:2.0',
'PRODID:-//NextStep//Medications//EN',
`X-WR-CALNAME:${escapeICalText(workspaceName)} - Medications`,
'CALSCALE:GREGORIAN',
'METHOD:PUBLISH',
]
const dateStr = format(targetDate, 'yyyy-MM-dd')
for (const med of medications) {
if (!med.active) continue
const times = getMedicationTimes(med)
for (const time of times) {
if (time === 'As needed') continue
const [hours, minutes] = time.split(':').map(Number)
const startDate = new Date(targetDate)
startDate.setHours(hours, minutes, 0, 0)
const endDate = new Date(startDate)
endDate.setMinutes(endDate.getMinutes() + 15)
lines.push('BEGIN:VEVENT')
lines.push(`UID:med-${med.id}-${dateStr}-${time}@nextstep`)
lines.push(`DTSTAMP:${formatICalDate(new Date())}`)
lines.push(`DTSTART:${formatICalDate(startDate)}`)
lines.push(`DTEND:${formatICalDate(endDate)}`)
lines.push(`SUMMARY:Take ${escapeICalText(med.name)}`)
lines.push('CATEGORIES:MEDICATION')
lines.push('BEGIN:VALARM')
lines.push('TRIGGER:PT0M')
lines.push('ACTION:DISPLAY')
lines.push(`DESCRIPTION:Time to take ${escapeICalText(med.name)}`)
lines.push('END:VALARM')
lines.push('END:VEVENT')
}
}
lines.push('END:VCALENDAR')
return lines.join('\r\n')
}
function getMedicationTimes(med: Medication): string[] {
const { scheduleType, scheduleData } = med
switch (scheduleType) {
case 'FIXED_TIMES':
return scheduleData.times || []
case 'INTERVAL':
const times: string[] = []
const startHour = parseInt(scheduleData.startTime?.split(':')[0] || '8')
const hours = scheduleData.hours || 4
for (let h = startHour; h < 24; h += hours) {
const hourStr = h.toString().padStart(2, '0')
times.push(`${hourStr}:00`)
}
return times
case 'WEEKDAYS':
return scheduleData.time ? [scheduleData.time] : []
case 'PRN':
return ['As needed']
default:
return []
}
}
function formatICalDate(date: Date): string {
// Format: YYYYMMDDTHHMMSSZ
return format(date, "yyyyMMdd'T'HHmmss'Z'")
}
function escapeICalText(text: string): string {
return text
.replace(/\\/g, '\\\\')
.replace(/;/g, '\\;')
.replace(/,/g, '\\,')
.replace(/\n/g, '\\n')
}

View File

@@ -0,0 +1,266 @@
import PDFDocument from 'pdfkit'
import { format, subDays } from 'date-fns'
interface PatientInfo {
name: string
patientName?: string | null
patientDOB?: Date | string | null
bloodType?: string | null
allergies?: string | null
medicalConditions?: string | null
primaryPhysician?: string | null
physicianPhone?: string | null
clinicPhone?: string | null
emergencyPhone?: string | null
}
interface Medication {
id: string
name: string
instructions: string | null
scheduleType: string
active: boolean
}
interface Appointment {
id: string
title: string
datetime: Date | string
location: string | null
notes: string | null
}
interface Symptom {
id: string
type: string
customName: string | null
severity: number
notes: string | null
recordedAt: Date | string
}
interface SummaryData {
patient: PatientInfo
medications: Medication[]
appointments: Appointment[]
symptoms: Symptom[]
generatedAt: Date
}
const SYMPTOM_LABELS: Record<string, string> = {
FATIGUE: 'Fatigue',
NAUSEA: 'Nausea',
PAIN: 'Pain',
APPETITE: 'Appetite Changes',
SLEEP: 'Sleep Issues',
MOOD: 'Mood Changes',
CUSTOM: 'Other',
}
const SEVERITY_LABELS = ['Minimal', 'Mild', 'Moderate', 'Severe', 'Extreme']
export function generateMedicalSummaryPDF(data: SummaryData): PDFKit.PDFDocument {
const doc = new PDFDocument({
size: 'A4',
margins: { top: 50, bottom: 50, left: 50, right: 50 },
info: {
Title: `Medical Summary - ${data.patient.patientName || data.patient.name}`,
Author: 'NextStep Health Management',
Subject: 'Medical Summary',
CreationDate: data.generatedAt,
},
})
const pageWidth = doc.page.width - 100 // Minus margins
// Header
doc
.fontSize(24)
.font('Helvetica-Bold')
.text('Medical Summary', { align: 'center' })
.moveDown(0.5)
doc
.fontSize(10)
.font('Helvetica')
.fillColor('#666666')
.text(`Generated: ${format(data.generatedAt, 'MMMM d, yyyy h:mm a')}`, { align: 'center' })
.moveDown(1.5)
.fillColor('#000000')
// Patient Information Section
addSectionHeader(doc, 'Patient Information')
const patientInfo = [
['Name', data.patient.patientName || data.patient.name || 'Not specified'],
['Date of Birth', data.patient.patientDOB ? format(new Date(data.patient.patientDOB), 'MMMM d, yyyy') : 'Not specified'],
['Blood Type', data.patient.bloodType || 'Not specified'],
['Primary Physician', data.patient.primaryPhysician || 'Not specified'],
['Physician Phone', data.patient.physicianPhone || 'Not specified'],
['Clinic Phone', data.patient.clinicPhone || 'Not specified'],
['Emergency Contact', data.patient.emergencyPhone || 'Not specified'],
]
for (const [label, value] of patientInfo) {
doc
.fontSize(10)
.font('Helvetica-Bold')
.text(`${label}: `, { continued: true })
.font('Helvetica')
.text(value)
}
doc.moveDown(0.5)
// Allergies (highlighted)
if (data.patient.allergies) {
doc
.fontSize(10)
.font('Helvetica-Bold')
.fillColor('#DC2626')
.text('ALLERGIES: ', { continued: true })
.font('Helvetica')
.text(data.patient.allergies)
.fillColor('#000000')
}
// Medical Conditions
if (data.patient.medicalConditions) {
doc
.fontSize(10)
.font('Helvetica-Bold')
.text('Medical Conditions: ', { continued: true })
.font('Helvetica')
.text(data.patient.medicalConditions)
}
doc.moveDown(1)
// Current Medications Section
addSectionHeader(doc, `Current Medications (${data.medications.filter(m => m.active).length})`)
const activeMeds = data.medications.filter((m) => m.active)
if (activeMeds.length === 0) {
doc.fontSize(10).font('Helvetica-Oblique').text('No active medications')
} else {
for (const med of activeMeds) {
doc
.fontSize(10)
.font('Helvetica-Bold')
.text(`${med.name}`, { continued: med.instructions ? true : false })
if (med.instructions) {
doc.font('Helvetica').text(` - ${med.instructions}`)
} else {
doc.text('')
}
doc.fontSize(9).fillColor('#666666').text(` Schedule: ${formatScheduleType(med.scheduleType)}`).fillColor('#000000')
}
}
doc.moveDown(1)
// Upcoming Appointments Section
const upcomingAppts = data.appointments
.filter((a) => new Date(a.datetime) >= new Date())
.sort((a, b) => new Date(a.datetime).getTime() - new Date(b.datetime).getTime())
.slice(0, 5)
addSectionHeader(doc, `Upcoming Appointments (${upcomingAppts.length})`)
if (upcomingAppts.length === 0) {
doc.fontSize(10).font('Helvetica-Oblique').text('No upcoming appointments')
} else {
for (const appt of upcomingAppts) {
const apptDate = new Date(appt.datetime)
doc
.fontSize(10)
.font('Helvetica-Bold')
.text(`${format(apptDate, 'EEE, MMM d')} at ${format(apptDate, 'h:mm a')}`)
.font('Helvetica')
.text(` ${appt.title}${appt.location ? ` - ${appt.location}` : ''}`)
}
}
doc.moveDown(1)
// Recent Symptoms Section (last 30 days)
const thirtyDaysAgo = subDays(new Date(), 30)
const recentSymptoms = data.symptoms
.filter((s) => new Date(s.recordedAt) >= thirtyDaysAgo)
.sort((a, b) => new Date(b.recordedAt).getTime() - new Date(a.recordedAt).getTime())
// Group by type
const symptomSummary = recentSymptoms.reduce(
(acc, s) => {
const key = s.type
if (!acc[key]) {
acc[key] = { count: 0, totalSeverity: 0, maxSeverity: 0 }
}
acc[key].count++
acc[key].totalSeverity += s.severity
acc[key].maxSeverity = Math.max(acc[key].maxSeverity, s.severity)
return acc
},
{} as Record<string, { count: number; totalSeverity: number; maxSeverity: number }>
)
addSectionHeader(doc, 'Symptoms (Last 30 Days)')
if (Object.keys(symptomSummary).length === 0) {
doc.fontSize(10).font('Helvetica-Oblique').text('No symptoms recorded in the last 30 days')
} else {
for (const [type, stats] of Object.entries(symptomSummary).sort((a, b) => b[1].count - a[1].count)) {
const avgSeverity = Math.round(stats.totalSeverity / stats.count)
doc
.fontSize(10)
.font('Helvetica-Bold')
.text(`${SYMPTOM_LABELS[type] || type}: `, { continued: true })
.font('Helvetica')
.text(`${stats.count} occurrence(s), Avg: ${SEVERITY_LABELS[avgSeverity - 1]}, Max: ${SEVERITY_LABELS[stats.maxSeverity - 1]}`)
}
}
doc.moveDown(1.5)
// Footer
doc
.fontSize(8)
.fillColor('#999999')
.text('This document is for informational purposes only and does not constitute medical advice.', { align: 'center' })
.text('Generated by NextStep Health Management', { align: 'center' })
return doc
}
function addSectionHeader(doc: PDFKit.PDFDocument, title: string) {
doc
.fontSize(14)
.font('Helvetica-Bold')
.fillColor('#1E3A8A')
.text(title)
.moveDown(0.3)
.strokeColor('#1E3A8A')
.lineWidth(1)
.moveTo(50, doc.y)
.lineTo(545, doc.y)
.stroke()
.moveDown(0.5)
.fillColor('#000000')
}
function formatScheduleType(type: string): string {
switch (type) {
case 'FIXED_TIMES':
return 'Fixed times daily'
case 'INTERVAL':
return 'At regular intervals'
case 'WEEKDAYS':
return 'Specific days of the week'
case 'PRN':
return 'As needed'
default:
return type
}
}

View File

@@ -0,0 +1,81 @@
import webpush from 'web-push'
// These should be set in environment variables
const VAPID_PUBLIC_KEY = process.env.NEXT_PUBLIC_VAPID_PUBLIC_KEY || ''
const VAPID_PRIVATE_KEY = process.env.VAPID_PRIVATE_KEY || ''
const VAPID_EMAIL = process.env.VAPID_EMAIL || 'mailto:admin@nextstep.local'
if (VAPID_PUBLIC_KEY && VAPID_PRIVATE_KEY) {
webpush.setVapidDetails(VAPID_EMAIL, VAPID_PUBLIC_KEY, VAPID_PRIVATE_KEY)
}
interface PushSubscriptionData {
endpoint: string
p256dh: string
auth: string
}
interface NotificationPayload {
title: string
body: string
icon?: string
badge?: string
tag?: string
data?: {
url?: string
medicationId?: string
action?: string
}
actions?: Array<{
action: string
title: string
}>
}
export async function sendPushNotification(
subscription: PushSubscriptionData,
payload: NotificationPayload
): Promise<boolean> {
if (!VAPID_PUBLIC_KEY || !VAPID_PRIVATE_KEY) {
console.warn('VAPID keys not configured, skipping push notification')
return false
}
try {
const pushSubscription = {
endpoint: subscription.endpoint,
keys: {
p256dh: subscription.p256dh,
auth: subscription.auth,
},
}
await webpush.sendNotification(
pushSubscription,
JSON.stringify(payload)
)
return true
} catch (error: any) {
if (error.statusCode === 410 || error.statusCode === 404) {
// Subscription has expired or is no longer valid
console.log('Push subscription expired:', subscription.endpoint)
return false
}
console.error('Push notification error:', error)
throw error
}
}
export function generateVAPIDKeys(): { publicKey: string; privateKey: string } {
const keys = webpush.generateVAPIDKeys()
return {
publicKey: keys.publicKey,
privateKey: keys.privateKey,
}
}
export function getPublicVAPIDKey(): string {
return VAPID_PUBLIC_KEY
}

View File

@@ -0,0 +1,188 @@
import { prisma } from '@/lib/db/prisma'
import { sendPushNotification } from './push'
interface MedicationSchedule {
medicationId: string
medicationName: string
workspaceId: string
times: string[] // HH:MM format
quietHoursStart: string | null
quietHoursEnd: string | null
}
/**
* Check if current time is within quiet hours
*/
function isQuietHours(
now: Date,
quietStart: string | null,
quietEnd: string | null
): boolean {
if (!quietStart || !quietEnd) return false
const currentMinutes = now.getHours() * 60 + now.getMinutes()
const [startH, startM] = quietStart.split(':').map(Number)
const [endH, endM] = quietEnd.split(':').map(Number)
const startMinutes = startH * 60 + startM
const endMinutes = endH * 60 + endM
// Handle overnight quiet hours (e.g., 22:00 to 07:00)
if (startMinutes > endMinutes) {
return currentMinutes >= startMinutes || currentMinutes < endMinutes
}
return currentMinutes >= startMinutes && currentMinutes < endMinutes
}
/**
* Check if a medication dose is due at the current time
*/
function isDue(scheduledTime: string, now: Date, toleranceMinutes = 5): boolean {
const [hours, minutes] = scheduledTime.split(':').map(Number)
const nowMinutes = now.getHours() * 60 + now.getMinutes()
const schedMinutes = hours * 60 + minutes
// Due if within tolerance window
return Math.abs(nowMinutes - schedMinutes) <= toleranceMinutes
}
/**
* Get all medication schedules that need notifications sent
* This should be called by a cron job or similar
*/
export async function getMedicationsDue(
now: Date = new Date()
): Promise<MedicationSchedule[]> {
const medications = await prisma.medication.findMany({
where: {
active: true,
deletedAt: null,
},
include: {
workspace: {
select: {
id: true,
quietHoursStart: true,
quietHoursEnd: true,
},
},
},
})
const due: MedicationSchedule[] = []
for (const med of medications) {
const scheduleData = med.scheduleData as any
// Skip if in quiet hours
if (
isQuietHours(
now,
med.workspace.quietHoursStart,
med.workspace.quietHoursEnd
)
) {
continue
}
let times: string[] = []
switch (med.scheduleType) {
case 'FIXED_TIMES':
times = scheduleData.times || []
break
case 'INTERVAL':
// Generate times based on interval
const startHour = parseInt(scheduleData.startTime?.split(':')[0] || '8')
const hours = scheduleData.hours || 4
for (let h = startHour; h < 24; h += hours) {
times.push(`${h.toString().padStart(2, '0')}:00`)
}
break
case 'WEEKDAYS':
// Check if today is a scheduled day
const todayDow = now.getDay()
if (scheduleData.days?.includes(todayDow)) {
times = scheduleData.time ? [scheduleData.time] : []
}
break
case 'PRN':
// PRN medications don't have scheduled times
break
}
// Check if any times are due now
const dueNow = times.some((time) => isDue(time, now))
if (dueNow) {
due.push({
medicationId: med.id,
medicationName: med.name,
workspaceId: med.workspaceId,
times,
quietHoursStart: med.workspace.quietHoursStart,
quietHoursEnd: med.workspace.quietHoursEnd,
})
}
}
return due
}
/**
* Send notifications for all due medications
*/
export async function sendDueNotifications(
now: Date = new Date()
): Promise<{ sent: number; failed: number }> {
const due = await getMedicationsDue(now)
let sent = 0
let failed = 0
for (const med of due) {
// Get all push subscriptions for this workspace
const subscriptions = await prisma.pushSubscription.findMany({
where: { workspaceId: med.workspaceId },
})
for (const sub of subscriptions) {
try {
const success = await sendPushNotification(
{
endpoint: sub.endpoint,
p256dh: sub.p256dh,
auth: sub.auth,
},
{
title: 'Medication Reminder',
body: `Time to take ${med.medicationName}`,
icon: '/icon-192.png',
badge: '/badge-72.png',
tag: `med-${med.medicationId}`,
data: {
url: '/meds',
medicationId: med.medicationId,
action: 'take_dose',
},
actions: [
{ action: 'take', title: 'Mark as Taken' },
{ action: 'snooze', title: 'Snooze 15 min' },
],
}
)
if (success) {
sent++
} else {
// Subscription expired, remove it
await prisma.pushSubscription.delete({ where: { id: sub.id } })
failed++
}
} catch (error) {
console.error('Failed to send notification:', error)
failed++
}
}
}
return { sent, failed }
}

View File

@@ -30,6 +30,11 @@ export interface LocalMedication {
syncedAt: string
createdBy?: { id: string; name: string }
updatedBy?: { id: string; name: string }
// Refill tracking fields
pillCount: number | null
pillsPerDose: number | null
refillThreshold: number | null
lastRefillDate: string | null
}
export interface LocalNote {
@@ -68,13 +73,37 @@ export interface LocalWorkspace {
largeTextMode: boolean
role?: string
updatedAt: string
// Emergency info fields
patientName: string | null
patientDOB: string | null
bloodType: string | null
allergies: string | null
medicalConditions: string | null
primaryPhysician: string | null
physicianPhone: string | null
}
export type SymptomType = 'FATIGUE' | 'NAUSEA' | 'PAIN' | 'APPETITE' | 'SLEEP' | 'MOOD' | 'CUSTOM'
export interface LocalSymptom {
id: string
workspaceId: string
type: SymptomType
customName: string | null
severity: number
notes: string | null
recordedAt: string
deletedAt: string | null
version: number
syncedAt: string
createdBy?: { id: string; name: string }
}
export interface SyncOp {
id: string
workspaceId: string
type: 'CREATE' | 'UPDATE' | 'DELETE' | 'TAKE_DOSE' | 'UNDO_DOSE' | 'MARK_ASKED'
entityType: 'APPOINTMENT' | 'MEDICATION' | 'NOTE' | 'DOSE_LOG'
type: 'CREATE' | 'UPDATE' | 'DELETE' | 'TAKE_DOSE' | 'UNDO_DOSE' | 'MARK_ASKED' | 'UNMARK_ASKED' | 'REFILL' | 'LOG_SYMPTOM' | 'DELETE_SYMPTOM'
entityType: 'APPOINTMENT' | 'MEDICATION' | 'NOTE' | 'DOSE_LOG' | 'SYMPTOM'
entityId?: string
data?: Record<string, unknown>
timestamp: number
@@ -94,12 +123,14 @@ class NextStepDB extends Dexie {
notes!: Table<LocalNote, string>
doseLogs!: Table<LocalDoseLog, string>
workspaces!: Table<LocalWorkspace, string>
symptoms!: Table<LocalSymptom, string>
outbox!: Table<SyncOp, string>
syncMeta!: Table<SyncMeta, string>
constructor() {
super('NextStepDB')
// Version 1: Original schema
this.version(1).stores({
appointments: 'id, workspaceId, datetime, deletedAt',
medications: 'id, workspaceId, active, deletedAt',
@@ -109,6 +140,18 @@ class NextStepDB extends Dexie {
outbox: 'id, workspaceId, timestamp',
syncMeta: 'id, workspaceId',
})
// Version 2: Add symptoms table, extend workspace & medication fields
this.version(2).stores({
appointments: 'id, workspaceId, datetime, deletedAt',
medications: 'id, workspaceId, active, deletedAt',
notes: 'id, workspaceId, type, deletedAt',
doseLogs: 'id, medicationId, workspaceId, takenAt',
workspaces: 'id',
symptoms: 'id, workspaceId, type, recordedAt, deletedAt',
outbox: 'id, workspaceId, timestamp',
syncMeta: 'id, workspaceId',
})
}
}

View File

@@ -25,4 +25,5 @@ export {
logDose,
undoDose,
markQuestionAsked,
unmarkQuestionAsked,
} from './manager'

View File

@@ -1,5 +1,5 @@
import { db, generateTempId, type SyncOp } from './db'
import type { LocalAppointment, LocalMedication, LocalNote, LocalDoseLog } from './db'
import type { LocalAppointment, LocalMedication, LocalNote, LocalDoseLog, LocalSymptom } from './db'
const SYNC_INTERVAL = 30000 // 30 seconds
const MAX_RETRIES = 3
@@ -70,12 +70,20 @@ export async function pullChanges(workspaceId: string): Promise<boolean> {
const data = await response.json()
// Update local database
await db.transaction('rw', [db.appointments, db.medications, db.notes, db.doseLogs, db.workspaces, db.syncMeta], async () => {
// Update workspace
await db.transaction('rw', [db.appointments, db.medications, db.notes, db.doseLogs, db.symptoms, db.workspaces, db.syncMeta], async () => {
// Update workspace (including emergency info fields)
if (data.workspace) {
await db.workspaces.put({
...data.workspace,
updatedAt: data.workspace.updatedAt || new Date().toISOString(),
// Ensure emergency fields are properly set (even if null)
patientName: data.workspace.patientName || null,
patientDOB: data.workspace.patientDOB || null,
bloodType: data.workspace.bloodType || null,
allergies: data.workspace.allergies || null,
medicalConditions: data.workspace.medicalConditions || null,
primaryPhysician: data.workspace.primaryPhysician || null,
physicianPhone: data.workspace.physicianPhone || null,
})
}
@@ -132,6 +140,18 @@ export async function pullChanges(workspaceId: string): Promise<boolean> {
}
}
// Update symptoms
for (const symptom of data.symptoms || []) {
const existing = await db.symptoms.get(symptom.id)
if (!existing || new Date(symptom.syncedAt) > new Date(existing.syncedAt)) {
await db.symptoms.put({
...symptom,
recordedAt: symptom.recordedAt,
syncedAt: symptom.syncedAt,
})
}
}
// Update sync cursor
await db.syncMeta.put({
id: workspaceId,
@@ -179,7 +199,7 @@ export async function pushChanges(workspaceId: string): Promise<boolean> {
const data = await response.json()
// Process results and remove successful ops from outbox
await db.transaction('rw', [db.outbox, db.appointments, db.notes], async () => {
await db.transaction('rw', [db.outbox, db.appointments, db.notes, db.symptoms], async () => {
for (const result of data.results) {
if (result.success) {
// Find the op
@@ -198,6 +218,12 @@ export async function pushChanges(workspaceId: string): Promise<boolean> {
await db.notes.delete(op.entityId)
await db.notes.put({ ...local, id: result.entityId })
}
} else if (op.entityType === 'SYMPTOM') {
const local = await db.symptoms.get(op.entityId)
if (local) {
await db.symptoms.delete(op.entityId)
await db.symptoms.put({ ...local, id: result.entityId })
}
}
}
@@ -408,6 +434,14 @@ export async function logDose(
}
await db.doseLogs.add(doseLog)
// Decrement pill count if tracking is enabled
const localMed = await db.medications.get(medicationId)
if (localMed && localMed.pillCount !== null && localMed.pillsPerDose !== null) {
const newCount = Math.max(0, localMed.pillCount - (localMed.pillsPerDose || 1))
await db.medications.update(medicationId, { pillCount: newCount })
}
await addToOutbox({
workspaceId,
type: 'TAKE_DOSE',
@@ -424,6 +458,13 @@ export async function undoDose(doseLog: LocalDoseLog): Promise<void> {
const now = new Date().toISOString()
await db.doseLogs.update(doseLog.id, { undoneAt: now })
// Restore pill count if tracking is enabled
const localMed = await db.medications.get(doseLog.medicationId)
if (localMed && localMed.pillCount !== null && localMed.pillsPerDose !== null) {
const newCount = localMed.pillCount + (localMed.pillsPerDose || 1)
await db.medications.update(doseLog.medicationId, { pillCount: newCount })
}
await addToOutbox({
workspaceId: doseLog.workspaceId,
type: 'UNDO_DOSE',
@@ -445,3 +486,75 @@ export async function markQuestionAsked(note: LocalNote): Promise<void> {
timestamp: Date.now(),
})
}
export async function unmarkQuestionAsked(note: LocalNote): Promise<void> {
await db.notes.update(note.id, { askedAt: null })
await addToOutbox({
workspaceId: note.workspaceId,
type: 'UNMARK_ASKED',
entityType: 'NOTE',
entityId: note.id,
timestamp: Date.now(),
})
}
export async function logSymptom(
workspaceId: string,
data: {
type: LocalSymptom['type']
customName?: string
severity: number
notes?: string
}
): Promise<LocalSymptom> {
const id = generateTempId()
const now = new Date().toISOString()
const symptom: LocalSymptom = {
id,
workspaceId,
type: data.type,
customName: data.customName || null,
severity: data.severity,
notes: data.notes || null,
recordedAt: now,
deletedAt: null,
version: 1,
syncedAt: now,
}
await db.symptoms.add(symptom)
await addToOutbox({
workspaceId,
type: 'LOG_SYMPTOM',
entityType: 'SYMPTOM',
entityId: id,
data: {
type: data.type,
customName: data.customName,
severity: data.severity,
notes: data.notes,
recordedAt: now,
},
timestamp: Date.now(),
})
return symptom
}
export async function deleteSymptom(symptom: LocalSymptom): Promise<void> {
const now = new Date().toISOString()
await db.symptoms.update(symptom.id, {
deletedAt: now,
version: symptom.version + 1,
syncedAt: now,
})
await addToOutbox({
workspaceId: symptom.workspaceId,
type: 'DELETE_SYMPTOM',
entityType: 'SYMPTOM',
entityId: symptom.id,
timestamp: Date.now(),
})
}

View File

@@ -24,6 +24,26 @@ export const updateWorkspaceSchema = z.object({
quietHoursStart: z.string().regex(/^([01]\d|2[0-3]):([0-5]\d)$/).nullable().optional(),
quietHoursEnd: z.string().regex(/^([01]\d|2[0-3]):([0-5]\d)$/).nullable().optional(),
largeTextMode: z.boolean().optional(),
// Emergency info fields
patientName: z.string().max(100).nullable().optional(),
patientDOB: z.string().datetime().nullable().optional(),
bloodType: z.string().max(10).nullable().optional(),
allergies: z.string().max(1000).nullable().optional(),
medicalConditions: z.string().max(2000).nullable().optional(),
primaryPhysician: z.string().max(100).nullable().optional(),
physicianPhone: z.string().max(50).nullable().optional(),
})
export const emergencyInfoSchema = z.object({
patientName: z.string().max(100).nullable().optional(),
patientDOB: z.string().datetime().nullable().optional(),
bloodType: z.string().max(10).nullable().optional(),
allergies: z.string().max(1000).nullable().optional(),
medicalConditions: z.string().max(2000).nullable().optional(),
primaryPhysician: z.string().max(100).nullable().optional(),
physicianPhone: z.string().max(50).nullable().optional(),
clinicPhone: z.string().max(50).nullable().optional(),
emergencyPhone: z.string().max(50).nullable().optional(),
})
export const inviteSchema = z.object({
@@ -108,8 +128,8 @@ export const syncQuerySchema = z.object({
export const syncOpSchema = z.object({
id: z.string(),
type: z.enum(['CREATE', 'UPDATE', 'DELETE', 'TAKE_DOSE', 'UNDO_DOSE', 'MARK_ASKED']),
entityType: z.enum(['APPOINTMENT', 'MEDICATION', 'NOTE', 'DOSE_LOG']),
type: z.enum(['CREATE', 'UPDATE', 'DELETE', 'TAKE_DOSE', 'UNDO_DOSE', 'MARK_ASKED', 'UNMARK_ASKED', 'REFILL', 'LOG_SYMPTOM', 'DELETE_SYMPTOM']),
entityType: z.enum(['APPOINTMENT', 'MEDICATION', 'NOTE', 'DOSE_LOG', 'SYMPTOM']),
entityId: z.string().optional(),
data: z.record(z.unknown()).optional(),
timestamp: z.number(),
@@ -120,15 +140,45 @@ export const syncOpsSchema = z.object({
ops: z.array(syncOpSchema),
})
// Symptom schemas
export const symptomTypeEnum = z.enum(['FATIGUE', 'NAUSEA', 'PAIN', 'APPETITE', 'SLEEP', 'MOOD', 'CUSTOM'])
export const symptomSchema = z.object({
type: symptomTypeEnum,
customName: z.string().max(100).nullable().optional(),
severity: z.number().min(1).max(5),
notes: z.string().max(2000).nullable().optional(),
recordedAt: z.string().datetime().optional(),
})
// Medication refill schema
export const refillSchema = z.object({
pillCount: z.number().min(0).optional(),
pillsPerDose: z.number().min(1).default(1).optional(),
refillThreshold: z.number().min(0).default(7).optional(),
lastRefillDate: z.string().datetime().nullable().optional(),
})
export const medicationWithRefillSchema = medicationSchema.extend({
pillCount: z.number().min(0).nullable().optional(),
pillsPerDose: z.number().min(1).nullable().optional(),
refillThreshold: z.number().min(0).nullable().optional(),
lastRefillDate: z.string().datetime().nullable().optional(),
})
// Type exports
export type LoginInput = z.infer<typeof loginSchema>
export type RegisterInput = z.infer<typeof registerSchema>
export type CreateWorkspaceInput = z.infer<typeof createWorkspaceSchema>
export type UpdateWorkspaceInput = z.infer<typeof updateWorkspaceSchema>
export type EmergencyInfoInput = z.infer<typeof emergencyInfoSchema>
export type InviteInput = z.infer<typeof inviteSchema>
export type AppointmentInput = z.infer<typeof appointmentSchema>
export type MedicationInput = z.infer<typeof medicationSchema>
export type MedicationWithRefillInput = z.infer<typeof medicationWithRefillSchema>
export type ScheduleDataInput = z.infer<typeof scheduleDataSchema>
export type DoseLogInput = z.infer<typeof doseLogSchema>
export type NoteInput = z.infer<typeof noteSchema>
export type SymptomInput = z.infer<typeof symptomSchema>
export type SymptomType = z.infer<typeof symptomTypeEnum>
export type SyncOp = z.infer<typeof syncOpSchema>

215
src/styles/print.css Normal file
View File

@@ -0,0 +1,215 @@
/* Print-specific styles */
@media print {
/* Hide navigation and non-essential elements */
nav,
.no-print,
button:not(.print-button),
.bottom-nav {
display: none !important;
}
/* Reset background colors for printing */
body,
.bg-surface,
.bg-background {
background: white !important;
color: black !important;
}
/* Ensure text is readable */
* {
color-adjust: exact !important;
-webkit-print-color-adjust: exact !important;
print-color-adjust: exact !important;
}
/* Page settings */
@page {
margin: 0.75in;
size: letter;
}
/* Prevent page breaks inside elements */
.print-no-break {
page-break-inside: avoid;
break-inside: avoid;
}
/* Force page breaks */
.print-page-break {
page-break-before: always;
break-before: page;
}
/* Print-specific sizes */
.print-title {
font-size: 24pt !important;
font-weight: bold !important;
margin-bottom: 12pt !important;
}
.print-subtitle {
font-size: 14pt !important;
font-weight: 600 !important;
margin-bottom: 8pt !important;
}
.print-text {
font-size: 12pt !important;
line-height: 1.4 !important;
}
.print-text-large {
font-size: 14pt !important;
line-height: 1.5 !important;
}
/* Large checkboxes for daily meds */
.print-checkbox {
width: 24pt !important;
height: 24pt !important;
border: 2pt solid black !important;
display: inline-block !important;
margin-right: 8pt !important;
vertical-align: middle !important;
}
/* Table styles */
.print-table {
width: 100% !important;
border-collapse: collapse !important;
margin-bottom: 12pt !important;
}
.print-table th,
.print-table td {
border: 1pt solid #333 !important;
padding: 6pt 8pt !important;
text-align: left !important;
font-size: 11pt !important;
}
.print-table th {
background-color: #f0f0f0 !important;
font-weight: bold !important;
}
/* Section spacing */
.print-section {
margin-bottom: 24pt !important;
}
/* Date header */
.print-date {
font-size: 12pt !important;
color: #666 !important;
margin-bottom: 16pt !important;
}
/* Emergency info box */
.print-emergency-box {
border: 2pt solid #dc2626 !important;
padding: 12pt !important;
margin-bottom: 16pt !important;
}
/* Medication list item */
.print-med-item {
display: flex !important;
align-items: center !important;
padding: 8pt 0 !important;
border-bottom: 1pt solid #ccc !important;
}
.print-med-name {
font-weight: bold !important;
font-size: 14pt !important;
flex: 1 !important;
}
.print-med-time {
font-size: 12pt !important;
min-width: 80pt !important;
text-align: right !important;
}
/* Notes section */
.print-notes {
border: 1pt solid #ccc !important;
padding: 8pt !important;
min-height: 48pt !important;
background: #fafafa !important;
}
/* Question list */
.print-question {
padding: 8pt 0 !important;
border-bottom: 1pt solid #eee !important;
}
.print-question-checkbox {
width: 16pt !important;
height: 16pt !important;
border: 1.5pt solid black !important;
display: inline-block !important;
margin-right: 8pt !important;
vertical-align: middle !important;
}
/* Footer */
.print-footer {
position: fixed !important;
bottom: 0 !important;
left: 0 !important;
right: 0 !important;
text-align: center !important;
font-size: 9pt !important;
color: #666 !important;
padding: 8pt !important;
}
/* Hide interactive elements */
input,
select,
textarea {
border: 1pt solid #ccc !important;
background: white !important;
}
/* Link styling */
a {
text-decoration: none !important;
color: black !important;
}
a[href]::after {
content: none !important;
}
}
/* Screen styles for print preview */
@media screen {
.print-preview {
max-width: 8.5in;
margin: 0 auto;
padding: 0.75in;
background: white;
box-shadow: 0 0 10px rgba(0, 0, 0, 0.1);
min-height: 11in;
}
.print-only {
display: none;
}
}
@media print {
.screen-only {
display: none !important;
}
.print-only {
display: block !important;
}
}