102 Commits

Author SHA1 Message Date
tcardoza ec67fb89ca embed improved
CI / build (push) Has been skipped
CI / deploy (push) Successful in 1m8s
2026-03-26 11:59:50 -05:00
tcardoza 6900a7cee1 SDK updates
CI / build (push) Has been skipped
CI / deploy (push) Successful in 1m2s
2026-03-26 11:39:52 -05:00
tcardoza e0e8f4e7e8 routing added
CI / build (push) Has been skipped
CI / deploy (push) Successful in 1m12s
2026-03-26 11:06:40 -05:00
tcardoza 613a2c72f5 improved chat ui
CI / build (push) Has been skipped
CI / deploy (push) Successful in 1m34s
2026-03-26 10:46:26 -05:00
tcardoza e45bc9e2b6 tips working and optimized
CI / build (push) Has been skipped
CI / deploy (push) Successful in 1m19s
2026-03-25 19:37:30 -05:00
tcardoza 7653c6bf0e fix
CI / build (push) Has been skipped
CI / deploy (push) Successful in 1m24s
2026-03-25 18:48:37 -05:00
tcardoza f30b160fa5 tip fix
CI / build (push) Has been skipped
CI / deploy (push) Successful in 1m19s
2026-03-25 18:41:57 -05:00
tcardoza 82c5f07f7a improved chat ui
CI / build (push) Has been skipped
CI / deploy (push) Successful in 1m22s
2026-03-25 18:38:04 -05:00
tcardoza 47257aa007 Improved Help and help chatbot
CI / build (push) Has been skipped
CI / deploy (push) Successful in 1m36s
2026-03-25 18:07:51 -05:00
tcardoza 20cfeaccd3 Help Guide updates 2026-03-25 16:36:31 -05:00
tcardoza 5fc1318c1d src/store/index.ts breakup (no drama) 2026-03-25 12:52:47 -05:00
tcardoza 446d599ed2 auto-cleanup implemented
CI / build (push) Has been skipped
CI / deploy (push) Successful in 1m12s
2026-03-25 12:25:37 -05:00
tcardoza 3876047dbe search filter by tag natively
CI / build (push) Has been skipped
CI / deploy (push) Successful in 1m1s
2026-03-25 11:02:23 -05:00
tcardoza 59eb5be34a search filter fix
CI / build (push) Has been skipped
CI / deploy (push) Successful in 1m16s
2026-03-25 10:57:12 -05:00
tcardoza 9400a04fe2 after the fact handoff and clear search
CI / build (push) Has been skipped
CI / deploy (push) Successful in 1m9s
2026-03-25 10:40:35 -05:00
tcardoza e4f274034e collapse realtime subsciption
CI / build (push) Has been skipped
CI / deploy (push) Successful in 1m7s
2026-03-25 10:23:25 -05:00
tcardoza 74537ff359 extra log fix
CI / build (push) Has been skipped
CI / deploy (push) Successful in 1m0s
2026-03-25 09:46:18 -05:00
tcardoza e61fa1d0cf subscription recovery
CI / build (push) Has been skipped
CI / deploy (push) Successful in 1m0s
2026-03-25 09:09:16 -05:00
tcardoza 52786dcd44 user-context migration
CI / build (push) Has been skipped
CI / deploy (push) Successful in 1m21s
2026-03-24 18:46:36 -05:00
tcardoza d33a838aec multi-checklist ready for use
CI / build (push) Has been skipped
CI / deploy (push) Successful in 1m5s
2026-03-24 16:18:05 -05:00
tcardoza 0f4d374658 handoff and collab modes support per task for @user @bucket and #note
CI / build (push) Has been skipped
CI / deploy (push) Successful in 1m15s
2026-03-23 17:59:44 -05:00
tcardoza 609163f933 notes better 2026-03-23 12:56:56 -05:00
tcardoza 23e3ddd667 note-linked tasks are now efficient
CI / build (push) Has been skipped
CI / deploy (push) Successful in 1m7s
2026-03-23 12:36:46 -05:00
tcardoza 6abb1835f0 Tasks linked to notes are public but inefficient 2026-03-23 12:19:02 -05:00
tcardoza 8e4eae92f8 share mode toggle added. Partial note task linkage change (stable) 2026-03-23 12:03:15 -05:00
tcardoza de8cea7d2e Fixed status circle checkmark for completed tasks
CI / build (push) Has been skipped
CI / deploy (push) Successful in 1m1s
2026-03-23 10:05:56 -05:00
tcardoza 55d161af25 Collapsed today and updates indicator ui fixes
CI / build (push) Has been skipped
CI / deploy (push) Successful in 1m8s
2026-03-23 09:19:41 -05:00
tcardoza e104d74e24 notes tags hiding
CI / build (push) Has been skipped
CI / deploy (push) Successful in 1m20s
2026-03-20 18:24:33 -05:00
tcardoza 1eca9f147d searchbar improvements 2026-03-20 18:12:35 -05:00
tcardoza 5a3cf8cde3 fixed starring tasks
CI / build (push) Has been skipped
CI / deploy (push) Successful in 1m3s
2026-03-20 16:36:06 -05:00
tcardoza 8077942e24 made collapse only availible when shared 2026-03-20 16:33:32 -05:00
tcardoza a0c67e745d added UPDATED indicators and task collapsing
CI / build (push) Has been skipped
CI / deploy (push) Successful in 1m8s
2026-03-20 16:27:38 -05:00
tcardoza 5b2fefbca4 load in optimization and error correction
CI / build (push) Has been skipped
CI / deploy (push) Successful in 1m23s
2026-03-20 13:08:15 -05:00
tcardoza da7d2f4d5d visibility fix for user's own tags
CI / build (push) Has been skipped
CI / deploy (push) Successful in 1m26s
2026-03-19 19:59:32 -05:00
tcardoza ef6c43f0c2 pre-migration prepped
CI / build (push) Has been skipped
CI / deploy (push) Successful in 1m30s
2026-03-19 19:27:29 -05:00
tcardoza 2e2b31262b Migration notifications fixed 2026-03-19 18:49:48 -05:00
tcardoza 4eb7650916 Notes linking confirmed 2026-03-19 18:48:10 -05:00
tcardoza b246f2da78 true bucket sharing handling 2026-03-19 18:39:35 -05:00
tcardoza b72980ad89 Task fully working except collaborative to handoff switching handling 2026-03-19 18:15:38 -05:00
tcardoza 9438cffd58 Task sharing wording improved 2026-03-19 18:03:32 -05:00
tcardoza 7f781edd89 task sharing restructure seems working 2026-03-19 17:41:56 -05:00
tcardoza a4d3da984b improved sharing not finished 2026-03-19 17:30:04 -05:00
tcardoza 727cfe0083 more stable but unfinished sharing refactor 2026-03-19 16:20:37 -05:00
tcardoza 37004e4b4a unstable sharing refactor 2026-03-19 16:15:47 -05:00
tcardoza 4259649fec task bucket fixes and task creation clarity
CI / build (push) Has been skipped
CI / deploy (push) Successful in 1m45s
2026-03-19 10:14:23 -05:00
tcardoza 4414476816 Added Note Sharing Links
CI / build (push) Has been skipped
CI / deploy (push) Successful in 1m16s
2026-03-18 18:22:30 -05:00
tcardoza 620071e19e combined upload buttons
CI / build (push) Has been skipped
CI / deploy (push) Successful in 2m1s
2026-03-17 18:52:06 -05:00
tcardoza 8f8bd8dcac Combined upload slash commands 2026-03-17 18:47:53 -05:00
tcardoza 94fce5652a image resize 2026-03-17 18:36:10 -05:00
tcardoza 5f7f3995f8 text formatting finished 2026-03-17 18:20:00 -05:00
tcardoza 35d968e2d8 Basic text formatting updates 2026-03-17 17:53:52 -05:00
tcardoza a096047a61 image/video/file delete button on mobile 2026-03-17 17:28:20 -05:00
tcardoza 8e85d7c835 ci fix
CI / build (push) Has been skipped
CI / deploy (push) Successful in 1m13s
2026-03-16 17:17:34 -05:00
tcardoza 10585f6f0e Loading Indicator
CI / build (push) Has been skipped
CI / deploy (push) Successful in 1m26s
2026-03-13 17:53:38 -05:00
tcardoza 163003d857 tables great
CI / build (push) Has been skipped
CI / deploy (push) Successful in 1m1s
2026-03-13 16:34:31 -05:00
tcardoza abbdb993d5 Tabe controls stable 2026-03-13 16:11:57 -05:00
tcardoza cb8c81c299 updated table controls 2026-03-13 14:49:01 -05:00
tcardoza 3bfb4cd40a Table control improvements 2026-03-13 14:27:29 -05:00
tcardoza ee2706df99 Cache.put() fix
CI / build (push) Has been skipped
CI / deploy (push) Successful in 1m7s
2026-03-13 13:06:06 -05:00
tcardoza 3ebc43ef72 Versioning and Updating improvements
CI / build (push) Has been skipped
CI / deploy (push) Successful in 1m3s
2026-03-13 12:21:25 -05:00
tcardoza b89e6ca2c3 repeating tasks UI improvements 2026-03-13 12:00:04 -05:00
tcardoza 3cbbc65c5c note vs tab deletion clarity 2026-03-13 11:50:15 -05:00
tcardoza b276345149 removed lnking tasks to note tabs 2026-03-13 11:35:57 -05:00
tcardoza ff9bf7afa4 filter and search improvements
CI / build (push) Has been skipped
CI / deploy (push) Successful in 1m8s
2026-03-13 11:14:49 -05:00
tcardoza 8ad5499f06 filtering by status added 2026-03-13 11:05:33 -05:00
tcardoza bd8c610584 removed migration code now that migration is complete 2026-03-13 10:52:40 -05:00
tcardoza 11daf918b6 migration of job_id tags in notes and updated mine filter 2026-03-13 10:50:22 -05:00
tcardoza 1d3d9b512a build error fixes
CI / build (push) Has been skipped
CI / deploy (push) Successful in 1m16s
2026-03-12 18:58:44 -05:00
tcardoza bb0fe6c749 Note and task ui fixes
CI / build (push) Has been skipped
CI / deploy (push) Failing after 50s
2026-03-12 18:49:01 -05:00
tcardoza 55047b5d90 Copy and paste as well as dragging files added
CI / build (push) Has been skipped
CI / deploy (push) Failing after 34s
2026-03-12 18:09:45 -05:00
tcardoza cd5fb71899 file attachment fix 2026-03-12 17:42:59 -05:00
tcardoza df94685c96 cleanup (broken files) 2026-03-12 17:23:28 -05:00
tcardoza f53030294b file upload 2026-03-12 17:05:07 -05:00
tcardoza cf188896be HEIC support 2026-03-12 16:43:27 -05:00
tcardoza 1e5f80739c working video 2026-03-12 16:33:57 -05:00
tcardoza 75723ed133 Upload image button 2026-03-12 15:51:37 -05:00
tcardoza 092e139f6e Working file loading!
CI / build (push) Has been skipped
CI / deploy (push) Successful in 1m22s
2026-03-09 09:57:05 -05:00
tcardoza 1c8e8b1d54 basic job file function 2026-03-09 09:44:12 -05:00
tcardoza 8dfd0c8274 PArtial Note-File Feature 2026-03-06 18:50:21 -06:00
tcardoza bc9060e00d quick add fixes for tasks. tagging current bucket context. preserving state when clicking off
CI / build (push) Has been skipped
CI / deploy (push) Successful in 58s
2026-03-06 16:12:05 -06:00
tcardoza 0a1909804f Tag filtering fix on mobile 2026-03-06 15:59:34 -06:00
tcardoza 3c23e2109a Note tabs and favoriting ready for production
CI / build (push) Has been skipped
CI / deploy (push) Successful in 56s
2026-03-06 14:18:18 -06:00
tcardoza 09557d49a4 favoriting 2026-03-06 13:53:26 -06:00
tcardoza 10d76eb3c1 Improved filtering and layout 2026-03-06 12:29:56 -06:00
tcardoza e406adf9c5 Note Tabs Reordering and Stable 2026-03-06 10:05:04 -06:00
tcardoza 8c307db57a Updated note tabs - NOT READY FOR PRODUCTION 2026-03-05 19:14:30 -06:00
tcardoza f7b46b2017 Partial Note Tabs Setup - NOT FOR PRODUCTION 2026-03-05 19:06:45 -06:00
tcardoza 9705a12660 Merge branch 'main' of https://gitea.ccllc.pro/Cardoza_Construction/TasGrid
CI / build (push) Has been skipped
CI / deploy (push) Successful in 1m3s
2026-03-05 16:23:40 -06:00
tcardoza cf22eda1e9 version test 2026-03-05 16:23:24 -06:00
tcardoza 75dde1b0d5 Added task and note crteation SDKs 2026-03-05 16:19:27 -06:00
eewing 46d8dd9a4c Refactor CI workflow to remove development deployment steps and restrict main branch triggers
CI / build (push) Has been skipped
CI / deploy (push) Successful in 57s
2026-03-05 08:46:15 -06:00
tcardoza 6a963a32fd Added custom PWA reloading - 10 minutes of idle or when moved to the background or when loading initially or manually with the new button in settings
CI / build (push) Has been skipped
CI / deploy_prod (push) Successful in 1m19s
CI / deploy_dev (push) Has been skipped
2026-03-04 19:29:18 -06:00
tcardoza 4ebd0b5f61 Realtime updates when viewing workspaces
CI / build (push) Has been skipped
CI / deploy_prod (push) Successful in 1m14s
CI / deploy_dev (push) Has been skipped
2026-03-04 19:08:50 -06:00
tcardoza 6a76f695fe Created share tag migration function
CI / build (push) Has been skipped
CI / deploy_prod (push) Successful in 1m16s
CI / deploy_dev (push) Has been skipped
2026-03-04 18:47:18 -06:00
tcardoza 0e85603699 added attachment cleanup in tasks
CI / build (push) Has been skipped
CI / deploy_prod (push) Successful in 1m25s
CI / deploy_dev (push) Has been skipped
2026-03-04 18:25:37 -06:00
tcardoza 4c096f18d7 Images adding in notes and cleanup of orphaned images from notes 2026-03-04 18:20:27 -06:00
tcardoza 9cbe7dcb12 Notes layout improvements and soft delete 2026-03-04 17:44:24 -06:00
tcardoza 391f52af23 Merge branch 'main' of https://gitea.ccllc.pro/Cardoza_Construction/TasGrid
CI / build (push) Has been skipped
CI / deploy_prod (push) Successful in 1m0s
CI / deploy_dev (push) Has been skipped
2026-03-04 14:07:40 -06:00
tcardoza ac119c288b Notes view layout fixes 2026-03-04 14:07:33 -06:00
tcardoza 0bc9c74797 Stable task transitions and animations 2026-03-04 13:25:38 -06:00
tcardoza 50ccbbc564 View layout fixes from task sorting animations 2026-03-04 12:42:38 -06:00
eewing a5ecee084e Enhanced Gitea CI pipeline by adding a new deployment job for the Dev branch and updating the existing production deployment configuration.
CI / build (push) Has been skipped
CI / deploy_prod (push) Successful in 56s
CI / deploy_dev (push) Has been skipped
2026-03-04 10:49:04 -06:00
83 changed files with 26508 additions and 4309 deletions
+1
View File
@@ -0,0 +1 @@
VITE_TASGRID_DATA_MODE=dev
+4
View File
@@ -0,0 +1,4 @@
VITE_TASGRID_ENABLE_PROD_MIGRATION=false
VITE_TASGRID_MIGRATION_ADMIN_EMAILS=tcardoza@cardoza.construction
VITE_FIREWORKS_API_KEY=fw_2QZReEeWXyJsuYn3JWffTh
VITE_FIREWORKS_HELP_MODEL=accounts/fireworks/models/gpt-oss-20b
+9 -2
View File
@@ -64,6 +64,13 @@ jobs:
ls -la
test -d dist || (echo "Missing dist/ (Vite output). Check Build step." && exit 1)
- name: Prepare deploy artifact
run: |
rm -rf deploy
mkdir -p deploy
cp -r dist package.json deploy/
(test -f bun.lock && cp bun.lock deploy/) || (test -f bun.lockb && cp bun.lockb deploy/) || true
- name: Setup SSH known hosts
run: |
mkdir -p ~/.ssh
@@ -75,7 +82,7 @@ jobs:
run: |
sshpass -p "$SSH_PASSWORD" rsync -avz --delete \
-e "ssh -o StrictHostKeyChecking=accept-new -p ${{ secrets.DEPLOY_SSH_PORT || '22' }}" \
dist/ \
deploy/ \
${{ secrets.DEPLOY_USER }}@${{ secrets.DEPLOY_HOST || env.DEPLOY_HOST }}:${{ secrets.DEPLOY_PATH || env.DEPLOY_PATH }}/
- name: Restart app on server
@@ -84,4 +91,4 @@ jobs:
run: |
sshpass -p "$SSH_PASSWORD" ssh -o StrictHostKeyChecking=accept-new -p ${{ secrets.DEPLOY_SSH_PORT || '22' }} \
${{ secrets.DEPLOY_USER }}@${{ secrets.DEPLOY_HOST || env.DEPLOY_HOST }} \
'bash -lc "cd ${{ secrets.DEPLOY_PATH || env.DEPLOY_PATH }} && export PORT=${{ env.APP_PORT }} && (pm2 restart tasgrid 2>/dev/null || pm2 start \"bun run serve\" --name tasgrid --update-env)"'
'bash -lc "cd ${{ secrets.DEPLOY_PATH || env.DEPLOY_PATH }} && bun install --frozen-lockfile && export PORT=${{ env.APP_PORT }} && (pm2 restart tasgrid 2>/dev/null || pm2 start \"bun run serve\" --name tasgrid --update-env)"'
-4
View File
@@ -1,7 +1,3 @@
> **Branch notice:** This README belongs to the `Dev` branch and describes the **dev environment**. For live/production information, use the `main` branch.
---
## 🚀 Getting Started
This project uses **Bun** as the package manager and runtime. Ensure you have Bun and PM2 installed globally before proceeding.
+6
View File
@@ -8,6 +8,7 @@
"@formkit/auto-animate": "^0.9.0",
"@kobalte/core": "^0.13.11",
"@tailwindcss/vite": "^4.1.18",
"@thisbeyond/solid-dnd": "^0.7.5",
"@tiptap/core": "^3.18.0",
"@tiptap/extension-bubble-menu": "^3.20.0",
"@tiptap/extension-dropcursor": "^3.20.0",
@@ -31,6 +32,7 @@
"autoprefixer": "^10.4.23",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"heic2any": "^0.0.4",
"lucide-solid": "^0.563.0",
"pm2": "^6.0.14",
"pocketbase": "^0.26.8",
@@ -460,6 +462,8 @@
"@tailwindcss/vite": ["@tailwindcss/vite@4.1.18", "", { "dependencies": { "@tailwindcss/node": "4.1.18", "@tailwindcss/oxide": "4.1.18", "tailwindcss": "4.1.18" }, "peerDependencies": { "vite": "^5.2.0 || ^6 || ^7" } }, "sha512-jVA+/UpKL1vRLg6Hkao5jldawNmRo7mQYrZtNHMIVpLfLhDml5nMRUo/8MwoX2vNXvnaXNNMedrMfMugAVX1nA=="],
"@thisbeyond/solid-dnd": ["@thisbeyond/solid-dnd@0.7.5", "", { "peerDependencies": { "solid-js": "^1.5" } }, "sha512-DfI5ff+yYGpK9M21LhYwIPlbP2msKxN2ARwuu6GF8tT1GgNVDTI8VCQvH4TJFoVApP9d44izmAcTh/iTCH2UUw=="],
"@tiptap/core": ["@tiptap/core@3.18.0", "", { "peerDependencies": { "@tiptap/pm": "^3.18.0" } }, "sha512-Gczd4GbK1DNgy/QUPElMVozoa0GW9mW8E31VIi7Q4a9PHHz8PcrxPmuWwtJ2q0PF8MWpOSLuBXoQTWaXZRPRnQ=="],
"@tiptap/extension-blockquote": ["@tiptap/extension-blockquote@3.18.0", "", { "peerDependencies": { "@tiptap/core": "^3.18.0" } }, "sha512-1HjEoM5vZDfFnq2OodNpW13s56a9pbl7jolUv1V9FrE3X5s7n0HCfDzIVpT7z1HgTdPtlN5oSt5uVyBwuwSUfA=="],
@@ -822,6 +826,8 @@
"hasown": ["hasown@2.0.2", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ=="],
"heic2any": ["heic2any@0.0.4", "", {}, "sha512-3lLnZiDELfabVH87htnRolZ2iehX9zwpRyGNz22GKXIu0fznlblf0/ftppXKNqS26dqFSeqfIBhAmAj/uSp0cA=="],
"html-entities": ["html-entities@2.3.3", "", {}, "sha512-DV5Ln36z34NNTDgnz0EWGBLZENelNAtkiFA4kyNOG2tDI6Mz1uSWiq1wAKdyjnJwyDiDO7Fa2SO1CTxPXL8VxA=="],
"http-proxy-agent": ["http-proxy-agent@7.0.2", "", { "dependencies": { "agent-base": "^7.1.0", "debug": "^4.3.4" } }, "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig=="],
+15
View File
@@ -0,0 +1,15 @@
import PocketBase from 'pocketbase';
const pb = new PocketBase('https://pocketbase.ccllc.pro');
async function check() {
try {
await pb.admins.authWithPassword("timothy@ccllc.pro", 'pass');
const res = await pb.collection('Job_Info_Prod').getList(1, 1);
console.log("Got item:", res.items[0]);
} catch (e) {
console.error("Auth / Fetch Error", e);
}
}
check();
+868 -77
View File
@@ -1,114 +1,905 @@
# TaskGrid User Guide
# TasGrid Reference Guide
## Welcome & Access
This document is a thorough reference for how TasGrid works today. It is intended to support future help tooling, deeper troubleshooting, and advanced feature explanations. It is not meant to match the shorter in-app Help screen one-to-one.
- **Website**: [tasgrid.ccllc.pro](https://tasgrid.ccllc.pro)
- **Login**: Use your **Cardoza Construction email** (the same one you use for Outlook). Your password should match your email password as well.
## Overview
---
- This guide focuses on end-user and team-lead features inside TasGrid.
- It explains both normal workflows and important behavior that may confuse users if it is not documented.
- It excludes SDK, API, and integration-developer documentation.
- It also excludes developer-only migration tools, admin repair flows, and hidden legacy UI that is not active in normal use.
## Basic Features (Getting Started)
## Core Mental Model
### Creating Your First Task
- TasGrid is built around tasks, notes, and contexts.
- Tasks are the primary work items.
- Notes are rich project workspaces that can hold content, tabs, linked tasks, and public share links.
- Contexts decide where work lives and who can see or act on it.
- Contexts are usually represented through special tags:
- `@person` for a user's personal context
- `@bucket` for a shared bucket context
- `#note` for note-linked workspaces
- These special tags are not just labels. TasGrid converts them into structured relationships that affect visibility, ownership, and workflow behavior.
- For tasks, the important persisted relationship fields are `labelTags`, `shareRefs`, and `noteRefs`.
- The user-facing tag list is effectively a derived view built back from those structured relationships, plus the user's hidden favorite tag when applicable.
- This means editing tags is not a purely decorative string change. TasGrid reinterprets the tag list and normalizes it back into structured refs.
1. Click the **large plus (+) sign** in the bottom right corner (or type control \+ k).
2. **Title**: Enter a clear name for what needs to be done.
3. **Priority**: Choose from 1 (trivial) to 10 (absolutely necessary).
4. **Urgency & Due Date**: These are linked\!
- Pick an **Urgency level** (10 is today, 1 is 6 months from now), and the due date will be calculated for you.
- Alternatively, pick a **Due Date** using the calendar, and the urgency will adjust automatically.
5. **Size**: This defaults to **5**. Adjust it if the task is very small (15 mins) or very large (multiple days).
6. **Description**: Add notes or details. Use the **forward slash (/)** to reveal formatting options like bold text, checklists, and even adding images.
7. Click **Create Task**.
## Navigation and Access
### Viewing Your Tasks
### Main App Modes
TaskGrid sorts your tasks so you don't have to. The primary views are:
- TasGrid has two main modes: `Tasks` and `Notes`.
- The top-center mode switcher toggles between them.
- On desktop, switching modes changes the main sidebar content.
- On mobile, Notes opens into a note-list-first flow and hides the bottom navigation while you are inside the note workspace.
- **✨ Focus (Default)**: Your most important tasks, ranked by a combination of Urgency and Priority.
- **📊 Strategy Matrix**: A 4-quadrant grid mapping Urgency vs. Priority.
- **Do First**: Urgent & Important.
- **Schedule**: Important but not urgent yet.
- **Delegate**: Urgent but not important.
- **Eliminate**: Neither urgent nor important.
- **❄️ Snowball**: Shows your **smallest** tasks first to help you build momentum.
- **⛏️ Dig In**: Shows your **largest** tasks first for when you have time to focus.
- **🕒 Urgency**: Sorts tasks purely by when they are due.
- **⭐ Priority**: Sorts tasks purely by how important they are.
### Desktop Navigation
### Other Info
- The left sidebar provides task views:
- `Focus`
- `Urgency`
- `Priority`
- `Matrix`
- `Snowball`
- `Dig In`
- `Progress`
- The context switcher sits near the top of the sidebar above the task views.
- The lower utility area includes:
- `Ask Questions`
- `Help`
- `Settings`
- The sidebar supports locked, collapsed, and peek states.
- **Search & Filters**: Use the bar at the top right to find specific tasks or filter by priority, urgency, or tasks edited today.
- **Themes**: Switch between **Light Mode** and **Dark Mode** in the Settings.
- **Trash**: Deleted tasks are kept for **7 days** before being permanently removed. You can recover them in Settings.
### Mobile Navigation
### Mobile Installation
- The mobile bottom nav exposes:
- `Focus`
- grouped `Value` views for `Priority` and `Urgency`
- `Matrix` inside the `Value` group
- grouped `Flow` views for `Snowball`, `Dig In`, and `Progress`
- `Ask`
- `Settings`
- Mobile does not have a dedicated Help tab in bottom navigation.
- Help is reached through `Settings > Resources > Help`.
- The mobile context switcher is a floating control near the top-left area of the screen.
TaskGrid works like a dedicated app on your phone:
### Ask Questions Access
- **Android**: Tap the three dots in Chrome, select "Add to Home Screen," then "Install."
- **iPhone**: Tap the Share icon in Safari and select "Add to Home Screen."
- `Ask Questions` is a built-in question panel for looking up how TasGrid works.
- On desktop it opens inline inside the sidebar so it can be used alongside the rest of the app.
- On mobile it is a dedicated bottom-nav destination labeled `Ask`.
- The current implementation is guide-grounded rather than general-purpose: answers are intended to come from the TasGrid reference material rather than from outside product knowledge.
---
### Help Access
## Advanced Features (Teams & Automation)
- Desktop Help is available from the left sidebar.
- Mobile Help is available through `Settings > Resources > Help`.
- Some higher-confusion areas include direct Help links that open the relevant help section.
- Help supports deep-link anchors in the form `#help/<section-id>`.
### Sharing & Collaboration
## Task Creation and Editing
Collaboration in TaskGrid is flexible and transparent:
### Task Creation
1. **Manual Sharing**: Open a task and use the **Share** menu to add a specific person.
- You can also **unshare** **and split** tasks here. Unshare just removes it from their list and split gives each of you a unique copy of the task.
2. **Tag-Based User Sharing**: Add a tag that matches a coworker's name (e.g., "John Smith").
- This automatically shares the task with them.
- Both of you can edit the task together (Collaborative mode).
3. **Collaboration vs. Handoff**:
- **Collaborate**: Both users can see and edit.
- **Handoff**: The task is transferred to the other person, and they become the new owner.
- The main task creation flow is Quick Entry.
- Users can open Quick Entry with:
- the floating `+` button
- `Ctrl+K` or `Cmd+K`
- Quick Entry requires:
- `Title`
- `Priority`
- `Urgency`
- Optional fields include:
- `Size`
- `Due Date`
- tags
- rich description/content
- template selection
### Team Spaces (Buckets)
### Default Task Values and Interpretation
"Buckets" are shared areas for teams (like "Shop" or "Maintenance").
- Size defaults to `3`, which represents about one hour of work.
- If a task is created with urgency but no due date, TasGrid derives the due date from the urgency setting.
- Due date and urgency are linked concepts in the task model.
- Creating a task inside an active context often causes TasGrid to inherit that context automatically.
- Common examples:
- creating a task while switched into a bucket adds that bucket context
- creating a task while in a supervised user context applies that personal context
- creating a task from a note applies the note's `#note` tag
- **Move to Bucket**: Simply add a tag that matches the bucket name (e.g., tagging a task with "Shop").
- **Visibility**: By default, bucketed tasks are hidden from your main view to keep your list clean. You can view them by selecting the bucket in the sidebar but they will only appear if you have pinned them.
- **Pinning**: In order to work in a bucket, you must "Pin" it in Settings so it always shows up in your sidebar.
### Quick Entry Shorthand
### Oversight (Share All)
- Quick Entry supports shorthand text commands:
- `/pN` to set priority
- `/uN` to set urgency
- `/t tag` to add a tag
- `/t tag:5` to add a tag with a score/value
- This lets users create and classify tasks quickly without manually touching every field.
Managers and team leads can ask their teams to create a "share all" sharing rule in setting. This will add that user's bucket as an option in the sidebar just like shared buckets. Clicking a user's name in your sidebar allows you to view their entire task list, making it easy to track progress across the team.
### Task Detail Editing
### Automatic Tasks (Recurrence)
- Opening a task shows the main task detail editing sheet.
- Users can edit:
- title
- status
- priority
- urgency
- size
- due date
- tags
- favorite state
- rich description/content
- Task detail auto-saves changes.
- Some saves are debounced rather than written instantly.
- Tag edits are interpreted into `labelTags`, `shareRefs`, and `noteRefs`, then written back as a normalized task state.
- If a user inserts an `@person` mention into task content, TasGrid also adds that matching `@person` context tag so the task participates in sharing correctly.
For recurring work, you can set a task to repeat **Daily**, **Weekly** (on any specified days of the week), or **Monthly** (on a specified day of the month).
### Task Card Actions
- When you mark it complete, TaskGrid automatically "un-completes" it for you when the next cycle begins.
- You'll see status labels like "Complete until Monday" to let you know exactly when it will reappear.
- Task cards support quick actions without opening the full editor.
- Common actions include:
- open the task
- change status
- complete the task
- duplicate the task
- toggle favorite
- Double-clicking a task can complete it quickly.
- Some collaboratively shared tasks show update indicators.
- There is also a `Collapse until updated` behavior for reducing clutter on ongoing shared work.
### Bulk Import (Filling a Bucket)
## Task Lifecycle and Scheduling
Need to add a long list of tasks at once?
### Task Status and Progress
1. Go to **Settings** \> **Import Tasks**.
2. Paste your list (one task per line).
3. Click "Parse and Configure."
4. Use the **Bulk Update** tool at the top to set the Priority, Urgency, and Tag (e.g., "Shop") for all tasks at once.
5. Update tasks that need individual urgency and priority. (Usually all tasks need individual urgency and priority because if everything is set the same TasGrid can't sort them)
6. Click **Import Tasks**.
- Task status is numeric from `0` to `10`.
- `0` is the starting state.
- `10` is complete.
- Progress-oriented views sort tasks using status plus other ranking signals.
- Recurring tasks that are completed may show a progress ring while waiting to reopen.
---
### Due Date, Urgency, Priority, and Size
## Power User Tips
- `Priority` and `Urgency` both influence ranking, but they are not the same thing.
- `Urgency` is closely tied to due date behavior.
- `Size` affects how some views rank or group work.
- The app calculates combined scores from these factors for several list views.
- Users often expect these values to be fully independent, but due date and urgency especially are coupled.
- **Quick Entry Shorthands**: Type `/p5` (priority), `/u8` (urgency), or `/t Wood` (tags) directly into the title box when creating a task to set properties instantly without touching your mouse!
- **Task Templates**: If you have recurring projects with the same notes and tags, create a **Template** in Settings. You can apply it with a single click in the creation menu.
- **Matrix Color Pro-tip**: The dots on your Strategy Matrix change color based on **Size**—**Blue** dots are small wins (easy tasks), and **Red** dots are major projects (time-consuming tasks).
- **Smart Tagging**: If you are currently viewing a Bucket (e.g., "Shop"), when you click the Plus button to add a task, TaskGrid will automatically add the "Shop" tag for you.
### Favorites
---
- Favoriting a task is personal to the current user.
- Internally, favorites are handled with hidden per-user tagging behavior.
- A task being starred by one person does not automatically mean it is starred for everyone else.
### Need help?
### Task Deletion, Trash, and Auto-Maintenance
If the app feels out of sync, a quick [**refresh**](#) usually fixes it. Most updates happen automatically in real-time\!
- Deleting a task is a soft delete, not an immediate permanent delete.
- Soft-deleted tasks go into Trash for 7 days.
- The app usually offers an immediate undo prompt after deletion.
- Permanent recovery and permanent delete are available in `Settings > Trash`.
- Trash is automatically purged on app load when items have exceeded the 7-day retention window.
If you have any other questions contact Timothy Cardoza or IT.
TasGrid also performs maintenance-related auto-trash behavior:
- completed tasks older than 90 days are automatically moved to trash
- tasks with no active personal, shared, or note-linked context can be auto-trashed during initialization
These behaviors are important because a user may feel a task has disappeared when it has actually moved to Trash automatically.
### Recurrence
- Tasks support recurring schedules.
- Supported recurrence types are:
- daily
- weekly
- monthly
- Weekly recurrence requires selected days.
- Monthly recurrence depends on a valid day-of-month configuration.
- Completed recurring tasks do not stay permanently complete. TasGrid reopens them when their next recurrence window arrives.
- Reopen checks happen on startup and continue periodically while the app is running.
### Task Templates
- Task templates let users store reusable task setups.
- Template fields include:
- template name
- title placeholder
- priority
- urgency
- tags
- rich description/content
- Notably, task templates do not include:
- size
- explicit due date
- When a template is applied, due date is recalculated from urgency rather than copied as a fixed old date.
### Task Import
- Settings includes an `Import Tasks` tool for pasting in task lists.
- The import flow is:
1. paste a task list
2. review parsed tasks
3. apply bulk defaults
4. import
- The parser strips common list formatting such as:
- bullets
- checkboxes
- numbered-list prefixes
- Each non-empty line becomes one task.
- Users can bulk-apply:
- priority
- urgency
- size
- tags
- Users can still edit or remove individual rows before import.
- If no tags are provided, imported tasks default to `work`.
- Due date is derived from urgency on import just like normal task creation.
## Task Views
### Focus
- Focus is the main ranking-oriented list view.
- It sorts by a combined score using the task's importance and urgency characteristics.
- This is often the default working view for deciding what deserves attention next.
### Urgency
- Urgency emphasizes time pressure.
- It sorts by a continuous urgency score and then falls back to other ranking signals.
- It is helpful when due dates and near-term deadlines matter more than task size or stage.
### Priority
- Priority is intended as an importance-oriented lens.
- In the current implementation, it is not meaningfully different from Focus in the way tasks are sorted.
- This is an important documentation caveat because users may expect a completely different ranking.
### Progress
- Progress groups and ranks work based on completion state first, then other score factors.
- It is useful when users want to move partially completed work forward rather than start new tasks.
### Snowball
- Snowball sorts by smaller tasks first.
- It is the "quick wins" view.
- This is useful when users want momentum, cleanup, or rapid visible progress.
### Dig In
- Dig In sorts by larger tasks first.
- It is the "deep work" view.
- This is useful when users want to tackle heavier tasks rather than clear short items.
### Matrix
- Matrix shows tasks on an urgency-versus-priority grid instead of a simple ranked list.
- Matrix points are clickable and expose more detail on hover or selection.
- Matrix communicates multiple dimensions:
- placement reflects urgency and priority
- dot size reflects task size
- dot color communicates ranking/meaning in the current matrix logic
- Tooltips surface key values such as urgency, size, and priority.
- Matrix includes time-scale controls:
- `1`
- `7`
- `30`
- `60`
- `90` days
- Changing the time scale changes how the urgency side of the visualization is interpreted.
## Search, Filters, and Saved Filters
### Task Search and Filters
- Task filtering supports:
- text query
- included tags
- priority ranges
- urgency ranges
- edited today
- mine
- starred
- Included tags use OR-style logic, not strict AND-only matching.
- Hidden system tags may not appear in the filter picker unless searched explicitly.
- `Mine` is based on personal-context access rules rather than just simplistic creator matching.
### Saved Task Filters
- Users can save the current task filter setup as a reusable filter template.
- Saved filters are managed from the filter/search area rather than from Settings.
- These are useful for repeat operational views like:
- follow-up work
- specific team contexts
- daily supervision checks
### Note Filters
- Notes also have filter templates, separate from task filter templates.
- They are managed in Notes mode through the note filter/search UI, not through Settings.
### Load All
- `Load All` is used when older completed or historical items are not currently loaded into the active store.
- This is especially relevant for search and filters because a task cannot be found if it has not been loaded into memory yet.
- Users may interpret missing search results as deletion when the actual issue is partial history loading.
## Contexts and Sharing
### Context Types
- TasGrid has three important context types:
- personal user contexts through `@person`
- shared bucket contexts through `@bucket`
- note contexts through `#note`
- These are system-backed contexts, not ordinary decorative tags.
### My Bucket and Personal Contexts
- Every user effectively has a personal working context.
- This is often represented in UI as `My Bucket`.
- Personal context affects what a user considers their own active work.
- Tasks can move into or out of a user's personal working space through context policies.
### Context Switcher
- The context switcher is how users move between available working spaces.
- It can show:
- `My Bucket`
- pinned shared buckets
- supervised personal contexts shared with the current user
- Desktop places this near the top of the sidebar above the task views.
- Mobile shows it through the floating control.
## Buckets and Supervision
### Shared Buckets
- Shared buckets are team contexts created in Settings.
- A bucket includes:
- required name
- color
- optional description
- New buckets default to `handoff` policy.
- Bucket rows support:
- inline rename
- policy toggle
- pin/unpin
- archive
- Archiving a bucket sets a deleted timestamp on the bucket context.
- There is no confirmed user-facing bucket restore flow at this time.
### Pinned Buckets
- Pinning a bucket adds it to the context switcher.
- Pinning is stored in user preferences.
- When a bucket is pinned, TasGrid loads tasks already tagged with that bucket into the local task store.
- This matters because unpinned bucket work may not appear in the same way during task search or cross-linking flows.
### Sharing Model
- TasGrid's main sharing workflow is tag-driven.
- Users share tasks by applying:
- an `@person` tag
- an `@bucket` tag
- a `#note` tag
- This is the primary sharing model.
- TasGrid parses those tags into structured `shareRefs` and `noteRefs` rather than relying on the display strings alone.
- Documentation should not describe a separate manual share dialog as the main expected workflow.
### `#note` Sharing Semantics
- Applying a `#note` tag does more than add a label:
- it creates a `noteRef`
- it also creates a note-kind `shareRef`
- A `#note` tag links the task into that note workspace. It does **not** mean "all teammates can now see this task."
- Visibility from `#note` linkage is governed by note accessibility:
- if the note is private, only the note owner can gain visibility from that note link alone
- if the note is public, other authenticated users can gain visibility from the note link because the note is accessible to them
- For explicit person-to-person or team sharing, use `@person` and `@bucket`.
- If the goal is "make sure another specific teammate can see this task," `#note` alone is not the clearest or safest mechanism unless that teammate already has access to the note.
- In implementation terms, note-linked visibility is checked through accessible `noteRefs`, while user and bucket sharing are checked through personal-context and bucket-context rules.
### Collaborative vs Handoff
- TasGrid uses two important context policies:
- `Collaborative`
- `Handoff`
- Collaborative means a task stays visible in both places as shared work.
- Handoff means responsibility shifts between contexts in a more directional way.
- Users can define personal user-tag policies in Settings.
- Buckets also carry a policy.
- These policies strongly affect whether a task feels shared, moved, or reassigned.
### Supervision
- Supervision lets one user enter another user's personal context.
- This is broader than sharing a single task.
- Personal Context Supervision settings let a user grant specific people permission to switch into that full personal context.
- `Personal Contexts Shared With You` lists supervised contexts the current user can access.
- Supervised contexts appear in the context switcher alongside buckets.
### Hidden System Tags
- Some system-backed context tags are intentionally hidden in normal UI.
- Hidden does not mean inactive.
- A task may still belong to a person, bucket, or note even when the user does not obviously see the matching context tag in normal task chips.
- In particular, the current user's own personal-context tag can be suppressed from visible task chips even though the underlying ref is still active.
- This is one of the biggest sources of user confusion and should be treated as normal behavior rather than as data loss.
### Context Effects on Task Creation and Visibility
- Creating work from the current context usually applies that context automatically.
- Context affects what tasks a user can see in a given working view.
- If work appears to vanish after sharing, switching context, or linking to notes, the cause is often context visibility rather than deletion.
- Users should check:
- current context
- active filters
- trash
- whether the task belongs to an unpinned bucket
## Notes Basics
### Notes Mode
- Notes is a full working mode, not just an attached notes panel.
- Switching into Notes triggers note metadata loading.
- Desktop replaces the normal task sidebar with a notes sidebar.
- Mobile shows the notes list first, then opens the editor as a layered workspace.
### Notes Sidebar
- The main notes sidebar lists root notes only.
- Child tabs do not appear in the main sidebar list.
- The sidebar supports:
- title-and-tag search
- saved filters
- starred filter
- edited today filter
- owned by me filter
- Prism job note filters where applicable
- Notes are sorted by title descending, not by most recently updated.
### Note Loading Model
- Notes load in two stages:
- metadata first
- full content when the note is opened
- This allows the notes list to appear before every note body has loaded.
- If a note is visible in metadata but restricted by visibility rules, its content may fail to hydrate for the current user.
### Creating Notes
- Creating a note from the sidebar produces a root note with:
- title `New Note`
- empty content
- empty tags
- `isPrivate: false`
- a generated normalized key with a timestamp suffix
### Editing Notes
- Root note titles are editable inline in the note header.
- Selected child tabs can also be renamed inline.
- Note content auto-saves on a 500ms debounce.
- Leaving a note flushes pending saves immediately.
### Deleting and Restoring Notes
- Deleting a note is a soft delete.
- Soft delete:
- sets `deletedAt`
- removes current selection
- shows an Undo toast
- Restore is available from:
- the immediate Undo action
- `Settings > Trash`
- Permanent delete is exposed from `Settings > Trash`.
- Permanent deletion also removes note references from tasks by clearing:
- matching `#note` tag
- matching `noteRefs`
- matching note-based `shareRefs`
## Note Sharing and Linked Work
### Public, Private, and Share Links
- Notes can be public or private.
- Metadata loading exposes:
- public notes
- private notes owned by the current user
- Privacy, share-link, and delete controls are owner-only.
- Favorites can still be toggled from the note header.
- Share Link copies an embed URL like `#/embed/notes?noteId=...`.
- If a note is private when the share link is copied, TasGrid prompts the owner to make it public first.
- Public notes can be viewed through the embed route without normal auth when the note ID is present.
- Public notes are editable in-app by non-owners, but owner-only controls remain restricted.
- A public note can also make note-linked tasks visible inside the authenticated app because those tasks pass the note-access visibility check.
- A public embed link should be treated as note-content access, not as a guaranteed full unauthenticated task workspace. Linked-task loading still depends on the in-app task-loading path.
### Child Tabs
- Root notes can have child tabs.
- Child tabs are separate note records tagged with `child-of-{rootId}`.
- The root note appears in the tab UI as `General`.
- Desktop shows tabs as draggable side ribbons.
- Mobile shows tabs in a dropdown.
- Tab order is stored through `taborder:{index}` tags.
- Dragging tabs updates those tags.
- Creating a tab makes a note with:
- title `New Tab`
- empty content
- empty linked tasks array
- `child-of-{rootId}` tag
- privacy copied from the root note at creation time
Important privacy caveat:
- child tabs inherit privacy when they are created
- later privacy changes on the root note do not automatically cascade back down to existing child tabs
### Linked Tasks
- Notes can act as project workspaces through a linked tasks panel.
- Linked tasks belong to the note workspace.
- If the user is inside a child tab, linked tasks resolve through the parent/root note workspace.
- A task counts as linked if either is true:
- it has a matching `noteRef`
- it has the matching `#note` tag
- In practice, TasGrid treats the root note as the linking owner for that workspace, even when the user is currently inside a child tab.
- Linked tasks are shown with incomplete items first, then ranked by combined score.
Linked task actions include:
- create a new task from the note
- search and link an existing task
- open a task from the linked task panel
Linking constraints:
- existing-task search returns nothing until the user types
- search matches task title only
- results are capped at 10
- deleted tasks are excluded
- tasks from only unpinned buckets are excluded
Current behavior caveats:
- child tabs cannot directly unlink tasks
- unlink behavior is not perfectly consistent across every tag-linked path
### Note Rename Propagation
- Renaming a note updates both:
- the note title
- the normalized note key
- TasGrid also renames the corresponding `#note` tag definition after the note rename.
- This helps linked tasks continue to point to the correct note workspace after the name changes.
## Rich Editor Basics
### Shared Editor Model
- Tasks, notes, and task templates all use the same rich editor system.
- The editor supports both simple writing and structured embedded content.
### Basic Content Types
- Supported content includes:
- paragraphs
- headings 1 to 3
- bullet lists
- checklists
- multi-checklists
- code blocks
- blockquotes
- horizontal rules
- links
- highlight
- underline
- text alignment
- tables
- images
- videos
- file attachments
- job file embeds
- `@user` mentions
- `#note` mentions
### Toolbar and Slash Commands
- The editor provides a persistent text formatting toolbar.
- When the cursor is inside a table, a table toolbar appears.
- The placeholder text is `Type '/' for commands or start writing...`.
- Typing `/` opens the slash command menu.
Available slash commands include:
- Text
- Heading 1
- Heading 2
- Heading 3
- Bullet List
- Checklist
- Multi Checklist
- Code Block
- Blockquote
- Upload
- Job File
- Divider
- Table
- Delete Table
- Slash command filtering is prefix-based against titles and aliases.
- The note header also provides shortcut buttons for `Commands` and `Upload`.
### Mentions
#### `@user` Mentions
- `@user` suggestions come from user share contexts.
- The current user's own context is excluded from suggestion results.
- Matching is based on the start of the display name.
- In task detail, inserting an `@user` mention also applies the corresponding `@user` tag for sharing behavior.
#### `#note` Mentions
- `#note` suggestions include root notes only, not child tabs.
- Matching uses note key or title substring behavior.
- If note content is already loaded, the suggestion list can show a short preview.
- Clicking a `#note` mention opens a preview popover with:
- note title
- short content preview
- `Open Note`
Current caveat:
- in task detail, `Open Note` is wired to actual note navigation
- in the notes editor, the preview appears but `Open Note` is not fully wired through the Notes view
## Rich Editor Uploads and Embeds
### Uploads and Embedded Media
- Upload flows are available through:
- the Upload button
- the slash menu
- paste
- drag and drop
- Uploading requires an active task or active note.
Upload result behavior:
- images become inline image blocks
- videos become inline video blocks
- other files become attachment blocks
- Most image uploads are converted before upload.
- Explicit Upload command flows insert a blank paragraph afterward so the user can continue typing.
- Removing an embedded file or media block deletes it from the editor HTML.
- When leaving a note, TasGrid checks for orphaned uploads and removes no-longer-referenced files from storage.
### Images
- Images render as inline block nodes.
- They support:
- resize handle
- delete overlay
- fullscreen preview
- Fullscreen preview supports:
- zoom by click
- wheel zoom
- panning while zoomed
### Videos
- Videos render inline with:
- native playback controls
- resize handle
- delete overlay
### File Attachments
- PDFs and `.txt` files can render inline in an embedded viewer.
- Other file types show as attachment cards with filename and open-in-new-tab behavior.
- If the first content block in a note is a PDF attachment or a job file viewer, the note uses a top-viewer layout with a large embedded viewer area.
### Tables
- Tables are inserted as 3x3 by default with a header row.
- They are resizable and selectable as editor nodes.
- The table toolbar supports:
- insert row above
- insert row below
- delete row
- insert column left
- insert column right
- delete column
- merge cells
- split cell
- toggle header row
- delete table
### Job File Embeds
- The editor supports Prism job file embeds through the `/job file` command.
- If a job file block has not been configured yet, it shows a picker UI.
- The picker tries to infer a job from the active note or one of its ancestor notes using a `job_id:` tag.
- If no job is detected, the user can search `Job_Info_Prod` by:
- job number
- job name
- Exposed file categories include:
- `plans`
- `EST_and_CO`
- `Addendums`
- `files`
- Selecting a file stores:
- `jobId`
- `collectionId`
- `filename`
- The final embedded viewer uses the PocketBase file URL inside an iframe-style viewer.
## Settings
### Account Header
- Settings shows the signed-in email at the top.
- It also includes:
- theme toggle
- `Sign Out`
### Theme
- The theme system supports:
- `light`
- `dark`
- `system`
- Theme preference is stored in local storage under `tasgrid-theme`.
- `system` is resolved from the browser or device `prefers-color-scheme`.
- The current Settings UI exposes only a visible light/dark toggle.
- Documentation should not promise a visible full theme selector with a dedicated system option unless that control is added later.
### Sharing Settings
- `Tag Sharing` explains the main context types:
- `@person`
- `@bucket`
- `#note`
- `User Tag Policies` lets users decide whether each personal user context behaves as:
- `Collaborative`
- `Handoff`
- This section reflects the current tag/context-based sharing model.
### Supervision Settings
- `Personal Context Supervision` lets a user grant specific people access to switch into that user's personal context.
- `Personal Contexts Shared With You` shows the contexts the current user can supervise.
- These settings are separate from normal task-by-task sharing because they apply to the whole context.
### Shared Buckets Manager
- `Shared Buckets` is the bucket management area.
- Users can:
- create buckets
- rename buckets
- pin buckets
- toggle bucket policy
- archive buckets
- Bucket creation requires a name and supports color plus optional description.
### Tag Manager
- `Tag List` is where users manage normal custom tag definitions.
- Users can:
- rename a custom tag
- recolor it
- rescore it from `0` to `10`
- delete it
- Renaming a tag propagates to tasks that use it.
- Deleting a tag removes it from tasks that use it.
- `System Tags (Users, Buckets & Notes)` is a separate expandable area.
- System tags are primarily reference/configuration artifacts tied to contexts and notes rather than standard freeform tags.
### Templates Manager
- `Task Templates` in Settings is the template management surface.
- Users can create, rename, edit, and delete templates there.
- Saved task and note filters are not managed in this section.
### Trash
- Trash contains separate lists for deleted tasks and deleted notes.
- Entries show the number of days remaining in the 7-day retention period.
- Each entry supports:
- recover
- permanent delete
- The Trash view is the main long-term recovery surface for deleted content.
### Update App
- `Update App` checks the service worker for updates.
- If an update is available, TasGrid reloads into the new version.
- If no update is found quickly, the app shows `No update found`.
Background update behavior:
- checks roughly hourly
- checks again when the window regains focus or visibility
- if an update is ready, it can apply automatically when the app is hidden or after about 10 minutes of idle time
### Reset PWA
- `Reset PWA` unregisters service workers, clears browser caches, and reloads the app.
- This is a repair tool for stale or broken cached app behavior.
- It should be used when the app seems out of sync, stale, or stuck, not as a normal daily action.
## Progressive Web App Behavior
### Installation
- TasGrid is configured as a PWA with standalone display behavior.
- Installation is browser-driven.
- There is no confirmed custom in-app install prompt flow.
- Users should install using the browser's own `Install App` or `Add to Home Screen` controls.
### Offline and Cached App Behavior
- Like many PWAs, TasGrid can continue showing older cached assets until a service worker update is applied.
- `Update App` and `Reset PWA` exist to help recover from stale-client issues.
## Common Confusions and Caveats
- Sharing is primarily tag-driven, not driven by a separate share dialog.
- `Ask Questions` is help-oriented and guide-grounded; it should not be treated as an authoritative source for behaviors that are not documented here.
- Sharing a task to `#note` does not automatically mean every teammate can see it. It means the task is linked to that note workspace, and visibility then depends on whether the note itself is accessible.
- Context tags may be hidden while still actively controlling task behavior.
- Creating work in the wrong active context can place it somewhere unexpected.
- Focus and Priority are not substantially different in current sorting behavior.
- Search is only as complete as the currently loaded data set.
- `Load All` may be necessary to find older completed work.
- Notes search does not search full note body text. It searches title and tags.
- The notes list does not include child tabs.
- Linking existing tasks from a note uses task title search only and returns at most 10 results.
- Task-link suggestions exclude tasks that belong only to unpinned buckets.
- Child note tabs inherit privacy only when created, not continuously afterward.
- Private note share links require the note to be made public before other users can view them.
- Trash is auto-purged after 7 days on app load.
- Completed tasks older than 90 days can be auto-moved to trash.
- Some tasks with no valid active context can also be auto-trashed.
- Bucket archive exists, but a confirmed user-facing bucket restore flow was not identified.
- In the notes editor, note mention previews exist, but `Open Note` from that preview is not fully wired through the same way it is in task detail.
## Reference Notes
### Explicit Exclusions
- SDK and API documentation
- integration-developer workflows
- dev-data migration tools
- production migration tools
- admin-only repair or maintenance flows
- inactive legacy settings UI hidden behind disabled conditions
### Summary for Future Help-Agent Use
- The biggest mental models to preserve are:
- contexts are first-class behavior, not decorative tags
- notes are workspaces, not just text documents
- due date and urgency are linked
- hidden tags can still matter
- several views are ranking lenses over the same work pool
- retrieval issues are often context, filtering, or loading issues rather than actual data loss
## Troubleshooting Checklist
When a user thinks something is missing or not working, check these in order:
1. Confirm whether they are in `Tasks` or `Notes`.
2. Confirm the active context in the context switcher.
3. Check active filters and whether a saved filter is still applied.
4. Use `Load All` if the issue may involve older items.
5. Check whether the item is in Trash.
6. Check whether the task belongs to an unpinned bucket or hidden system context.
7. For stale UI behavior, try `Update App`.
8. For persistent cache issues, use `Reset PWA`.
@@ -0,0 +1,200 @@
[
{
"id": "agjvjaleo6zfqe3",
"key": "user-oz8sxsyg8hznqa6",
"displayName": "IT",
"kind": "user",
"policy": "collaborative",
"targetUserId": "oz8sxsyg8hznqa6",
"deletedAt": null
},
{
"id": "u3julc5juv1c5pq",
"key": "user-2lmwfhtdwuexmiv",
"displayName": "Ezekiel Ewing",
"kind": "user",
"policy": "collaborative",
"targetUserId": "2lmwfhtdwuexmiv",
"deletedAt": null
},
{
"id": "73z3dp9b6alhbl4",
"key": "user-1hbif9dbqng86h9",
"displayName": "Alfonso Ewing",
"kind": "user",
"policy": "collaborative",
"targetUserId": "1hbif9dbqng86h9",
"deletedAt": null
},
{
"id": "p65sesgbxah4chu",
"key": "user-fdve3i2gop4huym",
"displayName": "Heidi Mahan",
"kind": "user",
"policy": "collaborative",
"targetUserId": "fdve3i2gop4huym",
"deletedAt": null
},
{
"id": "vo9x5s7x9jjq6zw",
"key": "user-ee4c6xmjrhypv4f",
"displayName": "Anita Pence",
"kind": "user",
"policy": "collaborative",
"targetUserId": null,
"deletedAt": null
},
{
"id": "ez800j2vp4tp0tk",
"key": "user-h9uihmnkwngdu8y",
"displayName": "Beth Cardoza",
"kind": "user",
"policy": "collaborative",
"targetUserId": "h9uihmnkwngdu8y",
"deletedAt": null
},
{
"id": "u9517tx1x3hxrs5",
"key": "user-e9vck6v3pa951wn",
"displayName": "Rick Cardoza",
"kind": "user",
"policy": "collaborative",
"targetUserId": "e9vck6v3pa951wn",
"deletedAt": null
},
{
"id": "2nlrw1wumhtbc98",
"key": "user-c1uzqhpacn65qt1",
"displayName": "Steve Brewer",
"kind": "user",
"policy": "collaborative",
"targetUserId": null,
"deletedAt": null
},
{
"id": "2ttnn1vcdnniqci",
"key": "user-rrjoxd2wewmlrwy",
"displayName": "David Cardoza",
"kind": "user",
"policy": "collaborative",
"targetUserId": "rrjoxd2wewmlrwy",
"deletedAt": null
},
{
"id": "a3nd0f8tpipjc3e",
"key": "user-sivbh2pwzyew5lb",
"displayName": "Timothy Cardoza",
"kind": "user",
"policy": "collaborative",
"targetUserId": "sivbh2pwzyew5lb",
"deletedAt": null
},
{
"id": "ucj2hrr4htezrzo",
"key": "user-gk3u91dx0r79fo2",
"displayName": "Employee",
"kind": "user",
"policy": "handoff",
"targetUserId": "gk3u91dx0r79fo2",
"deletedAt": null
},
{
"id": "lazcnjz2tsdoi0t",
"key": "user-mchpsyvh0wrcxv6",
"displayName": "Cortney Abrams",
"kind": "user",
"policy": "collaborative",
"targetUserId": "mchpsyvh0wrcxv6",
"deletedAt": null
},
{
"id": "p66mi0tscgw3sri",
"key": "user-ewdxrba73q68kqj",
"displayName": "Joel Snyder",
"kind": "user",
"policy": "collaborative",
"targetUserId": "ewdxrba73q68kqj",
"deletedAt": null
},
{
"id": "asgpvdgthuoyhgx",
"key": "user-s1gwhknxaww38i3",
"displayName": "David Rader",
"kind": "user",
"policy": "collaborative",
"targetUserId": "s1gwhknxaww38i3",
"deletedAt": null
},
{
"id": "5jdlsa7ebt1jd3i",
"key": "user-nc23vrshrg0qtdi",
"displayName": "Ben Poland",
"kind": "user",
"policy": "collaborative",
"targetUserId": "nc23vrshrg0qtdi",
"deletedAt": null
},
{
"id": "r27bfvcvig2n90m",
"key": "user-naztc2sg4b4fgfc",
"displayName": "Mark Huber",
"kind": "user",
"policy": "collaborative",
"targetUserId": "naztc2sg4b4fgfc",
"deletedAt": null
},
{
"id": "il3piz7xfklzw3o",
"key": "testing-bucket",
"displayName": "Testing Bucket",
"kind": "bucket",
"policy": "handoff",
"targetUserId": null,
"deletedAt": null
},
{
"id": "w9zx3zqsi5r8zht",
"key": "vehicle-maintenance",
"displayName": "Vehicle Maintenance",
"kind": "bucket",
"policy": "handoff",
"targetUserId": null,
"deletedAt": null
},
{
"id": "o6n72jlh3s6c42e",
"key": "shop",
"displayName": "Shop",
"kind": "bucket",
"policy": "handoff",
"targetUserId": null,
"deletedAt": null
},
{
"id": "cu4b1epkpwf15zs",
"key": "equipment-maintenance",
"displayName": "Equipment Maintenance",
"kind": "bucket",
"policy": "handoff",
"targetUserId": null,
"deletedAt": null
},
{
"id": "zdqmiqyfjx3z68d",
"key": "development",
"displayName": "Development",
"kind": "bucket",
"policy": "handoff",
"targetUserId": null,
"deletedAt": null
},
{
"id": "pjwskg0eo91n5y6",
"key": "user-i73wk8r7f2evbew",
"displayName": "Eddie",
"kind": "user",
"policy": "collaborative",
"targetUserId": "i73wk8r7f2evbew",
"deletedAt": null
}
]
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1,82 @@
[
{
"email": "it@cardoza.construction",
"id": "oz8sxsyg8hznqa6",
"name": "IT"
},
{
"email": "pbserviceupdate@cardoza.construction",
"id": "x6hu9mgawxzfvk6",
"name": "PBServiceWorker"
},
{
"email": "eewing@cardoza.construction",
"id": "2lmwfhtdwuexmiv",
"name": "Ezekiel Ewing"
},
{
"email": "aewing@cardoza.construction",
"id": "1hbif9dbqng86h9",
"name": "Alfonso Ewing"
},
{
"email": "hmahan@cardoza.construction",
"id": "fdve3i2gop4huym",
"name": "Heidi Mahan"
},
{
"email": "bcardoza@cardoza.construction",
"id": "h9uihmnkwngdu8y",
"name": "Beth Cardoza"
},
{
"email": "rcardoza@cardoza.construction",
"id": "e9vck6v3pa951wn",
"name": "Rick Cardoza"
},
{
"email": "dcardoza@cardoza.construction",
"id": "rrjoxd2wewmlrwy",
"name": "David Cardoza"
},
{
"email": "tcardoza@cardoza.construction",
"id": "sivbh2pwzyew5lb",
"name": "Timothy Cardoza"
},
{
"email": "employee@cardoza.construction",
"id": "gk3u91dx0r79fo2",
"name": "Employee"
},
{
"email": "cabrams@cardoza.construction",
"id": "mchpsyvh0wrcxv6",
"name": "Cortney Abrams"
},
{
"email": "jsnyder@cardoza.construction",
"id": "ewdxrba73q68kqj",
"name": "Joel Snyder"
},
{
"email": "drader@cardoza.construction",
"id": "s1gwhknxaww38i3",
"name": "David Rader"
},
{
"email": "mreds01@aol.com",
"id": "i73wk8r7f2evbew",
"name": "Eddie"
},
{
"email": "bpoland@cardoza.construction",
"id": "nc23vrshrg0qtdi",
"name": "Ben Poland"
},
{
"email": "markhuber66@yahoo.com",
"id": "naztc2sg4b4fgfc",
"name": "Mark Huber"
}
]
@@ -0,0 +1,266 @@
{
"summary": {
"touched": 28,
"personalRefsAdded": 7,
"personalRefsRemoved": 0,
"ambiguousMultiPersonal": 44,
"failures": 0
},
"tasks": [
{
"id": "z6s8kfpek0wowmp",
"title": "4150 Performing Arts - Material Lengths to Bill Mabary - see email",
"ownerIdBefore": "sivbh2pwzyew5lb",
"ownerIdAfter": "sivbh2pwzyew5lb",
"personalUserIds": [
"sivbh2pwzyew5lb",
"nc23vrshrg0qtdi"
]
},
{
"id": "hnn0ruj5kvcj7mi",
"title": "4150 Performing Arts - Acoustical",
"ownerIdBefore": "sivbh2pwzyew5lb",
"ownerIdAfter": "sivbh2pwzyew5lb",
"personalUserIds": [
"sivbh2pwzyew5lb",
"nc23vrshrg0qtdi"
]
},
{
"id": "ij2h8laauwxffyu",
"title": "4118 - Estes new wall CO Awaiting Conf and Sign",
"ownerIdBefore": "sivbh2pwzyew5lb",
"ownerIdAfter": "sivbh2pwzyew5lb",
"personalUserIds": [
"sivbh2pwzyew5lb",
"nc23vrshrg0qtdi"
]
},
{
"id": "yz34jlzc29tq3ve",
"title": "4150 Performing Arts - Framing Shop Drawings",
"ownerIdBefore": "sivbh2pwzyew5lb",
"ownerIdAfter": "sivbh2pwzyew5lb",
"personalUserIds": [
"sivbh2pwzyew5lb",
"nc23vrshrg0qtdi"
]
},
{
"id": "9wu0icmgy8z0dpn",
"title": "4150 Acoustic CO pending response - Performing Arts Center",
"ownerIdBefore": "sivbh2pwzyew5lb",
"ownerIdAfter": "sivbh2pwzyew5lb",
"personalUserIds": [
"sivbh2pwzyew5lb",
"nc23vrshrg0qtdi"
]
},
{
"id": "lq7rzmxv7wtxdx0",
"title": "4118 Estes CO - Windows and Server Room Plywood",
"ownerIdBefore": "sivbh2pwzyew5lb",
"ownerIdAfter": "sivbh2pwzyew5lb",
"personalUserIds": [
"sivbh2pwzyew5lb"
]
},
{
"id": "5cs9kcfqkavgy5y",
"title": "Check in with Dad about further contact Kickapoo Ceilings",
"ownerIdBefore": "sivbh2pwzyew5lb",
"ownerIdAfter": "sivbh2pwzyew5lb",
"personalUserIds": [
"sivbh2pwzyew5lb",
"h9uihmnkwngdu8y"
]
},
{
"id": "59apl48j7se00cw",
"title": "reorganize metal & label",
"ownerIdBefore": "s1gwhknxaww38i3",
"ownerIdAfter": "s1gwhknxaww38i3",
"personalUserIds": [
"s1gwhknxaww38i3"
]
},
{
"id": "5vn3zkn7wzaldsk",
"title": "test",
"ownerIdBefore": "gk3u91dx0r79fo2",
"ownerIdAfter": "gk3u91dx0r79fo2",
"personalUserIds": [
"gk3u91dx0r79fo2"
]
},
{
"id": "p2ut35nntf8s5on",
"title": "4841 - Andys HQ DD- Estimate",
"ownerIdBefore": "sivbh2pwzyew5lb",
"ownerIdAfter": "sivbh2pwzyew5lb",
"personalUserIds": [
"sivbh2pwzyew5lb"
]
},
{
"id": "kn1nxbmwgz7pyz9",
"title": "Equipment lists",
"ownerIdBefore": "nc23vrshrg0qtdi",
"ownerIdAfter": "nc23vrshrg0qtdi",
"personalUserIds": [
"nc23vrshrg0qtdi"
]
},
{
"id": "yqffg2agv88ggjo",
"title": "4118 - Estes - Lead Time Material Orders",
"ownerIdBefore": "nc23vrshrg0qtdi",
"ownerIdAfter": "nc23vrshrg0qtdi",
"personalUserIds": [
"nc23vrshrg0qtdi"
]
},
{
"id": "n9tb1zd2qovqycz",
"title": "4850 - Intercounty Electric Rolla - Estimate",
"ownerIdBefore": "sivbh2pwzyew5lb",
"ownerIdAfter": "sivbh2pwzyew5lb",
"personalUserIds": [
"sivbh2pwzyew5lb"
]
},
{
"id": "5rsy930ttk14kdm",
"title": "4850 - Intercounty Electric Rolla - Estimate (Copy)",
"ownerIdBefore": "e9vck6v3pa951wn",
"ownerIdAfter": "e9vck6v3pa951wn",
"personalUserIds": [
"e9vck6v3pa951wn"
]
},
{
"id": "zfmf0nil83svwan",
"title": "4150 Veregy - Joist lengths",
"ownerIdBefore": "sivbh2pwzyew5lb",
"ownerIdAfter": "sivbh2pwzyew5lb",
"personalUserIds": [
"nc23vrshrg0qtdi",
"sivbh2pwzyew5lb"
]
},
{
"id": "h2al3o03sfkn40z",
"title": "fix hilit gx-120",
"ownerIdBefore": null,
"ownerIdAfter": null,
"personalUserIds": []
},
{
"id": "m3jk7vi6jhnai3y",
"title": "TEST Tim to Bucket",
"ownerIdBefore": null,
"ownerIdAfter": null,
"personalUserIds": []
},
{
"id": "aq1trlil8wnqgms",
"title": "buffer task",
"ownerIdBefore": "gk3u91dx0r79fo2",
"ownerIdAfter": "gk3u91dx0r79fo2",
"personalUserIds": [
"gk3u91dx0r79fo2"
]
},
{
"id": "zbj1uyj0mvusqnb",
"title": "Door strike",
"ownerIdBefore": "2lmwfhtdwuexmiv",
"ownerIdAfter": "2lmwfhtdwuexmiv",
"personalUserIds": [
"2lmwfhtdwuexmiv"
]
},
{
"id": "9347spthvpgrpsn",
"title": "4845 - Pythian St Ceilings Colin - Estimate",
"ownerIdBefore": "sivbh2pwzyew5lb",
"ownerIdAfter": "sivbh2pwzyew5lb",
"personalUserIds": [
"sivbh2pwzyew5lb"
]
},
{
"id": "8xvb21a21k83gd1",
"title": "4150 Veregy Material Order",
"ownerIdBefore": "nc23vrshrg0qtdi",
"ownerIdAfter": "nc23vrshrg0qtdi",
"personalUserIds": [
"nc23vrshrg0qtdi",
"sivbh2pwzyew5lb"
]
},
{
"id": "fzjbasn9r86merg",
"title": "test employee to tim handoff",
"ownerIdBefore": "sivbh2pwzyew5lb",
"ownerIdAfter": "sivbh2pwzyew5lb",
"personalUserIds": [
"sivbh2pwzyew5lb"
]
},
{
"id": "w1ce533o8voja8w",
"title": "Task to note via handoff",
"ownerIdBefore": "sivbh2pwzyew5lb",
"ownerIdAfter": "sivbh2pwzyew5lb",
"personalUserIds": [
"sivbh2pwzyew5lb"
]
},
{
"id": "wk754kcblrx1pxt",
"title": "Test task employee to note via handoff",
"ownerIdBefore": "gk3u91dx0r79fo2",
"ownerIdAfter": "gk3u91dx0r79fo2",
"personalUserIds": []
},
{
"id": "napobxne0ak37gn",
"title": "Test task employee to note via collab",
"ownerIdBefore": "gk3u91dx0r79fo2",
"ownerIdAfter": "gk3u91dx0r79fo2",
"personalUserIds": [
"gk3u91dx0r79fo2"
]
},
{
"id": "1w3bbufjhoqq3sg",
"title": "Test task employee to tim via collab",
"ownerIdBefore": "gk3u91dx0r79fo2",
"ownerIdAfter": "gk3u91dx0r79fo2",
"personalUserIds": [
"gk3u91dx0r79fo2",
"sivbh2pwzyew5lb"
]
},
{
"id": "6o855v9n1r3147h",
"title": "Test task employee to bucket via collab",
"ownerIdBefore": "gk3u91dx0r79fo2",
"ownerIdAfter": "gk3u91dx0r79fo2",
"personalUserIds": [
"gk3u91dx0r79fo2"
]
},
{
"id": "r9303qb1ejgfoaw",
"title": "4150 Veregy Redo Full Takeoff",
"ownerIdBefore": "sivbh2pwzyew5lb",
"ownerIdAfter": "sivbh2pwzyew5lb",
"personalUserIds": [
"sivbh2pwzyew5lb"
]
}
]
}
+10 -2
View File
@@ -1,12 +1,18 @@
{
"name": "tasgrid",
"private": true,
"version": "0.0.0",
"version": "1.1.1",
"type": "module",
"scripts": {
"dev": "vite --host 0.0.0.0 --port 4001",
"dev:devdata": "vite --host 0.0.0.0 --port 4001 --mode devdata",
"build": "tsc -b && vite build",
"build:devdata": "tsc -b && vite build --mode devdata",
"migration:context-ownership:backup": "bun run scripts/context-ownership-migration.ts backup",
"migration:context-ownership:report": "bun run scripts/context-ownership-migration.ts report",
"migration:context-ownership:migrate": "bun run scripts/context-ownership-migration.ts migrate",
"serve": "vite preview --host 0.0.0.0 --port 4000",
"serve:devdata": "vite preview --host 0.0.0.0 --port 4002 --mode devdata",
"---PM2 COMMANDS---": "------------------",
"status": "pm2 status",
"logs": "pm2 logs tasgrid",
@@ -18,6 +24,7 @@
"@formkit/auto-animate": "^0.9.0",
"@kobalte/core": "^0.13.11",
"@tailwindcss/vite": "^4.1.18",
"@thisbeyond/solid-dnd": "^0.7.5",
"@tiptap/core": "^3.18.0",
"@tiptap/extension-bubble-menu": "^3.20.0",
"@tiptap/extension-dropcursor": "^3.20.0",
@@ -41,6 +48,7 @@
"autoprefixer": "^10.4.23",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"heic2any": "^0.0.4",
"lucide-solid": "^0.563.0",
"pm2": "^6.0.14",
"pocketbase": "^0.26.8",
@@ -61,4 +69,4 @@
"vite": "^7.2.4",
"vite-plugin-solid": "^2.11.10"
}
}
}
+44
View File
@@ -0,0 +1,44 @@
# Accessing Job Files from Sister App
This plan outlines how a sister application can access job data and physical files managed by Prism.
## Proposed Strategy
### 1. Accessing Job Metadata (Database)
The source of truth for all job information (names, numbers, addresses, and file paths) is the PocketBase instance.
**Recommendation: Direct PocketBase SDK Integration**
- The sister app should use the [PocketBase JavaScript/TypeScript SDK](https://github.com/pocketbase/js-sdk).
- Point the SDK to the same URL as Prism (e.g., `http://127.0.0.1:8090` or your production domain).
- **Authentication**: The sister app will need its own authentication or a way to share the `pb_auth` token.
### 2. Accessing Physical Files (NAS/Local Folders)
Prism uses two types of file links:
- **`Job_Folder_Link`**: URLs (SharePoint, OneDrive, etc.). These can be opened directly as `<a>` tags or via `window.open()`.
- **`Folder_Local`**: UNC paths (e.g., `\\server\share\folder`).
**Recommendation: Shared Local RPC Tool**
- Prism currently uses a local helper running on `http://localhost:8080` to open folders in File Explorer.
- The sister app can use the exact same logic found in [nasOpener.ts](file:///c:/Users/TimothyCardoza/Documents/AI-Apps/Prism/PRISM/src/lib/nasOpener.ts) to communicate with this tool.
- This allows both apps to provide a seamless "Open Folder" experience on the same machine.
### 3. Proposed API Enhancements (Optional)
If the sister app cannot talk to PocketBase directly (e.g., security restrictions), we can add a "Service Account" or "API Key" mechanism to Prism to proxy requests.
## Proposed Changes
### [Component Name]
No changes are strictly required to Prism's code unless you want a dedicated API for the sister app.
#### [MODIFY] [nasOpener.ts](file:///c:/Users/TimothyCardoza/Documents/AI-Apps/Prism/PRISM/src/lib/nasOpener.ts)
- Potentially export the RPC URL as a shared constant if both apps are in the same monorepo or share a library.
## Verification Plan
### Automated Tests
- Test that the sister app can fetch job data from PocketBase using the same collection names (`Job_Info_Prod`).
- Verify that `http://localhost:8080/health` is reachable from the sister app's environment.
### Manual Verification
- Attempt to open a job folder from the sister app and confirm it opens in the local system's File Explorer.
- Confirm that the sister app correctly resolves job IDs to their respective `Job_Number` or metadata.
+870
View File
@@ -0,0 +1,870 @@
/**
* TasGrid SDK
*
* Headless helpers for sister apps that need to create TasGrid tasks and notes
* using the same normalized creation model as the current TasGrid app.
*/
const DEFAULT_COLLECTIONS = {
tasks: "TasGrid",
notes: "TasGrid_Notes",
contexts: "TasGrid_Contexts",
};
export const TASGRID_SDK_SIGNATURE = "context-model-v2-2026-03-26";
const NOTE_HANDOFF_POLICY_TAG = "__note_policy_handoff__";
const USER_CONTEXT_KEY_PREFIX = "user-";
const URGENCY_HOURS = {
10: 12,
9: 24,
8: 36,
7: 72,
6: 120,
5: 240,
4: 504,
3: 1080,
2: 2160,
1: 4320,
};
const referenceCacheByPb = new WeakMap();
const normalizeWhitespace = (value) => String(value || "").trim().replace(/\s+/g, " ");
const normalizeKey = (value) =>
normalizeWhitespace(value)
.toLowerCase()
.replace(/^[@#]+/, "")
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "");
export const normalizeContextKey = (value) => normalizeKey(value);
export const normalizeNoteKey = (value) => normalizeKey(value);
export const buildShareTag = (displayName) => `@${normalizeWhitespace(displayName)}`;
export const buildNoteTag = (keyOrTitle) => `#${normalizeWhitespace(keyOrTitle)}`;
const buildUserContextKey = (userId) => `${USER_CONTEXT_KEY_PREFIX}${userId}`;
const parseTag = (rawTag) => {
const raw = normalizeWhitespace(rawTag);
const prefix = raw[0];
const display = prefix === "@" || prefix === "#" ? raw.slice(1).trim() : raw;
const key = normalizeKey(display);
if (prefix === "@") {
return { kind: "share", raw, key, display };
}
if (prefix === "#") {
return { kind: "note", raw, key, display };
}
return { kind: "label", raw, key, display };
};
const parseTags = (rawTags) =>
(Array.isArray(rawTags) ? rawTags : [])
.map(parseTag)
.filter((tag) => !!tag.display);
const uniqueStrings = (values) => [...new Set((Array.isArray(values) ? values : []).filter(Boolean))];
const dedupeShareRefs = (refs) => {
const seen = new Set();
return (Array.isArray(refs) ? refs : []).filter((ref) => {
if (!ref) return false;
const key = `${ref.kind}:${ref.contextId}:${ref.key}`;
if (seen.has(key)) return false;
seen.add(key);
return true;
});
};
const dedupeNoteRefs = (refs) => {
const seen = new Set();
return (Array.isArray(refs) ? refs : []).filter((ref) => {
if (!ref) return false;
const key = `${ref.noteId}:${ref.key}`;
if (seen.has(key)) return false;
seen.add(key);
return true;
});
};
const getSdkCache = (pb) => {
let cache = referenceCacheByPb.get(pb);
if (!cache) {
cache = {
collectionsKey: null,
contexts: null,
notes: null,
updatedByFieldSupport: null,
};
referenceCacheByPb.set(pb, cache);
}
return cache;
};
const getCollections = (payload = {}) => ({
tasks: payload.collections?.tasks || DEFAULT_COLLECTIONS.tasks,
notes: payload.collections?.notes || DEFAULT_COLLECTIONS.notes,
contexts: payload.collections?.contexts || DEFAULT_COLLECTIONS.contexts,
});
const resetCacheIfCollectionsChanged = (cache, collections) => {
const key = JSON.stringify(collections);
if (cache.collectionsKey !== key) {
cache.collectionsKey = key;
cache.contexts = null;
cache.notes = null;
cache.updatedByFieldSupport = null;
}
};
const getCurrentUserId = (pb) => pb?.authStore?.model?.id || null;
const getCurrentUserDisplayName = (pb) =>
normalizeWhitespace(pb?.authStore?.model?.name || pb?.authStore?.model?.email || "");
const getRelationId = (value) => {
if (!value) return null;
if (typeof value === "string") return value;
if (Array.isArray(value)) return value[0] || null;
if (typeof value === "object" && typeof value.id === "string") return value.id;
return null;
};
const getDefaultSharePolicyByKind = (kind) => {
if (kind === "user") return "collaborative";
if (kind === "note") return "collaborative";
return "handoff";
};
const mapContextRecord = (record) => ({
id: record.id,
key: record.key || normalizeContextKey(record.displayName || record.name || ""),
displayName: record.displayName || record.name || record.key || "",
kind: record.kind || "bucket",
policy: record.policy || (record.kind === "user" ? "collaborative" : "handoff"),
targetUserId: getRelationId(record.targetUserId),
deletedAt: record.deletedAt || null,
});
const mapNoteRecord = (record) => ({
id: record.id,
key: record.key || normalizeNoteKey(record.title || record.id),
title: record.title || "Untitled",
tags: uniqueStrings(record.tags),
isPrivate: !!record.isPrivate,
user: getRelationId(record.user) || "",
});
const findContextForShareTag = (contexts, parsedTag) =>
contexts.find((context) => !context.deletedAt && context.key === parsedTag.key) ||
contexts.find((context) => !context.deletedAt && context.kind === "user" && normalizeContextKey(context.displayName) === parsedTag.key) ||
contexts.find((context) => !context.deletedAt && normalizeContextKey(context.displayName) === parsedTag.key) ||
contexts.find((context) => !context.deletedAt && buildShareTag(context.displayName).toLowerCase() === parsedTag.raw.toLowerCase()) ||
null;
const getContextByRef = (contexts, notes, ref) =>
(ref.kind === "note"
? (() => {
const note = notes.find((existing) => existing.id === ref.contextId || existing.key === ref.key);
return note
? {
id: note.id,
key: note.key,
displayName: note.key || note.title,
kind: "note",
policy: getNoteSharePolicy(note),
targetUserId: note.user,
deletedAt: null,
}
: null;
})()
: null) ||
contexts.find((context) => !context.deletedAt && context.id === ref.contextId) ||
contexts.find((context) => !context.deletedAt && context.key === ref.key && context.kind === ref.kind) ||
contexts.find((context) => !context.deletedAt && context.kind === ref.kind && normalizeContextKey(context.displayName) === ref.key) ||
contexts.find((context) => !context.deletedAt && normalizeContextKey(context.displayName) === ref.key) ||
null;
const getNoteByRef = (notes, ref) =>
notes.find((note) => note.id === ref.noteId) ||
notes.find((note) => note.key === ref.key) ||
null;
const getNoteSharePolicy = (note) =>
note?.tags?.includes(NOTE_HANDOFF_POLICY_TAG) ? "handoff" : "collaborative";
const getTaskSharePolicy = (contexts, notes, ref) => {
if (ref.kind === "note") {
if (ref.policy) return ref.policy;
const note = notes.find((existing) => existing.id === ref.contextId || existing.key === ref.key);
return getNoteSharePolicy(note);
}
const context = getContextByRef(contexts, notes, ref);
if (ref.policy) return ref.policy;
if (context?.policy) return context.policy;
return getDefaultSharePolicyByKind(ref.kind);
};
const getPersonalContextUserIdFromRef = (contexts, notes, ref) => {
if (ref.kind !== "user") return null;
const context = getContextByRef(contexts, notes, ref);
if (context?.targetUserId) {
return context.targetUserId;
}
if (ref.key && ref.key.startsWith(USER_CONTEXT_KEY_PREFIX)) {
return ref.key.replace(USER_CONTEXT_KEY_PREFIX, "") || null;
}
return null;
};
const getTaskPersonalRefs = (contexts, notes, task) =>
(task.shareRefs || []).filter((ref) => ref.kind === "user" && !!getPersonalContextUserIdFromRef(contexts, notes, ref));
const taskHasLegacyPersonalAccess = (contexts, notes, task, userId) =>
task.ownerId === userId &&
getTaskPersonalRefs(contexts, notes, task).length === 0 &&
!(task.shareRefs || []).some((ref) => ref.kind !== "user" && getTaskSharePolicy(contexts, notes, ref) === "handoff");
const taskHasPersonalContextAccess = (contexts, notes, task, userId, allowLegacyFallback = true) => {
const personalUserIds = [...new Set(
(task.shareRefs || [])
.filter((ref) => ref.kind === "user")
.map((ref) => getPersonalContextUserIdFromRef(contexts, notes, ref))
.filter(Boolean)
)];
return personalUserIds.includes(userId) || (allowLegacyFallback && taskHasLegacyPersonalAccess(contexts, notes, task, userId));
};
const buildTaskTags = (contexts, notes, labelTags, shareRefs, noteRefs, favoriteTag) => {
const tags = [
...(labelTags || []),
...(shareRefs || []).map((ref) => {
const context = getContextByRef(contexts, notes, ref);
if (ref.kind === "note") {
return buildNoteTag(context?.key || ref.key);
}
if (context?.displayName) {
return buildShareTag(context.displayName);
}
if (ref.kind === "user" && ref.key && ref.key.startsWith(USER_CONTEXT_KEY_PREFIX)) {
return null;
}
return buildShareTag(ref.key);
}),
...(noteRefs || []).map((ref) => {
const note = getNoteByRef(notes, ref);
return buildNoteTag(note?.key || ref.key);
}),
];
if (favoriteTag && !tags.includes(favoriteTag)) {
tags.push(favoriteTag);
}
return [...new Set(tags.filter(Boolean))];
};
const buildTaskShareRef = (contexts, notes, parsedTag, existingRefs = [], shareModeOverrides = {}) => {
if (parsedTag.kind === "note") {
const note = notes.find((existing) => existing.key === parsedTag.key);
const nextRefBase = {
contextId: note?.id || parsedTag.key,
key: parsedTag.key,
kind: "note",
};
const existing = existingRefs.find((ref) => ref.kind === "note" && (ref.contextId === nextRefBase.contextId || ref.key === nextRefBase.key));
return {
...nextRefBase,
policy: shareModeOverrides[parsedTag.raw.toLowerCase()] || existing?.policy || getTaskSharePolicy(contexts, notes, nextRefBase),
};
}
const context = findContextForShareTag(contexts, parsedTag);
const nextRefBase = {
contextId: context?.id || parsedTag.key,
key: parsedTag.key,
kind: context?.kind || "bucket",
};
const existing = existingRefs.find((ref) =>
ref.kind === nextRefBase.kind &&
(ref.contextId === nextRefBase.contextId || ref.key === nextRefBase.key)
);
return {
...nextRefBase,
policy: shareModeOverrides[parsedTag.raw.toLowerCase()] || existing?.policy || getTaskSharePolicy(contexts, notes, nextRefBase),
};
};
const deriveTaskRelationsFromTags = (contexts, notes, tags, existingShareRefs = [], shareModeOverrides = {}) => {
const parsedTags = parseTags(tags);
const labelTags = parsedTags.filter((tag) => tag.kind === "label").map((tag) => tag.display);
const shareRefs = parsedTags
.filter((tag) => tag.kind === "share" || tag.kind === "note")
.map((tag) => buildTaskShareRef(contexts, notes, tag, existingShareRefs, shareModeOverrides));
const noteRefs = parsedTags
.filter((tag) => tag.kind === "note")
.map((tag) => {
const note = notes.find((existing) => existing.key === tag.key);
return {
noteId: note?.id || tag.key,
key: tag.key,
};
});
return {
labelTags,
shareRefs: dedupeShareRefs(shareRefs),
noteRefs: dedupeNoteRefs(noteRefs),
};
};
const resolveMirroredOwnerId = (contexts, notes, currentOwnerId, shareRefs, preferredOwnerId) => {
const personalUserIds = [...new Set(
(shareRefs || [])
.filter((ref) => ref.kind === "user")
.map((ref) => getPersonalContextUserIdFromRef(contexts, notes, ref))
.filter(Boolean)
)];
if (personalUserIds.length === 1) {
return personalUserIds[0];
}
if (personalUserIds.length > 1 && preferredOwnerId && personalUserIds.includes(preferredOwnerId)) {
return preferredOwnerId;
}
return currentOwnerId || null;
};
const applyHandoffs = (contexts, notes, senderUserId, senderPersonalContext, task, updates) => {
if (!senderUserId || !taskHasPersonalContextAccess(contexts, notes, task, senderUserId)) {
return updates;
}
const refs = updates.shareRefs || task.shareRefs || [];
const stripSenderPersonalContext = (inputRefs) =>
senderPersonalContext
? inputRefs.filter((existingRef) => !(
existingRef.kind === "user" &&
(existingRef.contextId === senderPersonalContext.id || existingRef.key === senderPersonalContext.key)
))
: inputRefs;
for (const ref of refs) {
const context = getContextByRef(contexts, notes, ref);
if (senderPersonalContext && ref.kind === "user" && (
ref.contextId === senderPersonalContext.id ||
ref.key === senderPersonalContext.key
)) {
continue;
}
if (!context || getTaskSharePolicy(contexts, notes, ref) !== "handoff") continue;
const nextShareRefs = stripSenderPersonalContext(refs);
const labelTags = updates.labelTags || task.labelTags || [];
const noteRefs = updates.noteRefs || task.noteRefs || [];
const favoriteTag = (updates.tags || task.tags || []).find((tag) => tag.endsWith("_favorite__"));
if (context.kind === "user" && context.targetUserId) {
return {
...updates,
ownerId: context.targetUserId,
shareRefs: nextShareRefs,
tags: buildTaskTags(contexts, notes, labelTags, nextShareRefs, noteRefs, favoriteTag),
};
}
if (context.kind === "bucket" || context.kind === "note") {
return {
...updates,
shareRefs: nextShareRefs,
tags: buildTaskTags(contexts, notes, labelTags, nextShareRefs, noteRefs, favoriteTag),
};
}
}
return updates;
};
const mapTaskRecord = (contexts, notes, record) => {
const legacyTags = uniqueStrings(record.tags).filter((tag) => tag !== "__template__");
const parsedTags = parseTags(legacyTags);
const shareRefs = Array.isArray(record.shareRefs)
? record.shareRefs.map((ref) => ({
contextId: ref.contextId,
key: ref.key,
kind: ref.kind,
policy: ref.policy,
}))
: parsedTags
.filter((tag) => tag.kind === "share" || tag.kind === "note")
.map((tag) => buildTaskShareRef(contexts, notes, tag));
const noteRefs = Array.isArray(record.noteRefs)
? record.noteRefs.map((ref) => ({ noteId: ref.noteId, key: ref.key }))
: parsedTags
.filter((tag) => tag.kind === "note")
.map((tag) => {
const note = notes.find((existing) => existing.key === tag.key);
return {
noteId: note?.id || tag.key,
key: tag.key,
};
});
const noteShareRefs = noteRefs.map((ref) => ({
contextId: ref.noteId || ref.key,
key: ref.key,
kind: "note",
policy: shareRefs.find((existing) => existing.kind === "note" && (existing.contextId === ref.noteId || existing.key === ref.key))?.policy,
}));
const mergedShareRefs = dedupeShareRefs([...shareRefs, ...noteShareRefs]);
const labelTags = Array.isArray(record.labelTags)
? record.labelTags
: parsedTags.filter((tag) => tag.kind === "label").map((tag) => tag.display);
const favoriteTag = legacyTags.find((tag) => tag.endsWith("_favorite__"));
return {
id: record.id,
title: record.title || "Untitled",
tags: buildTaskTags(contexts, notes, labelTags, mergedShareRefs, noteRefs, favoriteTag),
labelTags,
shareRefs: mergedShareRefs,
noteRefs,
ownerId: getRelationId(record.user),
createdBy: getRelationId(record.createdBy) || getRelationId(record.user),
};
};
const loadContexts = async (pb, collections, cache) => {
if (cache.contexts) return cache.contexts;
const records = await pb.collection(collections.contexts).getFullList({
sort: "displayName,key",
fields: "id,key,displayName,name,kind,policy,targetUserId,deletedAt",
requestKey: null,
}).catch(() => []);
cache.contexts = records.map(mapContextRecord);
return cache.contexts;
};
const loadNotes = async (pb, collections, cache) => {
if (cache.notes) return cache.notes;
const records = await pb.collection(collections.notes).getFullList({
fields: "id,key,title,tags,isPrivate,user",
requestKey: null,
}).catch(() => []);
cache.notes = records.map(mapNoteRecord);
return cache.notes;
};
const ensureOwnerPersonalContext = async (pb, ownerId, collections, cache) => {
if (!ownerId) return null;
const contexts = await loadContexts(pb, collections, cache);
const existing = contexts.find((context) =>
!context.deletedAt &&
context.kind === "user" &&
context.targetUserId === ownerId
);
if (existing) return existing;
if (ownerId !== getCurrentUserId(pb)) {
return null;
}
const displayName = getCurrentUserDisplayName(pb);
if (!displayName) {
return null;
}
try {
const created = await pb.collection(collections.contexts).create({
key: buildUserContextKey(ownerId),
displayName,
kind: "user",
policy: "collaborative",
targetUserId: ownerId,
}, { requestKey: null });
const mapped = mapContextRecord(created);
cache.contexts = [...contexts, mapped];
return mapped;
} catch (_err) {
return null;
}
};
const detectUpdatedBySupport = async (pb, collectionName, cache) => {
if (cache.updatedByFieldSupport !== null) {
return cache.updatedByFieldSupport;
}
try {
const sample = await pb.collection(collectionName).getList(1, 1, {
fields: "id,updatedBy",
requestKey: null,
});
const item = sample?.items?.[0];
cache.updatedByFieldSupport = !!(item && Object.prototype.hasOwnProperty.call(item, "updatedBy"));
} catch (_err) {
cache.updatedByFieldSupport = false;
}
return cache.updatedByFieldSupport;
};
const addOwnerContextTag = (tags, ownerContext) => {
const nextTags = uniqueStrings(tags);
if (!ownerContext?.displayName) return nextTags;
const ownerTag = buildShareTag(ownerContext.displayName);
if (!nextTags.some((tag) => tag.toLowerCase() === ownerTag.toLowerCase())) {
nextTags.push(ownerTag);
}
return nextTags;
};
const buildTaskCreatePayload = async (pb, payload = {}) => {
const collections = getCollections(payload);
const cache = getSdkCache(pb);
resetCacheIfCollectionsChanged(cache, collections);
const senderUserId = getCurrentUserId(pb);
const ownerId = payload.owner || senderUserId;
const ownerContext = await ensureOwnerPersonalContext(pb, ownerId, collections, cache);
const senderPersonalContext = senderUserId
? await ensureOwnerPersonalContext(pb, senderUserId, collections, cache)
: null;
const contexts = await loadContexts(pb, collections, cache);
const notes = await loadNotes(pb, collections, cache);
const urgencyVal = payload.urgency ?? 5;
const startDate = payload.startDate || new Date().toISOString();
const dueDate = payload.dueDate || calculateDateFromUrgency(urgencyVal);
const priority = payload.priority ?? 5;
const size = payload.size ?? 3;
const baseTags = Array.isArray(payload.tags) && payload.tags.length > 0 ? payload.tags : ["work"];
const tagsWithOwnerContext = addOwnerContextTag(baseTags, ownerContext);
const content = payload.content || "";
const { labelTags, shareRefs, noteRefs } = deriveTaskRelationsFromTags(
contexts,
notes,
tagsWithOwnerContext,
[],
payload.shareModeByTag || {}
);
const initialTask = {
title: payload.title || "New Task",
tags: buildTaskTags(contexts, notes, labelTags, shareRefs, noteRefs),
labelTags,
shareRefs,
noteRefs,
ownerId: ownerId || "",
createdBy: senderUserId || ownerId || "",
};
const handoffUpdates = applyHandoffs(
contexts,
notes,
senderUserId,
senderPersonalContext,
initialTask,
initialTask
);
const finalShareRefs = handoffUpdates.shareRefs || initialTask.shareRefs;
const finalOwnerId = resolveMirroredOwnerId(
contexts,
notes,
handoffUpdates.ownerId ?? initialTask.ownerId,
finalShareRefs,
handoffUpdates.ownerId ?? initialTask.ownerId
);
const finalTask = {
...initialTask,
...handoffUpdates,
ownerId: finalOwnerId,
};
return {
collections,
cache,
createPayload: {
createdBy: finalTask.createdBy,
title: finalTask.title,
startDate,
dueDate,
priority,
status: payload.status ?? 0,
completed: payload.completed ?? false,
tags: finalTask.tags,
labelTags: finalTask.labelTags,
shareRefs: finalTask.shareRefs,
noteRefs: finalTask.noteRefs,
content,
size,
},
};
};
/**
* Calculates a future ISO date string based on the TasGrid urgency decay algorithm.
* @param {number} level Urgency level (1-10)
* @returns {string} ISO Date string
*/
export const calculateDateFromUrgency = (level) => {
const roundedLevel = Math.max(1, Math.min(10, Math.round(level)));
const hours = URGENCY_HOURS[roundedLevel] || 24;
return new Date(Date.now() + hours * 60 * 60 * 1000).toISOString();
};
/**
* Creates a new task using TasGrid's current normalized creation model.
*
* @param {object} pb Authenticated PocketBase instance.
* @param {object} payload Task payload.
* @param {string} [payload.title="New Task"] Task title.
* @param {string} [payload.owner] PocketBase user id whose personal context should own the task.
* @param {number} [payload.priority=5] Priority (1-10).
* @param {number} [payload.urgency=5] Urgency (1-10). Used when dueDate is omitted.
* @param {number} [payload.size=3] Size (0-10).
* @param {number} [payload.status=0] Status (0-10).
* @param {boolean} [payload.completed=false] Completion status.
* @param {string[]} [payload.tags=["work"]] Display tags. Supports labels, @contexts, and #notes.
* @param {Record<string,"collaborative"|"handoff">} [payload.shareModeByTag] Share mode overrides keyed by tag.
* @param {string} [payload.content=""] HTML content.
* @param {string} [payload.dueDate] ISO date string.
* @param {string} [payload.startDate] ISO date string.
* @param {{tasks?: string, notes?: string, contexts?: string}} [payload.collections] Optional collection overrides.
* @returns {Promise<object>} Created PocketBase record.
*/
export const createTasgridTask = async (pb, payload = {}) => {
if (!pb || !pb.authStore || !pb.authStore.isValid || !pb.authStore.model) {
throw new Error("Invalid or unauthenticated PocketBase instance provided.");
}
const { collections, cache, createPayload } = await buildTaskCreatePayload(pb, payload);
const updatedBySupported = await detectUpdatedBySupport(pb, collections.tasks, cache);
if (updatedBySupported) {
createPayload.updatedBy = getCurrentUserId(pb);
}
try {
return await pb.collection(collections.tasks).create(createPayload, { requestKey: null });
} catch (err) {
if (updatedBySupported && err?.data?.data?.updatedBy) {
delete createPayload.updatedBy;
cache.updatedByFieldSupport = false;
return await pb.collection(collections.tasks).create(createPayload, { requestKey: null });
}
console.error("TasGrid SDK: Failed to create task", err);
throw err;
}
};
const buildNoteCreatePayload = async (pb, payload = {}) => {
const collections = getCollections(payload);
const cache = getSdkCache(pb);
resetCacheIfCollectionsChanged(cache, collections);
const title = payload.title || "New Note";
const explicitKey = normalizeNoteKey(payload.key || "");
const keyBase = explicitKey || normalizeNoteKey(title || "new-note") || "new-note";
const key = explicitKey || `${keyBase}-${Date.now()}`;
const baseTags = uniqueStrings(payload.tags);
const nextTags = payload.noteShareMode === "handoff" && !baseTags.includes(NOTE_HANDOFF_POLICY_TAG)
? [...baseTags, NOTE_HANDOFF_POLICY_TAG]
: baseTags;
return {
collections,
cache,
noteData: {
title,
key,
content: payload.content || "",
tags: nextTags,
isPrivate: !!payload.isPrivate,
tasks: uniqueStrings(payload.tasks),
user: payload.owner || getCurrentUserId(pb),
},
};
};
/**
* Adds current note-link metadata to existing tasks so they behave like note-linked
* tasks created inside TasGrid.
*
* @param {object} pb Authenticated PocketBase instance.
* @param {string|object} noteOrId Note id or note-like object with id/key/title/tags/user.
* @param {string[]} taskIds Task ids to link.
* @param {{collections?: {tasks?: string, notes?: string, contexts?: string}}} [options] Optional collection overrides.
* @returns {Promise<object[]>} Updated task records.
*/
export const linkTasgridTasksToNote = async (pb, noteOrId, taskIds, options = {}) => {
if (!pb || !pb.authStore || !pb.authStore.isValid || !pb.authStore.model) {
throw new Error("Invalid or unauthenticated PocketBase instance provided.");
}
const collections = getCollections(options);
const cache = getSdkCache(pb);
resetCacheIfCollectionsChanged(cache, collections);
const senderUserId = getCurrentUserId(pb);
const senderPersonalContext = senderUserId
? await ensureOwnerPersonalContext(pb, senderUserId, collections, cache)
: null;
const contexts = await loadContexts(pb, collections, cache);
const notes = await loadNotes(pb, collections, cache);
const note = typeof noteOrId === "string"
? notes.find((existing) => existing.id === noteOrId) || mapNoteRecord(await pb.collection(collections.notes).getOne(noteOrId, {
fields: "id,key,title,tags,isPrivate,user",
requestKey: null,
}))
: {
id: noteOrId.id,
key: noteOrId.key || normalizeNoteKey(noteOrId.title || noteOrId.id),
title: noteOrId.title || "Untitled",
tags: uniqueStrings(noteOrId.tags),
isPrivate: !!noteOrId.isPrivate,
user: noteOrId.user || "",
};
if (!notes.some((existing) => existing.id === note.id)) {
cache.notes = [...notes, note];
}
const noteTag = buildNoteTag(note.key || note.title);
const uniqueTaskIds = uniqueStrings(taskIds);
const updatedBySupported = await detectUpdatedBySupport(pb, collections.tasks, cache);
const updatedRecords = [];
for (const taskId of uniqueTaskIds) {
const record = await pb.collection(collections.tasks).getOne(taskId, {
fields: "id,title,tags,labelTags,shareRefs,noteRefs,user,createdBy",
requestKey: null,
});
const currentTask = mapTaskRecord(contexts, cache.notes || notes, record);
const favoriteTag = currentTask.tags.find((tag) => tag.endsWith("_favorite__"));
const nextTags = uniqueStrings([...(currentTask.tags || []), noteTag]);
const derived = deriveTaskRelationsFromTags(
contexts,
cache.notes || notes,
nextTags,
currentTask.shareRefs,
{}
);
const baseUpdates = {
labelTags: derived.labelTags,
shareRefs: derived.shareRefs,
noteRefs: derived.noteRefs,
tags: buildTaskTags(contexts, cache.notes || notes, derived.labelTags, derived.shareRefs, derived.noteRefs, favoriteTag),
};
const handoffUpdates = applyHandoffs(
contexts,
cache.notes || notes,
senderUserId,
senderPersonalContext,
currentTask,
baseUpdates
);
const finalShareRefs = handoffUpdates.shareRefs || baseUpdates.shareRefs;
const finalOwnerId = resolveMirroredOwnerId(
contexts,
cache.notes || notes,
handoffUpdates.ownerId ?? currentTask.ownerId,
finalShareRefs,
handoffUpdates.ownerId ?? currentTask.ownerId
);
const updatePayload = {
tags: handoffUpdates.tags || baseUpdates.tags,
labelTags: handoffUpdates.labelTags || baseUpdates.labelTags,
shareRefs: finalShareRefs,
noteRefs: handoffUpdates.noteRefs || baseUpdates.noteRefs,
};
if (updatedBySupported) {
updatePayload.updatedBy = senderUserId;
}
try {
const updated = await pb.collection(collections.tasks).update(taskId, updatePayload, { requestKey: null });
updatedRecords.push(updated);
} catch (err) {
if (updatedBySupported && err?.data?.data?.updatedBy) {
delete updatePayload.updatedBy;
cache.updatedByFieldSupport = false;
const updated = await pb.collection(collections.tasks).update(taskId, updatePayload, { requestKey: null });
updatedRecords.push(updated);
continue;
}
console.error("TasGrid SDK: Failed to link task to note", err);
throw err;
}
}
return updatedRecords;
};
/**
* Creates a new note using TasGrid's current creation model.
*
* @param {object} pb Authenticated PocketBase instance.
* @param {object} payload Note payload.
* @param {string} [payload.title="New Note"] Note title.
* @param {string} [payload.key] Optional canonical note key override.
* @param {string} [payload.content=""] HTML content.
* @param {string[]} [payload.tags=[]] Note tags.
* @param {"collaborative"|"handoff"} [payload.noteShareMode] Note workspace share mode.
* @param {boolean} [payload.isPrivate=false] Privacy flag.
* @param {string[]} [payload.tasks=[]] Task ids to store and link to this note.
* @param {boolean} [payload.linkTasks=true] When tasks are provided, also apply task-side note linkage metadata.
* @param {string} [payload.owner] PocketBase user id. Defaults to the current auth user.
* @param {{tasks?: string, notes?: string, contexts?: string}} [payload.collections] Optional collection overrides.
* @returns {Promise<object>} Created PocketBase record.
*/
export const createTasgridNote = async (pb, payload = {}) => {
if (!pb || !pb.authStore || !pb.authStore.isValid || !pb.authStore.model) {
throw new Error("Invalid or unauthenticated PocketBase instance provided.");
}
const { collections, cache, noteData } = await buildNoteCreatePayload(pb, payload);
try {
const record = await pb.collection(collections.notes).create(noteData, { requestKey: null });
const mappedNote = mapNoteRecord({ ...record, ...noteData });
cache.notes = [...(cache.notes || []), mappedNote];
if ((noteData.tasks || []).length > 0 && payload.linkTasks !== false) {
await linkTasgridTasksToNote(pb, mappedNote, noteData.tasks, { collections });
}
return record;
} catch (err) {
console.error("TasGrid SDK: Failed to create note", err);
throw err;
}
};
+596
View File
@@ -0,0 +1,596 @@
import fs from "node:fs/promises";
import path from "node:path";
type SharePolicy = "collaborative" | "handoff";
type ShareKind = "user" | "bucket" | "note";
type ShareContext = {
id: string;
key: string;
displayName: string;
kind: ShareKind;
policy: SharePolicy;
targetUserId?: string | null;
deletedAt?: string | null;
};
type NoteRecord = {
id: string;
title: string;
key: string;
tags: string[];
user: string;
tasks: string[];
};
type TaskShareRef = {
contextId: string;
key: string;
kind: ShareKind;
policy?: SharePolicy;
};
type NoteRef = {
noteId: string;
key: string;
};
type ParsedTag =
| { kind: "share"; raw: string; key: string; display: string }
| { kind: "note"; raw: string; key: string; display: string }
| { kind: "label"; raw: string; key: string; display: string };
type TaskRecord = {
id: string;
title: string;
tags?: string[];
labelTags?: string[];
shareRefs?: TaskShareRef[];
noteRefs?: NoteRef[];
createdBy?: string | null;
user?: string | string[] | null;
};
type Summary = {
touched: number;
personalRefsAdded: number;
personalRefsRemoved: number;
ambiguousMultiPersonal: number;
failures: number;
};
const mode = (process.argv[2] || "report").toLowerCase();
if (!["backup", "report", "migrate"].includes(mode)) {
throw new Error("Usage: bun run scripts/context-ownership-migration.ts [backup|report|migrate]");
}
const dataMode = (process.env.TASGRID_DATA_MODE || "prod").toLowerCase() === "dev" ? "dev" : "prod";
const withDataSuffix = (base: string) => dataMode === "dev" ? `${base}_Dev` : base;
const TASGRID_COLLECTION = withDataSuffix("TasGrid");
const CONTEXTS_COLLECTION = withDataSuffix("TasGrid_Contexts");
const NOTES_COLLECTION = withDataSuffix("TasGrid_Notes");
const NOTE_HANDOFF_POLICY_TAG = "__note_policy_handoff__";
const POCKETBASE_URL = process.env.POCKETBASE_URL || "https://pocketbase.ccllc.pro";
let authToken = "";
const normalizeWhitespace = (value: string) => value.trim().replace(/\s+/g, " ");
const normalizeKey = (value: string) =>
normalizeWhitespace(value)
.toLowerCase()
.replace(/^[@#]+/, "")
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "");
const buildShareTag = (displayName: string) => `@${normalizeWhitespace(displayName)}`;
const buildNoteTag = (value: string) => `#${normalizeWhitespace(value)}`;
const buildUserContextKey = (userId: string) => `user-${userId}`;
const unwrapRelationId = (value: unknown): string | null => {
if (typeof value === "string") return value || null;
if (Array.isArray(value)) {
const first = value[0];
return typeof first === "string" ? first : null;
}
return null;
};
const parseTag = (rawTag: string): ParsedTag => {
const raw = normalizeWhitespace(rawTag);
const prefix = raw[0];
const display = prefix === "@" || prefix === "#" ? raw.slice(1).trim() : raw;
const key = normalizeKey(display);
if (prefix === "@") return { kind: "share", raw, key, display };
if (prefix === "#") return { kind: "note", raw, key, display };
return { kind: "label", raw, key, display };
};
const parseTags = (rawTags: string[] | undefined | null) =>
(rawTags || []).map(parseTag).filter(tag => !!tag.display);
const dedupeShareRefs = (refs: TaskShareRef[]) => {
const seen = new Set<string>();
return refs.filter(ref => {
const key = `${ref.kind}:${ref.contextId}:${ref.key}`;
if (seen.has(key)) return false;
seen.add(key);
return true;
});
};
const dedupeNoteRefs = (refs: NoteRef[]) => {
const seen = new Set<string>();
return refs.filter(ref => {
const key = `${ref.noteId}:${ref.key}`;
if (seen.has(key)) return false;
seen.add(key);
return true;
});
};
const DEFAULT_SHARE_POLICY_BY_KIND: Record<ShareKind, SharePolicy> = {
user: "collaborative",
bucket: "handoff",
note: "handoff"
};
const getNoteSharePolicy = (note: Pick<NoteRecord, "tags"> | undefined): SharePolicy =>
note?.tags?.includes(NOTE_HANDOFF_POLICY_TAG) ? "handoff" : "collaborative";
const relationId = (record: any, field: string) => unwrapRelationId(record?.[field]);
const createOutputDir = async () => {
const outputDir = path.resolve(process.cwd(), "migration-output", `context-ownership-${new Date().toISOString().replace(/[:.]/g, "-")}`);
await fs.mkdir(outputDir, { recursive: true });
return outputDir;
};
const tryAuthEndpoint = async (pathname: string, email: string, password: string) => {
const response = await fetch(`${POCKETBASE_URL}${pathname}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ identity: email, password })
});
if (!response.ok) {
return {
ok: false as const,
status: response.status,
body: await response.text(),
pathname
};
}
return {
ok: true as const,
payload: await response.json(),
pathname
};
};
const auth = async () => {
const email = process.env.PB_ADMIN_EMAIL || process.env.PB_EMAIL;
const password = process.env.PB_ADMIN_PASSWORD || process.env.PB_PASSWORD;
if (!email || !password) {
throw new Error("Set PB_ADMIN_EMAIL/PB_ADMIN_PASSWORD (or PB_EMAIL/PB_PASSWORD) before running this script.");
}
const authAttempts = [
await tryAuthEndpoint("/api/collections/_superusers/auth-with-password", email, password),
await tryAuthEndpoint("/api/admins/auth-with-password", email, password)
];
const success = authAttempts.find(attempt => attempt.ok);
if (!success) {
const details = authAttempts
.map(attempt => `${attempt.pathname}: ${attempt.status} ${attempt.body}`)
.join(" | ");
throw new Error(`PocketBase superuser/admin auth failed. ${details}`);
}
const payload = success.payload;
authToken = payload.token;
};
const request = async (pathname: string, init: RequestInit = {}) => {
const response = await fetch(`${POCKETBASE_URL}${pathname}`, {
...init,
headers: {
"Content-Type": "application/json",
...(authToken ? { Authorization: authToken } : {}),
...(init.headers || {})
}
});
if (!response.ok) {
throw new Error(`${response.status} ${response.statusText}: ${await response.text()}`);
}
if (response.status === 204) {
return null;
}
return response.json();
};
const getFullList = async (collection: string, query: Record<string, string> = {}) => {
const items: any[] = [];
let page = 1;
while (true) {
const search = new URLSearchParams({ perPage: "500", page: String(page), ...query });
const payload = await request(`/api/collections/${collection}/records?${search.toString()}`);
items.push(...(payload.items || []));
if (!payload.totalPages || page >= payload.totalPages) {
break;
}
page += 1;
}
return items;
};
const createRecord = (collection: string, body: Record<string, unknown>) =>
request(`/api/collections/${collection}/records`, {
method: "POST",
body: JSON.stringify(body)
});
const updateRecord = (collection: string, id: string, body: Record<string, unknown>) =>
request(`/api/collections/${collection}/records/${id}`, {
method: "PATCH",
body: JSON.stringify(body)
});
const syncUserContexts = async (users: any[], contexts: ShareContext[]) => {
const nextContexts = [...contexts];
for (const user of users) {
const displayName = user.name || user.email;
const existing = nextContexts.find(context =>
context.kind === "user" &&
context.targetUserId === user.id &&
!context.deletedAt
);
if (!displayName || existing) continue;
const created = await createRecord(CONTEXTS_COLLECTION, {
key: buildUserContextKey(user.id),
displayName,
kind: "user",
policy: "collaborative",
targetUserId: user.id
});
nextContexts.push({
id: created.id,
key: created.key,
displayName: created.displayName,
kind: created.kind,
policy: "collaborative",
targetUserId: relationId(created, "targetUserId"),
deletedAt: created.deletedAt || null
});
}
return nextContexts;
};
const buildNoteLinkMap = (notes: NoteRecord[]) => {
const links = new Map<string, NoteRef[]>();
for (const note of notes) {
for (const taskId of note.tasks || []) {
const existing = links.get(taskId) || [];
existing.push({ noteId: note.id, key: note.key });
links.set(taskId, existing);
}
}
return links;
};
const main = async () => {
await auth();
const [users, rawContexts, rawNotes, rawTasks] = await Promise.all([
getFullList("users", { fields: "id,name,email" }),
getFullList(CONTEXTS_COLLECTION),
getFullList(NOTES_COLLECTION),
getFullList(TASGRID_COLLECTION)
]);
const contexts = mode === "migrate"
? await syncUserContexts(users, rawContexts.map((record: any) => ({
id: record.id,
key: record.key,
displayName: record.displayName,
kind: record.kind || "bucket",
policy: (record.policy || (record.kind === "user" ? "collaborative" : "handoff")) as SharePolicy,
targetUserId: relationId(record, "targetUserId"),
deletedAt: record.deletedAt || null
})))
: rawContexts.map((record: any) => ({
id: record.id,
key: record.key,
displayName: record.displayName,
kind: record.kind || "bucket",
policy: (record.policy || (record.kind === "user" ? "collaborative" : "handoff")) as SharePolicy,
targetUserId: relationId(record, "targetUserId"),
deletedAt: record.deletedAt || null
}));
const notes = rawNotes.map((record: any) => ({
id: record.id,
title: record.title,
key: record.key || normalizeKey(record.title || record.id),
tags: Array.isArray(record.tags) ? record.tags.filter(Boolean) : [],
user: relationId(record, "user") || "",
tasks: Array.isArray(record.tasks) ? record.tasks.filter(Boolean) : []
})) satisfies NoteRecord[];
const contextById = new Map(contexts.map(context => [context.id, context]));
const ownerContextByUserId = new Map(
contexts
.filter(context => context.kind === "user" && !!context.targetUserId && !context.deletedAt)
.map(context => [context.targetUserId as string, context] as const)
);
const noteById = new Map(notes.map(note => [note.id, note]));
const noteByKey = new Map(notes.map(note => [note.key, note]));
const noteLinksByTaskId = buildNoteLinkMap(notes);
const getContextByRef = (ref: TaskShareRef) => {
if (ref.kind === "note") {
const note = noteById.get(ref.contextId) || noteByKey.get(ref.key);
return note
? {
id: note.id,
key: note.key,
displayName: note.key || note.title,
kind: "note" as const,
policy: getNoteSharePolicy(note),
targetUserId: note.user
}
: null;
}
return contextById.get(ref.contextId)
|| contexts.find(context => !context.deletedAt && context.key === ref.key && context.kind === ref.kind)
|| contexts.find(context => !context.deletedAt && normalizeKey(context.displayName) === ref.key)
|| null;
};
const getTaskSharePolicy = (ref: TaskShareRef): SharePolicy => {
if (ref.kind === "note") {
if (ref.policy) return ref.policy;
const note = noteById.get(ref.contextId) || noteByKey.get(ref.key);
return getNoteSharePolicy(note);
}
const context = getContextByRef(ref);
if (ref.policy) return ref.policy;
return context?.policy || DEFAULT_SHARE_POLICY_BY_KIND[ref.kind];
};
const getPersonalContextUserIdFromRef = (ref: TaskShareRef) => {
if (ref.kind !== "user") return null;
const context = getContextByRef(ref);
if (context?.targetUserId) return context.targetUserId;
if (ref.key.startsWith("user-")) return ref.key.replace(/^user-/, "") || null;
return null;
};
const getTaskPersonalRefs = (refs: TaskShareRef[]) =>
refs.filter(ref => ref.kind === "user" && !!getPersonalContextUserIdFromRef(ref));
const resolveMirroredOwnerId = (currentOwnerId: string | null, refs: TaskShareRef[], preferredOwnerId?: string | null) => {
const personalUserIds = [...new Set(
refs
.map(ref => getPersonalContextUserIdFromRef(ref))
.filter((value): value is string => !!value)
)];
if (personalUserIds.length === 1) return personalUserIds[0];
if (personalUserIds.length > 1 && preferredOwnerId && personalUserIds.includes(preferredOwnerId)) return preferredOwnerId;
return currentOwnerId || null;
};
const buildTaskTags = (labelTags: string[], shareRefs: TaskShareRef[], noteRefs: NoteRef[], favoriteTag?: string) => {
const tags = [
...labelTags,
...shareRefs.map(ref => {
const context = getContextByRef(ref);
if (ref.kind === "note") return buildNoteTag(context?.key || ref.key);
if (context?.displayName) return buildShareTag(context.displayName);
if (ref.kind === "user" && ref.key.startsWith("user-")) return null;
return buildShareTag(ref.key);
}),
...noteRefs.map(ref => {
const note = noteById.get(ref.noteId) || noteByKey.get(ref.key);
return buildNoteTag(note?.key || ref.key);
})
];
if (favoriteTag && !tags.includes(favoriteTag)) tags.push(favoriteTag);
return [...new Set(tags.filter((value): value is string => !!value))];
};
const summary: Summary = {
touched: 0,
personalRefsAdded: 0,
personalRefsRemoved: 0,
ambiguousMultiPersonal: 0,
failures: 0
};
const reportRows: any[] = [];
for (const record of rawTasks as TaskRecord[]) {
if ((record.tags || []).includes("__template__")) continue;
try {
const legacyTags = record.tags || [];
const parsedTags = parseTags(legacyTags).filter(tag => tag.raw !== "__template__");
const shareRefs = Array.isArray(record.shareRefs)
? record.shareRefs.map(ref => ({
contextId: ref.contextId,
key: ref.key,
kind: ref.kind,
policy: ref.policy
}))
: parsedTags
.filter((tag): tag is Extract<(typeof parsedTags)[number], { kind: "share" | "note" }> => tag.kind === "share" || tag.kind === "note")
.map(tag => {
if (tag.kind === "note") {
const note = noteByKey.get(tag.key);
return {
contextId: note?.id || tag.key,
key: tag.key,
kind: "note" as const
};
}
const context = contexts.find(existing =>
!existing.deletedAt && (
existing.key === tag.key ||
normalizeKey(existing.displayName) === tag.key ||
buildShareTag(existing.displayName).toLowerCase() === tag.raw.toLowerCase()
)
);
return {
contextId: context?.id || tag.key,
key: tag.key,
kind: (context?.kind || "bucket") as "user" | "bucket",
policy: context?.policy
};
});
const noteRefs = dedupeNoteRefs([
...(Array.isArray(record.noteRefs)
? record.noteRefs.map(ref => ({ noteId: ref.noteId, key: ref.key }))
: parsedTags
.filter(tag => tag.kind === "note")
.map(tag => {
const note = noteByKey.get(tag.key);
return {
noteId: note?.id || tag.key,
key: tag.key
};
})),
...(noteLinksByTaskId.get(record.id) || [])
]);
let nextShareRefs = dedupeShareRefs([
...shareRefs,
...noteRefs.map(ref => ({
contextId: ref.noteId,
key: ref.key,
kind: "note" as const,
policy: shareRefs.find(existing => existing.kind === "note" && (existing.contextId === ref.noteId || existing.key === ref.key))?.policy
}))
]);
const ownerId = unwrapRelationId(record.user);
const ownerContext = ownerId ? ownerContextByUserId.get(ownerId) : undefined;
const shouldRemoveOwnerPersonalRef = !!ownerId &&
getTaskPersonalRefs(nextShareRefs).length === 0 &&
nextShareRefs.some(ref => ref.kind !== "user" && getTaskSharePolicy(ref) === "handoff");
if (ownerContext) {
const matchesOwnerContext = (ref: TaskShareRef) =>
ref.kind === "user" && (
ref.contextId === ownerContext.id ||
ref.key === ownerContext.key ||
normalizeKey(ownerContext.displayName) === ref.key
);
if (shouldRemoveOwnerPersonalRef) {
const before = nextShareRefs.length;
nextShareRefs = nextShareRefs.filter(ref => !matchesOwnerContext(ref));
if (before !== nextShareRefs.length) {
summary.personalRefsRemoved += before - nextShareRefs.length;
}
} else if (!nextShareRefs.some(matchesOwnerContext)) {
nextShareRefs = [
{
contextId: ownerContext.id,
key: ownerContext.key,
kind: "user" as const,
policy: ownerContext.policy
},
...nextShareRefs
];
summary.personalRefsAdded += 1;
}
}
nextShareRefs = dedupeShareRefs(nextShareRefs);
const labelTags = Array.isArray(record.labelTags)
? record.labelTags
: parsedTags.filter(tag => tag.kind === "label").map(tag => tag.display);
const favoriteTag = legacyTags.find(tag => tag.endsWith("_favorite__"));
const nextTags = buildTaskTags(labelTags, nextShareRefs, noteRefs, favoriteTag);
const nextOwnerId = resolveMirroredOwnerId(ownerId, nextShareRefs, ownerId);
const personalUserIds = [...new Set(nextShareRefs.map(ref => getPersonalContextUserIdFromRef(ref)).filter((value): value is string => !!value))];
if (personalUserIds.length > 1) {
summary.ambiguousMultiPersonal += 1;
}
const changed =
JSON.stringify(nextShareRefs) !== JSON.stringify(record.shareRefs || []) ||
JSON.stringify(noteRefs) !== JSON.stringify(record.noteRefs || []) ||
JSON.stringify(nextTags) !== JSON.stringify(record.tags || []) ||
nextOwnerId !== ownerId;
if (changed) {
summary.touched += 1;
reportRows.push({
id: record.id,
title: record.title,
ownerIdBefore: ownerId,
ownerIdAfter: nextOwnerId,
personalUserIds
});
if (mode === "migrate") {
await updateRecord(TASGRID_COLLECTION, record.id, {
user: nextOwnerId,
tags: nextTags,
labelTags,
shareRefs: nextShareRefs,
noteRefs
});
}
}
} catch (error) {
summary.failures += 1;
reportRows.push({
id: record.id,
title: record.title,
error: error instanceof Error ? error.message : String(error)
});
}
}
if (mode === "backup") {
const outputDir = await createOutputDir();
await Promise.all([
fs.writeFile(path.join(outputDir, "users.json"), JSON.stringify(users, null, 2)),
fs.writeFile(path.join(outputDir, "contexts.json"), JSON.stringify(contexts, null, 2)),
fs.writeFile(path.join(outputDir, "notes.json"), JSON.stringify(rawNotes, null, 2)),
fs.writeFile(path.join(outputDir, "tasks.json"), JSON.stringify(rawTasks, null, 2))
]);
console.log(JSON.stringify({ mode, dataMode, outputDir, counts: { users: users.length, contexts: contexts.length, notes: rawNotes.length, tasks: rawTasks.length } }, null, 2));
return;
}
if (mode === "report") {
const outputDir = await createOutputDir();
await fs.writeFile(path.join(outputDir, "context-ownership-report.json"), JSON.stringify({ summary, tasks: reportRows }, null, 2));
console.log(JSON.stringify({ mode, dataMode, outputDir, summary }, null, 2));
return;
}
console.log(JSON.stringify({ mode, dataMode, summary }, null, 2));
};
main().catch((error) => {
console.error(error);
process.exit(1);
});
+299 -55
View File
@@ -1,11 +1,12 @@
import { type Component, createSignal, onMount, onCleanup, createMemo, lazy, Suspense, Show } from 'solid-js';
import { type Component, createSignal, onMount, onCleanup, createMemo, createEffect, lazy, Suspense, Show, untrack } from 'solid-js';
import { Layout } from './components/Layout';
import { EmbedLayout } from './components/EmbedLayout';
import { EmbedAuthWrapper } from './components/EmbedAuthWrapper';
import { CriticalView } from './views/CriticalView';
import { AuthCallback } from './components/Auth';
import { pb } from './lib/pocketbase';
import { initStore } from './store';
import { initStore, setStore, store, advanceAIHelpTipSectionIndex } from './store';
import { generateAIHelpTip, hasFireworksApiKey } from './lib/ai-help';
// const CriticalView = lazy(() => import('./views/CriticalView').then(m => ({ default: m.CriticalView })));
const UrgencyView = lazy(() => import('./views/UrgencyView').then(m => ({ default: m.UrgencyView })));
@@ -16,25 +17,242 @@ const SnowballView = lazy(() => import('./views/SnowballView').then(m => ({ defa
const DigInView = lazy(() => import('./views/DigInView').then(m => ({ default: m.DigInView })));
const ProgressView = lazy(() => import('./views/ProgressView').then(m => ({ default: m.ProgressView })));
const HelpView = lazy(() => import('./views/HelpView').then(m => ({ default: m.HelpView })));
const AIHelpView = lazy(() => import('./views/AIHelpView').then(m => ({ default: m.AIHelpView })));
const NotepadView = lazy(() => import('./views/NotepadView').then(m => ({ default: m.NotepadView })));
const QuickEntryForm = lazy(() => import('./components/QuickEntryForm').then(m => ({ default: m.QuickEntryForm })));
const DEFAULT_VIEW = "critical";
const routeToView: Record<string, string> = {
critical: "critical",
focus: "critical",
urgency: "urgency",
priority: "priority",
matrix: "matrix",
snowball: "snowball",
"dig-in": "dig_in",
"dig_in": "dig_in",
progress: "progress",
settings: "settings",
help: "help",
ask: "help_ai",
"help-ai": "help_ai",
"help_ai": "help_ai",
};
const viewToRoute: Record<string, string> = {
critical: "focus",
urgency: "urgency",
priority: "priority",
matrix: "matrix",
snowball: "snowball",
dig_in: "dig-in",
progress: "progress",
settings: "settings",
help: "help",
help_ai: "ask",
};
const normalizeHash = (hash: string) => {
if (hash === "#help" || hash.startsWith("#help/")) {
return `#/${hash.slice(1)}`;
}
return hash;
};
const parseHashRoute = (hash: string): { view: string; sectionId?: string } | null => {
const normalizedHash = normalizeHash(hash);
if (!normalizedHash.startsWith("#/")) return null;
const routePath = normalizedHash.slice(2);
if (!routePath || routePath.startsWith("embed/")) return null;
const [viewSegment, ...rest] = routePath.split("/");
if (decodeURIComponent(viewSegment) === "notes") {
return {
view: "notes",
sectionId: rest.length > 0 ? decodeURIComponent(rest.join("/")) : undefined,
};
}
const view = routeToView[decodeURIComponent(viewSegment)];
if (!view) return null;
if (view === "help") {
return {
view,
sectionId: rest.length > 0 ? decodeURIComponent(rest.join("/")) : undefined,
};
}
return { view };
};
const getHashForView = (view: string, sectionId?: string) => {
if (view === "notes") {
return sectionId ? `#/notes/${encodeURIComponent(sectionId)}` : "#/notes";
}
const route = viewToRoute[view] || viewToRoute[DEFAULT_VIEW];
if (view === "help" && sectionId) {
return `#/${route}/${encodeURIComponent(sectionId)}`;
}
return `#/${route}`;
};
const App: Component = () => {
// Basic routing state
const [currentView, setCurrentView] = createSignal("critical");
const [currentView, setCurrentView] = createSignal(DEFAULT_VIEW);
const [lastNonAIView, setLastNonAIView] = createSignal(DEFAULT_VIEW);
const [isAuthenticated, setIsAuthenticated] = createSignal(pb.authStore.isValid);
const [authUserId, setAuthUserId] = createSignal<string | null>(pb.authStore.model?.id || null);
const [location, setLocation] = createSignal(window.location.href);
const [isDesktop, setIsDesktop] = createSignal(window.matchMedia("(min-width: 768px)").matches);
const [aiHelpTip, setAiHelpTip] = createSignal<string | null>(null);
const [tipGeneratedForUserId, setTipGeneratedForUserId] = createSignal<string | null>(null);
const [selectedNoteId, setSelectedNoteId] = createSignal<string | null>(null);
createEffect(() => {
const view = currentView();
if (view !== "help_ai") {
setLastNonAIView(view);
}
});
const effectiveView = createMemo(() =>
currentView() === "help_ai" && isDesktop() ? lastNonAIView() : currentView()
);
const syncNotesRoute = (noteId?: string | null, options?: { replace?: boolean }) => {
syncUrlToView("notes", { sectionId: noteId || undefined, replace: options?.replace });
};
const openNotes = (noteId?: string | null, options?: { replace?: boolean }) => {
setStore('isNotepadMode', true);
setSelectedNoteId(noteId || null);
syncNotesRoute(noteId, options);
};
const openTasks = (view?: string, options?: { replace?: boolean }) => {
setStore('isNotepadMode', false);
navigateToView(view || currentView(), options);
};
createEffect(() => {
const userId = authUserId();
const authenticated = isAuthenticated();
const desktop = isDesktop();
const initializing = store.isInitializing;
const hasApiKey = hasFireworksApiKey();
const alreadyGeneratedForUser = tipGeneratedForUserId() === userId;
const prefsLoadedForUser = !!userId && store.prefId === userId;
if (!authenticated || !userId || !desktop || initializing || !prefsLoadedForUser || !hasApiKey || alreadyGeneratedForUser) return;
setTipGeneratedForUserId(userId);
const sectionIndex = untrack(() => store.aiHelpTipSectionIndex);
void (async () => {
try {
const nextTip = await generateAIHelpTip(sectionIndex);
if (!nextTip) return;
setAiHelpTip(nextTip);
await advanceAIHelpTipSectionIndex();
} catch (err) {
console.warn("Tip generation skipped", err);
}
})();
});
const syncUrlToView = (view: string, options?: { sectionId?: string; replace?: boolean }) => {
if (isEmbed()) return;
const nextHash = getHashForView(view, options?.sectionId);
if (window.location.hash === nextHash) return;
if (options?.replace) {
const nextUrl = `${window.location.pathname}${window.location.search}${nextHash}`;
window.history.replaceState(window.history.state, "", nextUrl);
window.dispatchEvent(new HashChangeEvent("hashchange"));
return;
}
window.location.hash = nextHash;
};
const navigateToView = (view: string, options?: { sectionId?: string; replace?: boolean }) => {
setCurrentView(view);
syncUrlToView(view, options);
};
onMount(() => {
const handleLocChange = () => setLocation(window.location.href);
const mediaQuery = window.matchMedia("(min-width: 768px)");
const openHelpGuide = (sectionId?: string) => {
setStore('isNotepadMode', false);
navigateToView("help", { sectionId });
window.dispatchEvent(new CustomEvent("taskgrid:help-target", { detail: { sectionId } }));
};
const openAIHelp = () => {
setStore('isNotepadMode', false);
navigateToView("help_ai");
};
const handleMediaChange = (event: MediaQueryListEvent) => {
setIsDesktop(event.matches);
};
const handleLocChange = () => {
setLocation(window.location.href);
if (isEmbed()) return;
const routeState = parseHashRoute(window.location.hash);
if (routeState) {
if (routeState.view === "notes") {
setStore('isNotepadMode', true);
setSelectedNoteId(routeState.sectionId || null);
return;
}
setStore('isNotepadMode', false);
setCurrentView(routeState.view);
return;
}
const normalizedHash = normalizeHash(window.location.hash);
if (normalizedHash !== window.location.hash) {
const nextUrl = `${window.location.pathname}${window.location.search}${normalizedHash}`;
window.history.replaceState(window.history.state, "", nextUrl);
setLocation(window.location.href);
return;
}
if (!window.location.hash) {
if (store.isNotepadMode) {
syncNotesRoute(selectedNoteId(), { replace: true });
} else {
syncUrlToView(currentView(), { replace: true });
}
}
};
(window as any).setCurrentView = navigateToView;
(window as any).openTasGridHelp = openHelpGuide;
(window as any).openTasGridAIHelp = openAIHelp;
window.addEventListener('popstate', handleLocChange);
window.addEventListener('hashchange', handleLocChange);
mediaQuery.addEventListener('change', handleMediaChange);
let isFirstRun = true;
const removeListener = pb.authStore.onChange((token) => {
console.log('[DEBUG] pb.authStore.onChange fired, token present:', !!token);
const wasAuthenticated = isAuthenticated();
const previousUserId = authUserId();
const nextUserId = token ? (pb.authStore.model?.id || null) : null;
setIsAuthenticated(!!token);
setAuthUserId(nextUserId);
if (!token || nextUserId !== previousUserId) {
setTipGeneratedForUserId(null);
setAiHelpTip(null);
}
if (token && (isFirstRun || !wasAuthenticated)) {
initStore();
@@ -44,8 +262,12 @@ const App: Component = () => {
onCleanup(() => {
removeListener();
delete (window as any).setCurrentView;
delete (window as any).openTasGridHelp;
delete (window as any).openTasGridAIHelp;
window.removeEventListener('popstate', handleLocChange);
window.removeEventListener('hashchange', handleLocChange);
mediaQuery.removeEventListener('change', handleMediaChange);
});
// Background preload of other views when idle
@@ -60,6 +282,7 @@ const App: Component = () => {
import('./views/DigInView');
import('./views/ProgressView');
import('./views/HelpView');
import('./views/AIHelpView');
// Remove splash screen after high priority work is done
const splash = document.getElementById('splash');
@@ -68,6 +291,8 @@ const App: Component = () => {
setTimeout(() => splash.remove(), 500);
}
});
handleLocChange();
});
// Determine if we are in an embed route (check both path and hash for compatibility)
@@ -76,9 +301,7 @@ const App: Component = () => {
const url = new URL(loc);
const path = url.pathname;
const hash = url.hash;
const res = path.startsWith('/embed/') || hash.startsWith('#/embed/');
console.log('[DEBUG] isEmbed check:', { res, path, hash, href: loc });
return res;
return path.startsWith('/embed/') || hash.startsWith('#/embed/');
};
const getEmbedView = () => {
@@ -88,57 +311,78 @@ const App: Component = () => {
let view = null;
if (fullPath.includes('/notes')) view = 'embed_notes';
else if (fullPath.includes('/quick-add')) view = 'embed_quick_add';
console.log('[DEBUG] getEmbedView detected:', view, 'Full URL context:', fullPath);
return view;
};
return (
<Show
when={isEmbed()}
fallback={
<Show when={isAuthenticated()} fallback={<AuthCallback onSuccess={() => { }} />}>
<Layout currentView={currentView()} setView={setCurrentView}>
<Suspense fallback={<div class="flex items-center justify-center h-full"><div class="animate-spin rounded-full h-8 w-8 border-b-2 border-primary"></div></div>}>
<Show when={currentView() === "critical"}><CriticalView /></Show>
<Show when={currentView() === "urgency"}><UrgencyView /></Show>
<Show when={currentView() === "priority"}><PriorityView /></Show>
<Show when={currentView() === "matrix"}><MatrixView /></Show>
<Show when={currentView() === "snowball"}><SnowballView /></Show>
<Show when={currentView() === "dig_in"}><DigInView /></Show>
<Show when={currentView() === "progress"}><ProgressView /></Show>
<Show when={currentView() === "settings"}><SettingsView setView={setCurrentView} /></Show>
<Show when={currentView() === "help"}><HelpView onBack={() => setCurrentView("settings")} /></Show>
</Suspense>
</Layout>
return (
<Show
when={isEmbed()}
fallback={
<Show when={isAuthenticated()} fallback={<AuthCallback onSuccess={() => { }} />}>
<Layout
currentView={effectiveView()}
setView={navigateToView}
selectedNoteId={selectedNoteId()}
setSelectedNoteId={setSelectedNoteId}
onOpenTasks={openTasks}
onOpenNotes={openNotes}
isAIHelpOpen={currentView() === "help_ai" && isDesktop()}
onToggleAIHelp={() => navigateToView(currentView() === "help_ai" ? lastNonAIView() : "help_ai")}
aiHelpTip={aiHelpTip()}
hideQuickEntry={currentView() === "help_ai" && !isDesktop()}
>
<Suspense fallback={<div class="flex items-center justify-center h-full"><div class="animate-spin rounded-full h-8 w-8 border-b-2 border-primary"></div></div>}>
<Show when={effectiveView() === "critical"}><CriticalView /></Show>
<Show when={effectiveView() === "urgency"}><UrgencyView /></Show>
<Show when={effectiveView() === "priority"}><PriorityView /></Show>
<Show when={effectiveView() === "matrix"}><MatrixView /></Show>
<Show when={effectiveView() === "snowball"}><SnowballView /></Show>
<Show when={effectiveView() === "dig_in"}><DigInView /></Show>
<Show when={effectiveView() === "progress"}><ProgressView /></Show>
<Show when={effectiveView() === "settings"}><SettingsView setView={navigateToView} /></Show>
<Show when={effectiveView() === "help_ai"}><AIHelpView /></Show>
<Show when={effectiveView() === "help"}><HelpView onBack={() => navigateToView("settings")} /></Show>
</Suspense>
</Layout>
</Show>
}
>
{(() => {
const isPublicNote = () => {
const view = getEmbedView();
const urlObj = new URL(location());
const noteId = new URLSearchParams(urlObj.search).get('noteId') ||
(urlObj.hash.includes('?') ? new URLSearchParams(urlObj.hash.substring(urlObj.hash.indexOf('?'))).get('noteId') : null);
return view === 'embed_notes' && !!noteId;
};
return (
<EmbedAuthWrapper skipAuth={isPublicNote()}>
<EmbedLayout>
<Suspense fallback={<div class="flex items-center justify-center h-full"><div class="animate-spin rounded-full h-8 w-8 border-b-2 border-primary"></div></div>}>
<Show when={getEmbedView() === 'embed_notes'}>
{(() => {
const noteIdFromUrl = createMemo(() => {
const urlObj = new URL(location());
let noteId = new URLSearchParams(urlObj.search).get('noteId');
if (!noteId && urlObj.hash.includes('?')) {
const hashQueryString = urlObj.hash.substring(urlObj.hash.indexOf('?'));
noteId = new URLSearchParams(hashQueryString).get('noteId');
}
return noteId;
});
return <NotepadView selectedNoteId={noteIdFromUrl()} setSelectedNoteId={() => { }} hideNavigation={!!noteIdFromUrl()} />;
})()}
</Show>
<Show when={getEmbedView() === 'embed_quick_add'}>
<QuickEntryForm autoFocus={true} />
</Show>
</Suspense>
</EmbedLayout>
</EmbedAuthWrapper>
);
})()}
</Show>
}
>
<EmbedAuthWrapper>
<EmbedLayout>
<Suspense fallback={<div class="flex items-center justify-center h-full"><div class="animate-spin rounded-full h-8 w-8 border-b-2 border-primary"></div></div>}>
<Show when={getEmbedView() === 'embed_notes'}>
{(() => {
const noteIdFromUrl = createMemo(() => {
const urlObj = new URL(location());
let noteId = new URLSearchParams(urlObj.search).get('noteId');
// Production likely uses hash routing, so check hash for query params too
if (!noteId && urlObj.hash.includes('?')) {
const hashQueryString = urlObj.hash.substring(urlObj.hash.indexOf('?'));
noteId = new URLSearchParams(hashQueryString).get('noteId');
}
return noteId;
});
console.log('[DEBUG App] Rendering embed/notes context. noteId trace:', noteIdFromUrl());
return <NotepadView selectedNoteId={noteIdFromUrl()} setSelectedNoteId={() => { }} hideNavigation={!!noteIdFromUrl()} />;
})()}
</Show>
<Show when={getEmbedView() === 'embed_quick_add'}>
<QuickEntryForm autoFocus={true} />
</Show>
</Suspense>
</EmbedLayout>
</EmbedAuthWrapper>
</Show>
);
};
+281
View File
@@ -0,0 +1,281 @@
import { type Component, For, Show, createEffect, createMemo, createSignal } from "solid-js";
import { MessageCircle, LoaderCircle, RefreshCw, ArrowUp } from "lucide-solid";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
import { FIREWORKS_HELP_MODEL } from "@/lib/app-config";
import { askAIHelp, hasFireworksApiKey } from "@/lib/ai-help";
import { MarkdownMessage } from "./MarkdownMessage";
type ChatMessage = {
role: "user" | "assistant";
content: string;
};
const SUGGESTED_PROMPTS = [
"How does sharing work in TasGrid?",
"What is the difference between Handoff and Collaborative?",
"How do linked tasks and notes work together?",
"Why might a task disappear from my view?",
"How do recurrence and templates behave?"
];
const INITIAL_MESSAGE = "Ask a question about how TasGrid works.";
export const AIHelpPanel: Component<{
variant?: "page" | "sidebar";
class?: string;
}> = (props) => {
const variant = () => props.variant ?? "page";
const [messages, setMessages] = createSignal<ChatMessage[]>([
{ role: "assistant", content: INITIAL_MESSAGE }
]);
const [input, setInput] = createSignal("");
const [isLoading, setIsLoading] = createSignal(false);
const [error, setError] = createSignal<string | null>(null);
let messagesViewport: HTMLDivElement | undefined;
const hasApiKey = createMemo(() => hasFireworksApiKey());
const showSuggestions = createMemo(() =>
hasApiKey() && messages().length === 1 && messages()[0]?.role === "assistant"
);
createEffect(() => {
messages();
isLoading();
queueMicrotask(() => {
messagesViewport?.scrollTo({
top: messagesViewport.scrollHeight,
behavior: "smooth"
});
});
});
const submitQuestion = async (questionOverride?: string) => {
const question = (questionOverride ?? input()).trim();
if (!question || isLoading()) return;
const priorMessages = messages();
const userMessage: ChatMessage = { role: "user", content: question };
const nextMessages = [...priorMessages, userMessage];
setMessages(nextMessages);
setInput("");
setError(null);
setIsLoading(true);
if (!hasApiKey()) {
setMessages([
...nextMessages,
{
role: "assistant",
content: "Questions are not configured yet. Add `VITE_FIREWORKS_API_KEY` to the app environment to enable responses."
}
]);
setIsLoading(false);
return;
}
try {
const assistantText = await askAIHelp(nextMessages);
setMessages(prev => [...prev, { role: "assistant", content: assistantText }]);
} catch (err) {
const message = err instanceof Error ? err.message : "Unknown error";
setError(message);
setMessages(prev => [
...prev,
{
role: "assistant",
content: "I couldn't get a response from the help model just now. Try again in a moment."
}
]);
} finally {
setIsLoading(false);
}
};
const resetChat = () => {
setMessages([{ role: "assistant", content: INITIAL_MESSAGE }]);
setInput("");
setError(null);
};
const isSidebar = () => variant() === "sidebar";
return (
<div
class={cn(
"flex min-h-0 flex-col overflow-hidden",
isSidebar()
? "rounded-2xl border border-border bg-card"
: "h-full rounded-2xl border border-border bg-card",
props.class
)}
>
<div class={cn(
"flex items-center justify-between gap-3 border-b border-border",
isSidebar() ? "px-3 py-3" : "px-4 py-4 sm:px-5"
)}>
<div class="flex items-center gap-2 min-w-0">
<MessageCircle size={16} class="text-primary shrink-0" />
<h1 class={cn("font-semibold tracking-tight", isSidebar() ? "text-sm" : "text-lg")}>Ask Questions</h1>
</div>
<Button
variant="ghost"
size="sm"
class="gap-2 rounded-lg shrink-0"
onClick={resetChat}
>
<RefreshCw size={13} />
New
</Button>
</div>
<div class={cn(
"flex min-h-0 flex-1 flex-col",
isSidebar() ? "p-3 gap-3" : "p-4 gap-3 sm:p-5"
)}>
<Show when={!hasApiKey()}>
<div class={cn(
"rounded-2xl border border-amber-500/30 bg-amber-500/10",
isSidebar() ? "p-3 text-[0.75rem]" : "p-4 text-sm"
)}>
Add `VITE_FIREWORKS_API_KEY` to enable Ask Questions. The view is wired to Fireworks model `{FIREWORKS_HELP_MODEL}`.
</div>
</Show>
<div
ref={messagesViewport}
class={cn(
"min-h-0 flex-1 overflow-y-auto space-y-3",
isSidebar() ? "px-0.5" : "px-0.5"
)}
>
<For each={messages()}>
{(message) => (
<div class="space-y-2">
<div
class={cn(
"flex",
message.role === "user"
? "justify-center pt-8 pb-0"
: "justify-start"
)}
>
<div
class={cn(
"rounded-2xl px-4 leading-relaxed whitespace-pre-wrap shadow-sm",
message.role === "assistant"
? "w-full bg-card border border-border px-4 py-3 text-foreground"
: cn(
"bg-card px-4 pt-3 pb-0 text-foreground",
isSidebar() ? "max-w-[92%]" : "max-w-[90%] sm:max-w-[80%]"
)
)}
>
<Show when={message.role === "assistant"}>
<div class="mb-1 flex items-center gap-2 text-[0.625rem] font-black uppercase tracking-[0.18em] opacity-70">
<MessageCircle size={12} />
<span>TasGrid Help</span>
</div>
</Show>
<MarkdownMessage
content={message.content}
class=""
/>
</div>
</div>
<Show when={showSuggestions() && message.role === "assistant"}>
<div class={cn("flex flex-wrap gap-2", isSidebar() ? "pl-1" : "pl-1")}>
<For each={SUGGESTED_PROMPTS}>
{(prompt) => (
<button
class={cn(
"rounded-full border border-border bg-card text-left font-medium text-foreground transition-colors hover:bg-muted",
isSidebar() ? "px-2.5 py-1.5 text-[0.6875rem]" : "px-3 py-2 text-xs sm:text-sm"
)}
onClick={() => void submitQuestion(prompt)}
disabled={isLoading()}
>
{prompt}
</button>
)}
</For>
</div>
</Show>
</div>
)}
</For>
<Show when={isLoading()}>
<div class="flex justify-start">
<div class={cn(
"rounded-2xl px-4 py-3 leading-relaxed shadow-sm bg-card border border-border text-foreground",
isSidebar() ? "max-w-[92%] text-sm" : "max-w-[80%] text-sm"
)}>
<div class="mb-1 flex items-center gap-2 text-[0.625rem] font-black uppercase tracking-[0.18em] opacity-70">
<MessageCircle size={12} />
<span>TasGrid Help</span>
</div>
<div class="flex items-center gap-2 text-muted-foreground">
<LoaderCircle size={14} class="animate-spin" />
Finding an answer...
</div>
</div>
</div>
</Show>
</div>
<Show when={error()}>
{(message) => (
<div class={cn(
"rounded-xl border border-destructive/30 bg-destructive/10 text-destructive",
isSidebar() ? "px-3 py-2 text-[0.75rem]" : "px-4 py-3 text-sm"
)}>
Request error: {message()}
</div>
)}
</Show>
<form
class={cn(
"space-y-2"
)}
onSubmit={(e) => {
e.preventDefault();
void submitQuestion();
}}
>
<div class="relative">
<textarea
value={input()}
onInput={(e) => setInput(e.currentTarget.value)}
onKeyDown={(e) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
void submitQuestion();
}
}}
placeholder="Ask how a TasGrid feature works, what a setting does, or why something may behave a certain way..."
class={cn(
"flex w-full rounded-xl border border-input bg-background transition-colors placeholder:text-muted-foreground/60 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring resize-none leading-relaxed",
isSidebar()
? "min-h-[88px] px-3 py-2.5 pr-14 text-sm"
: "min-h-[96px] px-3 py-2.5 pr-14 text-sm"
)}
/>
<Button
type="submit"
size="icon"
class="absolute bottom-3 right-3 h-8 w-8 rounded-full shadow-sm"
disabled={isLoading() || !input().trim()}
aria-label="Ask Question"
title="Ask Question"
>
<ArrowUp size={15} />
</Button>
</div>
</form>
</div>
</div>
);
};
+5 -3
View File
@@ -3,12 +3,14 @@ import { pb } from "@/lib/pocketbase";
interface EmbedAuthWrapperProps {
children: JSX.Element;
skipAuth?: boolean;
}
export const EmbedAuthWrapper: Component<EmbedAuthWrapperProps> = (props) => {
const [isHydrated, setIsHydrated] = createSignal(pb.authStore.isValid);
const [isHydrated, setIsHydrated] = createSignal(pb.authStore.isValid || !!props.skipAuth);
const handleMessage = (event: MessageEvent) => {
if (props.skipAuth) return; // Don't process auth messages in skip mode
const { data } = event;
console.log('[DEBUG] EmbedAuthWrapper received message:', data?.type);
if (data?.type === "TASGRID_AUTH" && data.token) {
@@ -20,7 +22,7 @@ export const EmbedAuthWrapper: Component<EmbedAuthWrapperProps> = (props) => {
};
onMount(() => {
console.log('[DEBUG] EmbedAuthWrapper mounted. Initial isHydrated:', isHydrated());
console.log('[DEBUG] EmbedAuthWrapper mounted. Initial isHydrated:', isHydrated(), 'skipAuth:', props.skipAuth);
window.addEventListener("message", handleMessage);
});
@@ -30,7 +32,7 @@ export const EmbedAuthWrapper: Component<EmbedAuthWrapperProps> = (props) => {
return (
<Show
when={isHydrated()}
when={isHydrated() || props.skipAuth}
fallback={
<div class="flex flex-col items-center justify-center h-full space-y-4">
<div class="animate-spin rounded-full h-8 w-8 border-b-2 border-primary"></div>
+6
View File
@@ -11,6 +11,12 @@ export const EmbedLayout: Component<EmbedLayoutProps> = (props) => {
<div class="w-full h-full mx-auto p-4">
{props.children}
</div>
<div
class="md:hidden shrink-0"
style={{
height: "calc(8px + env(safe-area-inset-bottom, 0px))"
}}
/>
</main>
</div>
);
+412 -141
View File
@@ -1,29 +1,70 @@
import { type Component, createSignal, For, Show } from "solid-js";
import { Search, X, Tag, ArrowUpCircle, Clock, Minus, Save, Trash2, Bookmark } from "lucide-solid";
import { store, setFilter, clearFilter, saveFilterTemplate, applyFilterTemplate, removeFilterTemplate, loadAllHistory } from "@/store";
import { Search, X, Tag, ArrowUpCircle, Clock, Minus, Save, Trash2, Bookmark, User, Star, SlidersHorizontal } from "lucide-solid";
import { store, setFilter, clearFilter, saveFilterTemplate, applyFilterTemplate, removeFilterTemplate, loadAllHistory, setNoteFilter, clearNoteFilter, saveNoteFilterTemplate, applyNoteFilterTemplate, removeNoteFilterTemplate, type Filter } from "@/store";
import { Button } from "./ui/button";
import { cn } from "@/lib/utils";
import { Badge } from "./ui/badge";
import { Select, SelectContent, SelectItem, SelectTrigger } from "./ui/select";
import { Command, CommandInput, CommandList, CommandEmpty, CommandGroup, CommandItem } from "./ui/command";
import { Popover, PopoverContent, PopoverTrigger } from "./ui/popover";
import { useTheme } from "./ThemeProvider";
import { getThemeAdjustedColor } from "@/lib/colors";
export const FilterBar: Component = () => {
export const FilterBar: Component<{ mode?: 'tasks' | 'notes', class?: string, triggerClass?: string }> = (props) => {
const { resolvedTheme } = useTheme();
const [isOpen, setIsOpen] = createSignal(false);
const [isTagOpen, setIsTagOpen] = createSignal(false);
const [tagSearch, setTagSearch] = createSignal("");
const [newTemplateName, setNewTemplateName] = createSignal("");
const [isSaving, setIsSaving] = createSignal(false);
const [isJobStatusOpen, setIsJobStatusOpen] = createSignal(false);
const [isJobDivisionOpen, setIsJobDivisionOpen] = createSignal(false);
const mode = () => props.mode || 'tasks';
const currentFilter = () => mode() === 'tasks' ? store.filter : store.noteFilter;
const currentSetFilter = (val: Partial<Filter>) => mode() === 'tasks' ? setFilter(val) : setNoteFilter(val);
const currentClearFilter = () => mode() === 'tasks' ? clearFilter() : clearNoteFilter();
const currentSaveTemplate = (name: string) => mode() === 'tasks' ? saveFilterTemplate(name) : saveNoteFilterTemplate(name);
const currentApplyTemplate = (id: string) => mode() === 'tasks' ? applyFilterTemplate(id) : applyNoteFilterTemplate(id);
const currentRemoveTemplate = (id: string) => mode() === 'tasks' ? removeFilterTemplate(id) : removeNoteFilterTemplate(id);
const hasActiveFilters = () => {
const f = store.filter;
return f.query !== "" || f.tags.length > 0 || f.priorityMin !== 1 || f.priorityMax !== 10 || f.urgencyMin !== 1 || f.urgencyMax !== 10 || f.editedToday;
const f = currentFilter();
const baseActive = (f.query || "") !== "" || (f.tags || []).length > 0 || f.editedToday || f.ownedByMe || f.starred;
const jobActive = (f.jobStatus?.length || 0) > 0 || (f.jobDivision?.length || 0) > 0;
if (mode() === 'tasks') {
return baseActive || f.priorityMin !== 1 || f.priorityMax !== 10 || f.urgencyMin !== 1 || f.urgencyMax !== 10;
}
return baseActive || jobActive;
};
const allTags = () => store.tagDefinitions.map(d => d.name).sort();
const JOB_STATUSES = [
"Estimating",
"Est Sent",
"Est Hold",
"Follow Up",
"Awarded",
"In Progress",
"On Hold",
"Billed (In Progress)",
"Billed (Closed)",
"Archive",
"Not Awarded",
"Not Bidding"
];
const JOB_DIVISIONS = ["C#", "R#", "S#"];
const normalizeFilterTags = (items: Array<string | { name: string; excluded: boolean }> | undefined) => {
if (!items) return [] as { name: string; excluded: boolean }[];
return items.map((i: any) => {
if (typeof i === "string") return { name: i, excluded: false };
return { name: i.name, excluded: !!i.excluded };
});
};
return (
<div class="fixed top-4 right-5 sm:right-6 z-[60]">
<div class={cn(mode() === 'tasks' ? "fixed top-4 right-5 sm:right-6 z-[60]" : "relative", props.class)}>
<Popover open={isOpen()} onOpenChange={setIsOpen}>
<PopoverTrigger
as={Button}
@@ -32,18 +73,34 @@ export const FilterBar: Component = () => {
class={cn(
"bg-background/80 backdrop-blur-sm border border-border hover:bg-muted shadow-sm transition-all duration-300",
hasActiveFilters() && "border-primary/50 ring-1 ring-primary/20",
isOpen() && "bg-muted ring-1 ring-border"
isOpen() && "bg-muted ring-1 ring-border",
props.triggerClass
)}
title="Filters & Search"
title={mode() === 'tasks' ? "Filters & Search" : "Filters"}
>
<div class="relative">
<Search size={18} class={cn("transition-transform duration-300", isOpen() && "scale-110")} />
{hasActiveFilters() && !isOpen() && (
<div class="absolute -top-1 -right-1 w-2 h-2 rounded-full bg-primary animate-pulse border border-background" />
{mode() === 'tasks' ? (
<Search
size={18}
class={cn(
"transition-transform duration-300",
isOpen() && "scale-110",
hasActiveFilters() && "text-primary"
)}
/>
) : (
<SlidersHorizontal
size={18}
class={cn(
"transition-transform duration-300",
isOpen() && "scale-110",
hasActiveFilters() && "text-primary"
)}
/>
)}
</div>
</PopoverTrigger>
<PopoverContent class="w-[calc(100vw-2rem)] sm:w-[480px] p-0 overflow-hidden bg-card border-border shadow-2xl rounded-2xl animate-in fade-in zoom-in-95 duration-200">
<PopoverContent class="w-[calc(100vw-2rem)] sm:w-[480px] p-0 overflow-hidden bg-card border-border shadow-2xl rounded-2xl animate-in fade-in zoom-in-95 duration-200 z-[100]">
{/* Header */}
<div class="px-4 py-3 border-b border-border/50 bg-muted/20 flex items-center justify-between">
<div class="flex items-center gap-4">
@@ -53,37 +110,63 @@ export const FilterBar: Component = () => {
</h3>
<div class="h-4 w-px bg-border/50" />
<button
onClick={() => setFilter({ editedToday: !store.filter.editedToday })}
onClick={() => currentSetFilter({ editedToday: !currentFilter().editedToday })}
class={cn(
"flex items-center gap-1.5 px-2 py-0.5 rounded-full text-[0.5625rem] font-black uppercase tracking-tight transition-all border",
store.filter.editedToday
currentFilter().editedToday
? "bg-primary/10 text-primary border-primary/20"
: "bg-muted/50 text-muted-foreground border-transparent hover:bg-muted"
)}
>
<div class={cn("w-1.5 h-1.5 rounded-full", store.filter.editedToday ? "bg-primary" : "bg-muted-foreground/30")} />
<div class={cn("w-1.5 h-1.5 rounded-full", currentFilter().editedToday ? "bg-primary" : "bg-muted-foreground/30")} />
Edited Today
</button>
<button
onClick={() => currentSetFilter({ ownedByMe: !currentFilter().ownedByMe })}
class={cn(
"flex items-center gap-1.5 px-2 py-0.5 rounded-full text-[0.5625rem] font-black uppercase tracking-tight transition-all border",
currentFilter().ownedByMe
? "bg-primary/10 text-primary border-primary/20"
: "bg-muted/50 text-muted-foreground border-transparent hover:bg-muted"
)}
>
<User size={10} class={cn(currentFilter().ownedByMe ? "text-primary" : "text-muted-foreground/50")} />
Mine
</button>
<button
onClick={() => currentSetFilter({ starred: !currentFilter().starred })}
class={cn(
"flex items-center gap-1.5 px-2 py-0.5 rounded-full text-[0.5625rem] font-black uppercase tracking-tight transition-all border",
currentFilter().starred
? "bg-amber-500/10 text-amber-500 border-amber-500/20"
: "bg-muted/50 text-muted-foreground border-transparent hover:bg-muted"
)}
>
<Star size={10} class={cn(currentFilter().starred ? "fill-amber-500 text-amber-500" : "text-muted-foreground/50")} />
Starred
</button>
</div>
<div class="flex items-center gap-2">
<Button
variant="ghost"
size="sm"
class="h-7 px-2 text-[0.625rem] font-bold uppercase tracking-wider text-muted-foreground hover:text-primary"
onClick={() => {
loadAllHistory();
setIsOpen(false);
}}
title="Load all completed tasks"
>
Load All
</Button>
<Show when={mode() === 'tasks'}>
<Button
variant="ghost"
size="sm"
class="h-7 px-2 text-[0.625rem] font-bold uppercase tracking-wider text-muted-foreground hover:text-primary"
onClick={() => {
loadAllHistory();
setIsOpen(false);
}}
title="Load all completed tasks"
>
Load All
</Button>
</Show>
<Show when={hasActiveFilters()}>
<Button
variant="ghost"
size="sm"
class="h-7 px-2 text-[0.625rem] font-bold uppercase tracking-wider text-muted-foreground hover:text-destructive"
onClick={clearFilter}
onClick={currentClearFilter}
>
Clear
</Button>
@@ -101,100 +184,257 @@ export const FilterBar: Component = () => {
{/* Controls */}
<div class="p-4 space-y-6">
{/* Search Input */}
<div class="space-y-2">
<div class="relative group">
<Search size={16} class="absolute left-3 top-1/2 -translate-y-1/2 text-muted-foreground/50 group-focus-within:text-primary transition-colors" />
<input
ref={(el) => {
if (el) setTimeout(() => el.focus(), 50);
}}
type="text"
placeholder="Search tasks, descriptions, tags..."
value={store.filter.query}
onInput={(e) => setFilter({ query: e.currentTarget.value })}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault();
setIsOpen(false);
}
}}
class="w-full bg-muted/30 border border-border/50 rounded-xl pl-10 pr-4 py-2.5 text-sm outline-none focus:ring-2 focus:ring-primary/20 focus:border-primary/40 transition-all placeholder:text-muted-foreground/30"
/>
</div>
</div>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-6">
{/* Priority Range */}
<div class="space-y-2.5">
<label class="text-[0.625rem] font-black uppercase tracking-widest text-muted-foreground ml-1">Priority Range</label>
<div class="flex items-center gap-2 bg-muted/20 p-1 rounded-xl border border-border/30">
<Select
value={store.filter.priorityMin.toString()}
onChange={(val) => val && setFilter({ priorityMin: parseInt(val) })}
options={["1", "2", "3", "4", "5", "6", "7", "8", "9", "10"]}
itemComponent={(props) => <SelectItem item={props.item}>{props.item.rawValue}</SelectItem>}
>
<SelectTrigger class="h-9 flex-1 bg-transparent border-none shadow-none focus:ring-0 text-xs font-bold text-foreground">
<div class="flex items-center gap-2 justify-center">
<ArrowUpCircle size={14} class="text-muted-foreground/70" />
<span>Min: {store.filter.priorityMin}</span>
</div>
</SelectTrigger>
<SelectContent />
</Select>
<div class="w-px h-4 bg-border/50" />
<Select
value={store.filter.priorityMax.toString()}
onChange={(val) => val && setFilter({ priorityMax: parseInt(val) })}
options={["1", "2", "3", "4", "5", "6", "7", "8", "9", "10"]}
itemComponent={(props) => <SelectItem item={props.item}>{props.item.rawValue}</SelectItem>}
>
<SelectTrigger class="h-9 flex-1 bg-transparent border-none shadow-none focus:ring-0 text-xs font-bold text-foreground">
<div class="flex items-center gap-2 justify-center">
<span>Max: {store.filter.priorityMax}</span>
</div>
</SelectTrigger>
<SelectContent />
</Select>
<Show when={mode() === 'tasks'}>
<div class="space-y-2">
<div class="relative group">
<Search size={16} class="absolute left-3 top-1/2 -translate-y-1/2 text-muted-foreground/50 group-focus-within:text-primary transition-colors" />
<input
ref={(el) => {
if (el) setTimeout(() => el.focus(), 50);
}}
type="text"
placeholder="Search tasks, descriptions, tags..."
value={currentFilter().query}
onInput={(e) => currentSetFilter({ query: e.currentTarget.value })}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault();
setIsOpen(false);
}
}}
class="w-full bg-muted/30 border border-border/50 rounded-xl pl-10 pr-4 py-2.5 text-sm outline-none focus:ring-2 focus:ring-primary/20 focus:border-primary/40 transition-all placeholder:text-muted-foreground/30"
/>
</div>
</div>
</Show>
{/* Urgency Range */}
<div class="space-y-2.5">
<label class="text-[0.625rem] font-black uppercase tracking-widest text-muted-foreground ml-1">Urgency Range</label>
<div class="flex items-center gap-2 bg-muted/20 p-1 rounded-xl border border-border/30">
<Select
value={store.filter.urgencyMin.toString()}
onChange={(val) => val && setFilter({ urgencyMin: parseInt(val) })}
options={["1", "2", "3", "4", "5", "6", "7", "8", "9", "10"]}
itemComponent={(props) => <SelectItem item={props.item}>{props.item.rawValue}</SelectItem>}
>
<SelectTrigger class="h-9 flex-1 bg-transparent border-none shadow-none focus:ring-0 text-xs font-bold text-foreground">
<div class="flex items-center gap-2 justify-center">
<Clock size={14} class="text-muted-foreground/70" />
<span>Min: {store.filter.urgencyMin}</span>
</div>
</SelectTrigger>
<SelectContent />
</Select>
<div class="w-px h-4 bg-border/50" />
<Select
value={store.filter.urgencyMax.toString()}
onChange={(val) => val && setFilter({ urgencyMax: parseInt(val) })}
options={["1", "2", "3", "4", "5", "6", "7", "8", "9", "10"]}
itemComponent={(props) => <SelectItem item={props.item}>{props.item.rawValue}</SelectItem>}
>
<SelectTrigger class="h-9 flex-1 bg-transparent border-none shadow-none focus:ring-0 text-xs font-bold text-foreground">
<div class="flex items-center gap-2 justify-center">
<span>Max: {store.filter.urgencyMax}</span>
</div>
</SelectTrigger>
<SelectContent />
</Select>
<Show when={mode() === 'tasks'}>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-6">
{/* Priority Range */}
<div class="space-y-2.5">
<label class="text-[0.625rem] font-black uppercase tracking-widest text-muted-foreground ml-1">Priority Range</label>
<div class="flex items-center gap-2 bg-muted/20 p-1 rounded-xl border border-border/30">
<Select
value={store.filter.priorityMin.toString()}
onChange={(val) => val && setFilter({ priorityMin: parseInt(val) })}
options={["1", "2", "3", "4", "5", "6", "7", "8", "9", "10"]}
itemComponent={(props) => <SelectItem item={props.item}>{props.item.rawValue}</SelectItem>}
>
<SelectTrigger class="h-9 flex-1 bg-transparent border-none shadow-none focus:ring-0 text-xs font-bold text-foreground">
<div class="flex items-center gap-2 justify-center">
<ArrowUpCircle size={14} class="text-muted-foreground/70" />
<span>Min: {store.filter.priorityMin}</span>
</div>
</SelectTrigger>
<SelectContent />
</Select>
<div class="w-px h-4 bg-border/50" />
<Select
value={store.filter.priorityMax.toString()}
onChange={(val) => val && setFilter({ priorityMax: parseInt(val) })}
options={["1", "2", "3", "4", "5", "6", "7", "8", "9", "10"]}
itemComponent={(props) => <SelectItem item={props.item}>{props.item.rawValue}</SelectItem>}
>
<SelectTrigger class="h-9 flex-1 bg-transparent border-none shadow-none focus:ring-0 text-xs font-bold text-foreground">
<div class="flex items-center gap-2 justify-center">
<span>Max: {store.filter.priorityMax}</span>
</div>
</SelectTrigger>
<SelectContent />
</Select>
</div>
</div>
{/* Urgency Range */}
<div class="space-y-2.5">
<label class="text-[0.625rem] font-black uppercase tracking-widest text-muted-foreground ml-1">Urgency Range</label>
<div class="flex items-center gap-2 bg-muted/20 p-1 rounded-xl border border-border/30">
<Select
value={store.filter.urgencyMin.toString()}
onChange={(val) => val && setFilter({ urgencyMin: parseInt(val) })}
options={["1", "2", "3", "4", "5", "6", "7", "8", "9", "10"]}
itemComponent={(props) => <SelectItem item={props.item}>{props.item.rawValue}</SelectItem>}
>
<SelectTrigger class="h-9 flex-1 bg-transparent border-none shadow-none focus:ring-0 text-xs font-bold text-foreground">
<div class="flex items-center gap-2 justify-center">
<Clock size={14} class="text-muted-foreground/70" />
<span>Min: {store.filter.urgencyMin}</span>
</div>
</SelectTrigger>
<SelectContent />
</Select>
<div class="w-px h-4 bg-border/50" />
<Select
value={store.filter.urgencyMax.toString()}
onChange={(val) => val && setFilter({ urgencyMax: parseInt(val) })}
options={["1", "2", "3", "4", "5", "6", "7", "8", "9", "10"]}
itemComponent={(props) => <SelectItem item={props.item}>{props.item.rawValue}</SelectItem>}
>
<SelectTrigger class="h-9 flex-1 bg-transparent border-none shadow-none focus:ring-0 text-xs font-bold text-foreground">
<div class="flex items-center gap-2 justify-center">
<span>Max: {store.filter.urgencyMax}</span>
</div>
</SelectTrigger>
<SelectContent />
</Select>
</div>
</div>
</div>
</div>
</Show>
<Show when={mode() === 'notes'}>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-6">
<div class="space-y-2.5">
<label class="text-[0.625rem] font-black uppercase tracking-widest text-muted-foreground ml-1">Job Status</label>
<div class="flex flex-wrap gap-2 min-h-[44px] p-2 bg-muted/10 border border-dashed border-border/60 rounded-xl">
<For each={normalizeFilterTags(currentFilter().jobStatus as any)}>
{(status) => (
<Badge
variant="secondary"
class={cn(
"h-7 px-2.5 rounded-lg text-xs gap-2 transition-all border shadow-sm",
status.excluded
? "bg-red-500/10 text-red-600 border-red-500/20 hover:bg-red-500/20"
: "bg-blue-500/10 text-blue-600 border-blue-500/20 hover:bg-blue-500/20"
)}
>
<div
class="flex items-center gap-2 cursor-pointer h-full"
onClick={() => {
const current = normalizeFilterTags(currentFilter().jobStatus as any);
const next = current.map(s =>
s.name === status.name ? { ...s, excluded: !s.excluded } : s
);
currentSetFilter({ jobStatus: next as any });
}}
>
<Show when={status.excluded} fallback={<Tag size={12} class="opacity-70" />}>
<Minus size={12} class="opacity-100" />
</Show>
<span class={cn(status.excluded && "line-through opacity-70")}>{status.name}</span>
</div>
<button
class="p-0.5 hover:bg-foreground/10 rounded transition-colors"
onClick={(e) => {
e.stopPropagation();
const next = normalizeFilterTags(currentFilter().jobStatus as any).filter(s => s.name !== status.name);
currentSetFilter({ jobStatus: next as any });
}}
>
<X size={10} />
</button>
</Badge>
)}
</For>
<Popover open={isJobStatusOpen()} onOpenChange={setIsJobStatusOpen}>
<PopoverTrigger class="h-7 px-3 bg-muted/40 border-none shadow-none focus:ring-0 text-[0.625rem] font-bold uppercase tracking-wider rounded-lg hover:bg-muted/60 transition-colors">
<span>+ Status</span>
</PopoverTrigger>
<PopoverContent class="w-[220px] p-0" align="start">
<Command>
<CommandInput placeholder="Search status..." />
<CommandList>
<CommandEmpty>No status found.</CommandEmpty>
<CommandGroup>
<For each={JOB_STATUSES}>
{(status) => (
<CommandItem
value={status}
onSelect={() => {
const current = normalizeFilterTags(currentFilter().jobStatus as any);
if (!current.some(s => s.name === status)) {
currentSetFilter({ jobStatus: [...current, { name: status, excluded: false }] as any });
}
setIsJobStatusOpen(false);
}}
>
{status}
</CommandItem>
)}
</For>
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
</div>
</div>
<div class="space-y-2.5">
<label class="text-[0.625rem] font-black uppercase tracking-widest text-muted-foreground ml-1">Job Division</label>
<div class="flex flex-wrap gap-2 min-h-[44px] p-2 bg-muted/10 border border-dashed border-border/60 rounded-xl">
<For each={normalizeFilterTags(currentFilter().jobDivision as any)}>
{(division) => (
<Badge
variant="secondary"
class={cn(
"h-7 px-2.5 rounded-lg text-xs gap-2 transition-all border shadow-sm",
division.excluded
? "bg-red-500/10 text-red-600 border-red-500/20 hover:bg-red-500/20"
: "bg-emerald-500/10 text-emerald-600 border-emerald-500/20 hover:bg-emerald-500/20"
)}
>
<div
class="flex items-center gap-2 cursor-pointer h-full"
onClick={() => {
const current = normalizeFilterTags(currentFilter().jobDivision as any);
const next = current.map(d =>
d.name === division.name ? { ...d, excluded: !d.excluded } : d
);
currentSetFilter({ jobDivision: next as any });
}}
>
<Show when={division.excluded} fallback={<Tag size={12} class="opacity-70" />}>
<Minus size={12} class="opacity-100" />
</Show>
<span class={cn(division.excluded && "line-through opacity-70")}>{division.name}</span>
</div>
<button
class="p-0.5 hover:bg-foreground/10 rounded transition-colors"
onClick={(e) => {
e.stopPropagation();
const next = normalizeFilterTags(currentFilter().jobDivision as any).filter(d => d.name !== division.name);
currentSetFilter({ jobDivision: next as any });
}}
>
<X size={10} />
</button>
</Badge>
)}
</For>
<Popover open={isJobDivisionOpen()} onOpenChange={setIsJobDivisionOpen}>
<PopoverTrigger class="h-7 px-3 bg-muted/40 border-none shadow-none focus:ring-0 text-[0.625rem] font-bold uppercase tracking-wider rounded-lg hover:bg-muted/60 transition-colors">
<span>+ Division</span>
</PopoverTrigger>
<PopoverContent class="w-[200px] p-0" align="start">
<Command>
<CommandInput placeholder="Search division..." />
<CommandList>
<CommandEmpty>No division found.</CommandEmpty>
<CommandGroup>
<For each={JOB_DIVISIONS}>
{(division) => (
<CommandItem
value={division}
onSelect={() => {
const current = normalizeFilterTags(currentFilter().jobDivision as any);
if (!current.some(d => d.name === division)) {
currentSetFilter({ jobDivision: [...current, { name: division, excluded: false }] as any });
}
setIsJobDivisionOpen(false);
}}
>
{division}
</CommandItem>
)}
</For>
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
</div>
</div>
</div>
</Show>
{/* Tags Multi-select */}
<div class="space-y-2.5">
@@ -206,7 +446,7 @@ export const FilterBar: Component = () => {
</span>
</label>
<div class="flex flex-wrap gap-2 min-h-[44px] p-2 bg-muted/10 border border-dashed border-border/60 rounded-xl">
<For each={store.filter.tags}>
<For each={currentFilter().tags}>
{(tag) => (
<Badge
variant="secondary"
@@ -229,10 +469,10 @@ export const FilterBar: Component = () => {
<div
class="flex items-center gap-2 cursor-pointer h-full"
onClick={() => {
const newTags = store.filter.tags.map(t =>
const newTags = currentFilter().tags.map(t =>
t.name === tag.name ? { ...t, excluded: !t.excluded } : t
);
setFilter({ tags: newTags });
currentSetFilter({ tags: newTags });
}}
>
<Show when={tag.excluded} fallback={<Tag size={12} class="opacity-70" />}>
@@ -244,7 +484,7 @@ export const FilterBar: Component = () => {
class="p-0.5 hover:bg-foreground/10 rounded transition-colors"
onClick={(e) => {
e.stopPropagation();
setFilter({ tags: store.filter.tags.filter(t => t.name !== tag.name) });
currentSetFilter({ tags: currentFilter().tags.filter(t => t.name !== tag.name) });
}}
>
<X size={10} />
@@ -252,22 +492,53 @@ export const FilterBar: Component = () => {
</Badge>
)}
</For>
<Select
value=""
onChange={(val) => {
if (val && !store.filter.tags.some(t => t.name === val)) {
setFilter({ tags: [...store.filter.tags, { name: val, excluded: false }] });
}
}}
options={allTags()}
placeholder="Add tag..."
itemComponent={(props) => <SelectItem item={props.item}>{props.item.rawValue}</SelectItem>}
>
<SelectTrigger class="h-7 px-3 bg-muted/40 border-none shadow-none focus:ring-0 text-[0.625rem] font-bold uppercase tracking-wider rounded-lg hover:bg-muted/60 transition-colors">
<Popover open={isTagOpen()} onOpenChange={setIsTagOpen}>
<PopoverTrigger class="h-7 px-3 bg-muted/40 border-none shadow-none focus:ring-0 text-[0.625rem] font-bold uppercase tracking-wider rounded-lg hover:bg-muted/60 transition-colors">
<span>+ Tag</span>
</SelectTrigger>
<SelectContent />
</Select>
</PopoverTrigger>
<PopoverContent class="w-[200px] p-0" align="start">
<Command>
<CommandInput
placeholder="Search tags..."
value={tagSearch()}
onValueChange={setTagSearch}
/>
<CommandList>
<Show when={allTags().filter(t => {
if (t.startsWith('_')) return false;
const search = tagSearch().toLowerCase();
if (!search && (t.startsWith('@') || t.startsWith('#'))) return false;
return t.toLowerCase().includes(search);
}).length === 0}>
<CommandEmpty>No tags found.</CommandEmpty>
</Show>
<CommandGroup>
<For each={allTags().filter(t => {
if (t.startsWith('_')) return false;
const search = tagSearch().toLowerCase();
if (!search && (t.startsWith('@') || t.startsWith('#'))) return false;
return t.toLowerCase().includes(search);
})}>
{(tag) => (
<CommandItem
value={tag}
onSelect={() => {
if (!currentFilter().tags.some(t => t.name === tag)) {
currentSetFilter({ tags: [...currentFilter().tags, { name: tag, excluded: false }] });
}
setIsTagOpen(false);
setTagSearch("");
}}
>
{tag}
</CommandItem>
)}
</For>
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
</div>
</div>
</div>
@@ -301,7 +572,7 @@ export const FilterBar: Component = () => {
onKeyDown={(e) => {
if (e.key === 'Enter') {
if (newTemplateName().trim()) {
saveFilterTemplate(newTemplateName().trim());
currentSaveTemplate(newTemplateName().trim());
setNewTemplateName("");
setIsSaving(false);
}
@@ -315,7 +586,7 @@ export const FilterBar: Component = () => {
class="h-8 px-3"
disabled={!newTemplateName().trim()}
onClick={() => {
saveFilterTemplate(newTemplateName().trim());
currentSaveTemplate(newTemplateName().trim());
setNewTemplateName("");
setIsSaving(false);
}}
@@ -343,7 +614,7 @@ export const FilterBar: Component = () => {
<div class="group flex items-center justify-between p-2 rounded-xl bg-muted/20 border border-border/30 hover:bg-muted/40 transition-all">
<button
class="flex-1 text-left px-1 min-w-0"
onClick={() => applyFilterTemplate(template.id)}
onClick={() => currentApplyTemplate(template.id)}
>
<div class="flex items-center gap-2">
<p class="text-[0.6875rem] font-bold text-foreground truncate">{template.name}</p>
@@ -354,7 +625,7 @@ export const FilterBar: Component = () => {
<Show when={template.filter.tags.length > 0}>
<div class="w-1 h-1 rounded-full bg-indigo-500" />
</Show>
<Show when={template.filter.priorityMin > 1 || template.filter.priorityMax < 10}>
<Show when={mode() === 'tasks' && (template.filter.priorityMin > 1 || template.filter.priorityMax < 10)}>
<div class="w-1 h-1 rounded-full bg-amber-500" />
</Show>
</div>
@@ -364,7 +635,7 @@ export const FilterBar: Component = () => {
class="opacity-0 group-hover:opacity-100 p-1.5 hover:bg-destructive/10 hover:text-destructive rounded-lg transition-all"
onClick={(e) => {
e.stopPropagation();
removeFilterTemplate(template.id);
currentRemoveTemplate(template.id);
}}
>
<Trash2 size={12} />
+1 -1
View File
@@ -223,7 +223,7 @@ export const ImportTool: Component = () => {
<div class="space-y-1">
<label class="text-[0.5625rem] font-bold uppercase tracking-wider text-muted-foreground/70 ml-0.5">Tags</label>
<div class="flex items-center gap-1 flex-wrap">
<TagPicker selectedTags={bulkTags()} onTagsChange={setBulkTags} />
<TagPicker selectedTags={bulkTags()} onTagsChange={(tags) => setBulkTags(tags)} />
<For each={bulkTags()}>
{(tag) => (
<Badge
+198
View File
@@ -0,0 +1,198 @@
import { createSignal, createResource, Show, For, onMount } from "solid-js";
import { pb } from "@/lib/pocketbase";
import { store, activeNoteId } from "@/store";
import { Search, File as FileIcon, Loader2, FolderOpen } from "lucide-solid";
interface JobFileSelectorProps {
onSelectFile: (jobId: string, filename: string) => void;
}
export const JobFileSelector = (props: JobFileSelectorProps) => {
const [query, setQuery] = createSignal("");
const [selectedJobId, setSelectedJobId] = createSignal<string | null>(null);
// Auto-detect job ID from active note tags (traversing up hierarchy)
onMount(() => {
const currentNoteId = activeNoteId();
if (currentNoteId) {
let currentId = currentNoteId;
let currentNote = store.notes.find(n => n.id === currentId);
let depth = 0;
// Traverse up "child-of-" tags to find the root or any ancestor with a job_id
while (currentNote && depth < 10) {
const jobIdTag = currentNote.tags?.find(t => t.startsWith("job_id:"));
if (jobIdTag) {
const id = jobIdTag.split(":")[1];
if (id) {
setSelectedJobId(id);
return; // Found it!
}
}
const parentTag = currentNote.tags?.find(t => t.startsWith("child-of-"));
if (!parentTag) break;
const parentId = parentTag.replace("child-of-", "");
const parent = store.notes.find(n => n.id === parentId);
if (!parent) break;
currentNote = parent;
depth++;
}
}
});
// Query Jobs
const [jobs] = createResource(query, async (q) => {
if (!q.trim()) return [];
try {
const res = await pb.collection('Job_Info_Prod').getList(1, 10, {
filter: `Job_Number ~ "${q}" || Job_Name ~ "${q}"`,
sort: '-created',
requestKey: null // Allow concurrent overlapping requests without auto-cancelling if needed, or leave it
});
return res.items;
} catch (e) {
return [];
}
});
// Fetch details for the selected job to list files
const [selectedJob] = createResource(selectedJobId, async (id) => {
if (!id) return null;
try {
const data = await pb.collection('Job_Info_Prod').getOne(id);
console.log("Job File Selector Selected Job Data:", data);
return data;
} catch (e) {
console.error("Job File Selector Error Fetching Job:", e);
return null;
}
});
const categories = ["plans", "EST_and_CO", "Addendums", "files"] as const;
return (
<div class="w-full flex flex-col items-center justify-center p-8 gap-6 bg-muted/10 rounded-xl relative">
<Show when={!selectedJobId()}>
<div class="text-center w-full max-w-lg space-y-4 relative z-10">
<div class="flex items-center justify-center w-12 h-12 rounded-full bg-primary/10 text-primary mx-auto mb-4">
<FolderOpen size={24} />
</div>
<div class="space-y-1">
<h3 class="font-semibold text-lg">Embed a Job File</h3>
<p class="text-sm text-muted-foreground">Search for a Prism job to browse its files.</p>
</div>
<div class="relative w-full">
<Search class="absolute left-3 top-1/2 -translate-y-1/2 text-muted-foreground w-4 h-4" />
<input
type="text"
placeholder="Search by Job Name or Number..."
class="w-full pl-9 pr-4 py-2 bg-background border border-border rounded-lg outline-none focus:ring-2 focus:ring-primary/50 transition-all text-sm"
value={query()}
onInput={(e) => setQuery(e.currentTarget.value)}
/>
</div>
<Show when={jobs.loading}>
<div class="flex items-center justify-center py-4 text-muted-foreground">
<Loader2 class="w-5 h-5 animate-spin" />
</div>
</Show>
<Show when={jobs() && jobs()!.length > 0}>
<div class="mt-4 border border-border bg-background rounded-lg shadow-sm overflow-hidden text-left flex flex-col">
<For each={jobs()}>
{(job) => (
<button
class="px-4 py-3 hover:bg-muted text-left flex flex-col gap-1 border-b border-border last:border-0 transition-colors cursor-pointer"
onClick={() => setSelectedJobId(job.id)}
>
<span class="font-medium text-sm text-foreground">{job.Job_Name || "Unnamed Job"}</span>
<span class="text-xs text-muted-foreground">Job #{job.Job_Number || job.id}</span>
</button>
)}
</For>
</div>
</Show>
</div>
</Show>
<Show when={selectedJobId()}>
<Show when={selectedJob.loading}>
<div class="flex flex-col items-center justify-center gap-4 py-12 relative z-10">
<Loader2 class="w-8 h-8 animate-spin text-primary" />
<span class="text-sm text-muted-foreground font-medium">Loading Job Files...</span>
</div>
</Show>
<Show when={selectedJob()}>
{(jobRecord) => (
<div class="w-full max-w-2xl bg-background rounded-xl border border-border shadow-sm overflow-hidden relative z-10">
<div class="p-4 border-b border-border bg-muted/30 flex items-center justify-between">
<div class="flex flex-col">
<h3 class="font-semibold">{jobRecord().Job_Name || "Unnamed Job"}</h3>
<span class="text-xs text-muted-foreground">Job #{jobRecord().Job_Number || jobRecord().id}</span>
</div>
<button
class="text-xs text-blue-500 hover:text-blue-600 font-medium px-2 py-1 hover:bg-blue-50 rounded transition-colors cursor-pointer"
onClick={() => setSelectedJobId(null)}
>
Change Job
</button>
</div>
<div class="p-4 flex flex-col gap-6 max-h-[400px] overflow-y-auto">
<For each={categories}>
{(category) => {
let files = jobRecord()[category];
if (files === null || files === undefined || files === "") return null;
if (!Array.isArray(files)) files = [files];
files = files.filter(Boolean);
if (files.length === 0) return null;
return (
<div class="space-y-2">
<h4 class="text-xs font-semibold text-muted-foreground uppercase tracking-wider">{category.replace(/_/g, " ")}</h4>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-2">
<For each={files as string[]}>
{(filename) => (
<button
class="flex items-center gap-3 p-2 rounded-md border border-border hover:bg-muted hover:border-primary/50 transition-colors text-left cursor-pointer group"
onClick={() => props.onSelectFile(jobRecord().id, filename)}
>
<div class="p-2 rounded bg-muted text-muted-foreground group-hover:text-primary transition-colors shrink-0">
<FileIcon class="w-4 h-4" />
</div>
<span class="text-sm truncate font-medium text-foreground min-w-0" title={filename}>
{filename}
</span>
</button>
)}
</For>
</div>
</div>
);
}}
</For>
{/* Empty state if no files at all */}
<Show when={categories.every(c => {
const val = jobRecord()[c];
return !val || (Array.isArray(val) && val.length === 0) || (typeof val === 'string' && val.trim() === '');
})}>
<div class="text-center py-8 text-muted-foreground text-sm">
No files found for this job.
</div>
</Show>
</div>
</div>
)}
</Show>
</Show>
</div>
);
};
+111 -25
View File
@@ -1,6 +1,6 @@
import { type Component, type JSX, createSignal, createEffect, Show, lazy, Suspense, onMount, onCleanup } from "solid-js";
import { Sidebar, BottomNav, ContextSwitcher } from "./Navigation";
import { store, setStore, activeTaskId, setActiveTaskId } from "@/store";
import { store, activeTaskId, setActiveTaskId, requestImmediateNotesMetadataLoad, fetchNoteById, updateStoreWithNote } from "@/store";
import { PanelLeftOpen, PanelLeftClose } from "lucide-solid";
const TaskDetail = lazy(() => import("./TaskDetail").then(m => ({ default: m.TaskDetail })));
@@ -9,33 +9,45 @@ const NotepadView = lazy(() => import("../views/NotepadView").then(m => ({ defau
import { NotesSidebar } from "./NotesSidebar";
import { Button } from "./ui/button";
import { cn } from "@/lib/utils";
import { FilterBar } from "./FilterBar";
import { TaskSearchPopover } from "./TaskSearchPopover";
interface LayoutProps {
children: JSX.Element;
currentView: string;
setView: (v: string) => void;
selectedNoteId: string | null;
setSelectedNoteId: (id: string | null) => void;
onOpenTasks: (view?: string, options?: { replace?: boolean }) => void;
onOpenNotes: (noteId?: string | null, options?: { replace?: boolean }) => void;
isAIHelpOpen?: boolean;
onToggleAIHelp?: () => void;
aiHelpTip?: string | null;
hideQuickEntry?: boolean;
}
export const Layout: Component<LayoutProps> = (props) => {
const [isSidebarLocked, setIsSidebarLocked] = createSignal(true);
const [isSidebarPeeking, setIsSidebarPeeking] = createSignal(false);
const [isDropdownOpen, setIsDropdownOpen] = createSignal(false);
const [resolvingRoutedNoteId, setResolvingRoutedNoteId] = createSignal<string | null>(null);
// Lift selectedNoteId up to Layout so it can be passed to NotesSidebar and NotepadView
const [selectedNoteId, setSelectedNoteId] = createSignal<string | null>(null);
const setActiveNoteId = (id: string | null) => {
props.setSelectedNoteId(id);
if (store.isNotepadMode) {
props.onOpenNotes(id);
}
};
// Make it available globally for QuickEntry auto-tagging without circular deps
createEffect(() => {
(window as any)._activeNoteId = selectedNoteId();
(window as any)._activeNoteId = props.selectedNoteId;
});
onMount(() => {
const handleOpenNote = (e: Event) => {
const detail = (e as CustomEvent).detail;
if (detail && detail.noteId) {
setStore('isNotepadMode', true);
setSelectedNoteId(detail.noteId);
props.onOpenNotes(detail.noteId);
}
};
window.addEventListener('open-note', handleOpenNote);
@@ -54,15 +66,70 @@ export const Layout: Component<LayoutProps> = (props) => {
// Derived active task for the detail view
const activeTask = () => store.tasks.find(t => t.id === activeTaskId());
createEffect(() => {
if (store.isNotepadMode) {
void requestImmediateNotesMetadataLoad();
}
});
createEffect(() => {
const noteId = props.selectedNoteId;
if (!noteId) return;
const noteExists = store.notes.some(note => note.id === noteId);
if (noteExists) {
if (resolvingRoutedNoteId() === noteId) {
setResolvingRoutedNoteId(null);
}
return;
}
if (store.isNotesLoading || !store.hasLoadedNotesMetadata) return;
if (resolvingRoutedNoteId() === noteId) return;
setResolvingRoutedNoteId(noteId);
void (async () => {
try {
const fetched = await fetchNoteById(noteId);
if (fetched) {
updateStoreWithNote(fetched);
return;
}
} catch (error) {
console.error("Failed to resolve routed note", error);
}
if (props.selectedNoteId === noteId) {
setActiveNoteId(null);
props.onOpenNotes(null, { replace: true });
}
setResolvingRoutedNoteId(current => current === noteId ? null : current);
})();
});
return (
(() => {
const sidebarWidthClass = () => {
if (store.isNotepadMode) return "w-80";
if (props.isAIHelpOpen) return "w-[24rem]";
return "w-48";
};
const sidebarPaddingClass = () => {
if (store.isNotepadMode) return "md:pl-80";
if (props.isAIHelpOpen) return "md:pl-[24rem]";
return "md:pl-48";
};
return (
<div class="flex h-screen w-full min-w-0 bg-background overflow-hidden relative font-sans antialiased text-foreground">
{/* Desktop UI Container */}
<div
style={{ "will-change": "width, transform" }}
class={cn(
"hidden md:flex flex-col border-r border-border bg-card h-screen fixed left-0 top-0 transition-all duration-200 ease-in-out z-[50] overflow-hidden",
isSidebarLocked() ? (store.isNotepadMode ? "w-80 translate-x-0" : "w-48 translate-x-0") : (store.isNotepadMode ? "w-80 -translate-x-full" : "w-48 -translate-x-full"),
!isSidebarLocked() && (isSidebarPeeking() || (store.isNotepadMode && !selectedNoteId())) && "translate-x-0 shadow-2xl ring-1 ring-border"
isSidebarLocked() ? `${sidebarWidthClass()} translate-x-0` : `${sidebarWidthClass()} -translate-x-full`,
!isSidebarLocked() && (isSidebarPeeking() || (store.isNotepadMode && !props.selectedNoteId)) && "translate-x-0 shadow-2xl ring-1 ring-border"
)}
onMouseEnter={() => !isSidebarLocked() && setIsSidebarPeeking(true)}
onMouseLeave={() => {
@@ -82,6 +149,9 @@ export const Layout: Component<LayoutProps> = (props) => {
<Sidebar
currentView={props.currentView}
setView={props.setView}
isAIHelpOpen={!!props.isAIHelpOpen}
onToggleAIHelp={() => props.onToggleAIHelp?.()}
aiHelpTip={props.aiHelpTip}
isLocked={isSidebarLocked()}
setIsLocked={setIsSidebarLocked}
isPeeking={isSidebarPeeking()}
@@ -122,8 +192,8 @@ export const Layout: Component<LayoutProps> = (props) => {
</Show>
</div>
<div class={cn("flex-1 overflow-hidden transition-all duration-200", (!isSidebarLocked() && !isSidebarPeeking() && !(store.isNotepadMode && !selectedNoteId())) ? "opacity-0 pointer-events-none scale-95" : "opacity-100 scale-100")}>
<NotesSidebar selectedNoteId={selectedNoteId()} setSelectedNoteId={setSelectedNoteId} />
<div class={cn("flex-1 overflow-hidden transition-all duration-200", (!isSidebarLocked() && !isSidebarPeeking() && !(store.isNotepadMode && !props.selectedNoteId)) ? "opacity-0 pointer-events-none scale-95" : "opacity-100 scale-100")}>
<NotesSidebar selectedNoteId={props.selectedNoteId} setSelectedNoteId={setActiveNoteId} />
</div>
</div>
</div>
@@ -134,7 +204,7 @@ export const Layout: Component<LayoutProps> = (props) => {
class={cn(
"flex-1 flex flex-col min-w-0 transition-all duration-200 ease-in-out h-screen",
// Only apply md padding if locked
isSidebarLocked() ? (store.isNotepadMode ? "md:pl-80" : "md:pl-48") : "md:pl-0"
isSidebarLocked() ? sidebarPaddingClass() : "md:pl-0"
)}>
{/* Expand Trigger Button (visible when not locked) */}
{!isSidebarLocked() && (
@@ -172,7 +242,7 @@ export const Layout: Component<LayoutProps> = (props) => {
"flex-1 h-8 text-[0.7rem] font-bold uppercase tracking-widest z-10 transition-colors duration-200 flex items-center justify-center rounded-lg",
!store.isNotepadMode ? "text-primary-foreground" : "text-muted-foreground hover:text-foreground"
)}
onClick={() => setStore('isNotepadMode', false)}
onClick={() => props.onOpenTasks()}
>
Tasks
</button>
@@ -181,31 +251,43 @@ export const Layout: Component<LayoutProps> = (props) => {
"flex-1 h-8 text-[0.7rem] font-bold uppercase tracking-widest z-10 transition-colors duration-200 flex items-center justify-center rounded-lg",
store.isNotepadMode ? "text-primary-foreground" : "text-muted-foreground hover:text-foreground"
)}
onClick={() => setStore('isNotepadMode', true)}
onClick={() => props.onOpenNotes(props.selectedNoteId)}
>
Notes
</button>
</div>
</div>
{/* Sync Progress Bar Overlay */}
<Show when={store.isInitializing || (store.isNotepadMode && store.isNotesLoading)}>
<div class="fixed top-0 left-0 w-full h-0.5 z-[100] overflow-hidden bg-primary/10">
<div class="h-full bg-primary animate-progress-fast w-1/3" />
</div>
</Show>
{/* Main Content Area */}
<main class={cn(
"flex-1 min-w-0 overflow-y-auto overflow-x-hidden relative scroll-smooth",
!store.isNotepadMode ? "pb-32 sm:pb-6" : ""
"flex-1 min-w-0 overflow-x-hidden relative scroll-smooth",
props.currentView === "help_ai" ? "overflow-hidden" : "overflow-y-auto",
!store.isNotepadMode ? "sm:pb-6" : ""
)}>
<div class={cn(
"w-full h-full mx-auto",
!store.isNotepadMode ? "max-w-screen-2xl px-4 sm:px-8 lg:px-12 pt-16 sm:pt-20 pb-8 space-y-6 sm:space-y-8" : "max-w-screen-2xl md:px-8 lg:px-12 md:pt-20 md:pb-8 pt-14"
"w-full mx-auto",
props.currentView === "help_ai"
? "max-w-screen-2xl px-4 sm:px-8 lg:px-12 pt-16 sm:pt-20 pb-[calc(4.5rem+env(safe-area-inset-bottom,0px))] sm:pb-8 h-full"
: !store.isNotepadMode
? "min-h-full max-w-screen-2xl px-4 sm:px-8 lg:px-12 pt-16 sm:pt-20 mobile-footer-clearance space-y-6 sm:space-y-8"
: "max-w-screen-2xl md:px-8 lg:px-12 md:pt-20 md:pb-8 pt-14"
)}>
<div class={cn(store.isNotepadMode ? "hidden" : "block")}>
<Show when={props.currentView.toLowerCase() !== "settings"}>
<FilterBar />
<div class={cn(store.isNotepadMode ? "hidden" : props.currentView === "help_ai" ? "block h-full" : "block")}>
<Show when={!["settings", "help", "help_ai"].includes(props.currentView.toLowerCase())}>
<TaskSearchPopover />
</Show>
{props.children}
</div>
<div class={cn(!store.isNotepadMode ? "hidden" : "block", "h-[calc(100vh-3.5rem)] md:h-[calc(100vh-8rem)] w-full")}>
<Suspense>
<NotepadView selectedNoteId={selectedNoteId()} setSelectedNoteId={setSelectedNoteId} />
<NotepadView selectedNoteId={props.selectedNoteId} setSelectedNoteId={setActiveNoteId} />
</Suspense>
</div>
</div>
@@ -216,9 +298,11 @@ export const Layout: Component<LayoutProps> = (props) => {
</div>
</div>
<Suspense>
<QuickEntry />
</Suspense>
<Show when={!props.hideQuickEntry}>
<Suspense>
<QuickEntry />
</Suspense>
</Show>
<Show when={activeTask()}>
<Suspense>
@@ -230,5 +314,7 @@ export const Layout: Component<LayoutProps> = (props) => {
</Suspense>
</Show>
</div >
);
})()
);
};
+165
View File
@@ -0,0 +1,165 @@
import { type Component, createMemo } from "solid-js";
import { cn } from "@/lib/utils";
const escapeHtml = (value: string) =>
value
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;");
const applyInlineMarkdown = (value: string) => {
let text = escapeHtml(value);
text = text.replace(/`([^`]+)`/g, "<code>$1</code>");
text = text.replace(/\[([^\]]+)\]\((https?:\/\/[^\s)]+)\)/g, '<a href="$2" target="_blank" rel="noreferrer">$1</a>');
text = text.replace(/\*\*([^*]+)\*\*/g, "<strong>$1</strong>");
text = text.replace(/(^|[\s(])\*([^*]+)\*(?=[\s).,!?:;]|$)/g, '$1<em>$2</em>');
return text;
};
const splitTableRow = (line: string) => {
const trimmed = line.trim().replace(/^\|/, "").replace(/\|$/, "");
return trimmed.split("|").map(cell => applyInlineMarkdown(cell.trim()));
};
const isTableSeparator = (line: string) => {
const trimmed = line.trim().replace(/^\|/, "").replace(/\|$/, "");
if (!trimmed) return false;
return trimmed
.split("|")
.every(cell => /^:?-{3,}:?$/.test(cell.trim()));
};
const isTableRow = (line: string) => {
const trimmed = line.trim();
return trimmed.includes("|") && splitTableRow(trimmed).length > 1;
};
const renderMarkdown = (markdown: string) => {
const lines = markdown.replace(/\r\n/g, "\n").split("\n");
const html: string[] = [];
let i = 0;
while (i < lines.length) {
const line = lines[i];
const trimmed = line.trim();
if (!trimmed) {
i += 1;
continue;
}
if (trimmed.startsWith("```")) {
const codeLines: string[] = [];
i += 1;
while (i < lines.length && !lines[i].trim().startsWith("```")) {
codeLines.push(lines[i]);
i += 1;
}
if (i < lines.length) i += 1;
html.push(`<pre><code>${escapeHtml(codeLines.join("\n"))}</code></pre>`);
continue;
}
const headingMatch = trimmed.match(/^(#{1,6})\s+(.+)$/);
if (headingMatch) {
const level = headingMatch[1].length;
const text = applyInlineMarkdown(headingMatch[2].trim());
html.push(`<h${level}>${text}</h${level}>`);
i += 1;
continue;
}
if (
i + 1 < lines.length &&
isTableRow(lines[i]) &&
isTableSeparator(lines[i + 1])
) {
const headerCells = splitTableRow(lines[i]);
const bodyRows: string[] = [];
i += 2;
while (i < lines.length && lines[i].trim() && isTableRow(lines[i])) {
const cells = splitTableRow(lines[i]).map(cell => `<td>${cell}</td>`);
bodyRows.push(`<tr>${cells.join("")}</tr>`);
i += 1;
}
html.push(
`<div class="table-wrap"><table><thead><tr>${headerCells.map(cell => `<th>${cell}</th>`).join("")}</tr></thead>${bodyRows.length > 0 ? `<tbody>${bodyRows.join("")}</tbody>` : ""}</table></div>`
);
continue;
}
if (/^[-*]\s+/.test(trimmed)) {
const items: string[] = [];
while (i < lines.length && /^[-*]\s+/.test(lines[i].trim())) {
items.push(`<li>${applyInlineMarkdown(lines[i].trim().replace(/^[-*]\s+/, ""))}</li>`);
i += 1;
}
html.push(`<ul>${items.join("")}</ul>`);
continue;
}
if (/^\d+\.\s+/.test(trimmed)) {
const items: string[] = [];
while (i < lines.length && /^\d+\.\s+/.test(lines[i].trim())) {
items.push(`<li>${applyInlineMarkdown(lines[i].trim().replace(/^\d+\.\s+/, ""))}</li>`);
i += 1;
}
html.push(`<ol>${items.join("")}</ol>`);
continue;
}
if (/^>\s?/.test(trimmed)) {
const quoteLines: string[] = [];
while (i < lines.length && /^>\s?/.test(lines[i].trim())) {
quoteLines.push(applyInlineMarkdown(lines[i].trim().replace(/^>\s?/, "")));
i += 1;
}
html.push(`<blockquote><p>${quoteLines.join("<br />")}</p></blockquote>`);
continue;
}
const paragraphLines: string[] = [];
while (i < lines.length && lines[i].trim() && !lines[i].trim().startsWith("```") && !/^[-*]\s+/.test(lines[i].trim()) && !/^\d+\.\s+/.test(lines[i].trim()) && !/^>\s?/.test(lines[i].trim())) {
paragraphLines.push(applyInlineMarkdown(lines[i].trim()));
i += 1;
}
html.push(`<p>${paragraphLines.join("<br />")}</p>`);
}
return html.join("");
};
export const MarkdownMessage: Component<{
content: string;
class?: string;
}> = (props) => {
const html = createMemo(() => renderMarkdown(props.content));
return (
<div
class={cn(
"prose prose-sm dark:prose-invert max-w-none",
"prose-headings:mt-4 prose-headings:mb-2 prose-headings:font-semibold prose-headings:tracking-tight",
"prose-h1:text-xl prose-h2:text-lg prose-h3:text-base prose-h4:text-sm",
"prose-p:my-2 prose-ul:my-2 prose-ol:my-2 prose-li:my-0",
"prose-pre:my-3 prose-pre:rounded-lg prose-pre:bg-muted prose-pre:p-3",
"prose-code:rounded prose-code:bg-muted prose-code:px-1 prose-code:py-0.5 prose-code:text-[0.9em]",
"prose-code:before:content-none prose-code:after:content-none",
"prose-a:text-primary prose-a:underline prose-a:underline-offset-4",
"prose-blockquote:border-l-4 prose-blockquote:border-primary prose-blockquote:pl-4 prose-blockquote:italic",
"prose-table:my-3 prose-table:w-full prose-table:border-collapse prose-th:border prose-th:border-border prose-th:bg-muted prose-th:px-2 prose-th:py-1 prose-th:text-left prose-th:font-semibold",
"prose-td:border prose-td:border-border prose-td:px-2 prose-td:py-1 align-top",
"[&_div.table-wrap]:my-3 [&_div.table-wrap]:overflow-x-auto",
props.class
)}
innerHTML={html()}
/>
);
};
+256
View File
@@ -0,0 +1,256 @@
import { Node, mergeAttributes } from '@tiptap/core'
const MIN_CHECKBOXES = 1
const normalizeCheckStates = (value: unknown): boolean[] => {
if (Array.isArray(value)) {
const states = value.map(Boolean)
return states.length >= MIN_CHECKBOXES
? states
: [...states, ...Array.from({ length: MIN_CHECKBOXES - states.length }, () => false)]
}
return Array.from({ length: MIN_CHECKBOXES }, () => false)
}
const parseCheckStates = (rawValue: string | null): boolean[] => {
if (!rawValue) {
return normalizeCheckStates(null)
}
try {
return normalizeCheckStates(JSON.parse(rawValue))
} catch {
return normalizeCheckStates(
rawValue
.split(',')
.map(value => value.trim())
.filter(Boolean)
.map(value => value === 'true' || value === '1')
)
}
}
declare module '@tiptap/core' {
interface Commands<ReturnType> {
multiTaskItem: {
addCheckboxToMultiTaskItem: () => ReturnType
removeCheckboxFromMultiTaskItem: () => ReturnType
}
}
}
export const MultiTaskItem = Node.create({
name: 'multiTaskItem',
addOptions() {
return {
nested: true,
HTMLAttributes: {},
}
},
content: 'paragraph block*',
defining: true,
addAttributes() {
return {
checkStates: {
default: normalizeCheckStates(null),
keepOnSplit: true,
parseHTML: element => parseCheckStates(element.getAttribute('data-check-states')),
renderHTML: attributes => ({
'data-check-states': JSON.stringify(normalizeCheckStates(attributes.checkStates)),
}),
},
}
},
parseHTML() {
return [
{
tag: 'li[data-type="multiTaskItem"]',
priority: 51,
},
]
},
renderHTML({ node, HTMLAttributes }) {
const states = normalizeCheckStates(node.attrs.checkStates)
const controlGroup = [
'div',
{ 'data-multi-task-controls': 'true' },
...states.map(state => [
'label',
[
'input',
{
type: 'checkbox',
checked: state ? 'checked' : null,
},
],
['span', ''],
]),
]
return [
'li',
mergeAttributes(this.options.HTMLAttributes, HTMLAttributes, {
'data-type': 'multiTaskItem',
}),
controlGroup,
['div', 0],
]
},
addKeyboardShortcuts() {
return {
Enter: () => this.editor.commands.splitListItem(this.name),
'Shift-Tab': () => this.editor.commands.liftListItem(this.name),
Tab: () => this.editor.commands.sinkListItem(this.name),
}
},
addCommands() {
const updateCheckStates = (transform: (states: boolean[]) => boolean[]) => {
return () => ({ state, commands }: any) => {
const { $from } = state.selection
for (let depth = $from.depth; depth > 0; depth -= 1) {
const node = $from.node(depth)
if (node.type.name !== this.name) {
continue
}
const position = $from.before(depth)
return commands.command(({ tr }: any) => {
tr.setNodeMarkup(position, undefined, {
...node.attrs,
checkStates: normalizeCheckStates(transform(normalizeCheckStates(node.attrs.checkStates))),
})
return true
})
}
return false
}
}
return {
addCheckboxToMultiTaskItem: updateCheckStates(states => [...states, false]),
removeCheckboxFromMultiTaskItem: updateCheckStates(states => states.length > MIN_CHECKBOXES ? states.slice(0, -1) : states),
}
},
addNodeView() {
return ({ node, getPos, editor }) => {
let currentNode = node
const dom = document.createElement('li')
dom.classList.add('flex', 'items-start', 'group', 'relative', 'my-1')
dom.dataset.type = 'multiTaskItem'
const controls = document.createElement('div')
controls.contentEditable = 'false'
controls.classList.add('flex', 'items-center', 'mr-3', 'select-none', 'shrink-0')
const checkboxRow = document.createElement('div')
checkboxRow.classList.add('flex', 'items-center', 'gap-2')
const content = document.createElement('div')
content.classList.add('flex-1', 'min-w-0', 'min-h-[1.5rem]', 'tiptap-task-content')
const updateNode = (nextStates: boolean[]) => {
if (typeof getPos !== 'function') {
return
}
editor.commands.command(({ tr }) => {
const position = getPos()
if (typeof position !== 'number') {
return false
}
tr.setNodeMarkup(position, undefined, {
...currentNode.attrs,
checkStates: normalizeCheckStates(nextStates),
})
return true
})
}
const createCheckbox = (checked: boolean, index: number) => {
const wrapper = document.createElement('div')
wrapper.classList.add('w-5', 'flex', 'justify-center')
const button = document.createElement('button')
button.type = 'button'
button.classList.add(
'w-5', 'h-5',
'flex', 'items-center', 'justify-center',
'rounded-md', 'border-[1.5px]',
'transition-colors', 'duration-300', 'cursor-pointer',
'shrink-0'
)
button.setAttribute('aria-label', `Toggle checkbox ${index + 1}`)
const checkIcon = document.createElement('div')
checkIcon.innerHTML = '<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"></polyline></svg>'
checkIcon.classList.add('transition-opacity', 'duration-300', 'ease-in-out')
button.appendChild(checkIcon)
const updateCheckboxVisuals = (isChecked: boolean) => {
if (isChecked) {
button.classList.add('bg-primary', 'border-primary', 'text-primary-foreground')
button.classList.remove('border-muted-foreground/30', 'bg-transparent', 'text-transparent')
checkIcon.style.opacity = '1'
} else {
button.classList.remove('bg-primary', 'border-primary', 'text-primary-foreground')
button.classList.add('border-muted-foreground/30', 'bg-transparent', 'text-transparent')
checkIcon.style.opacity = '0'
}
}
updateCheckboxVisuals(checked)
button.addEventListener('click', (event) => {
event.preventDefault()
event.stopPropagation()
const states = normalizeCheckStates(currentNode.attrs.checkStates)
states[index] = !states[index]
updateNode(states)
})
wrapper.appendChild(button)
return wrapper
}
const renderCheckboxes = (states: boolean[]) => {
checkboxRow.replaceChildren(...states.map((state, index) => createCheckbox(state, index)))
}
renderCheckboxes(normalizeCheckStates(currentNode.attrs.checkStates))
controls.appendChild(checkboxRow)
dom.appendChild(controls)
dom.appendChild(content)
return {
dom,
contentDOM: content,
update: updatedNode => {
if (updatedNode.type.name !== this.name) {
return false
}
currentNode = updatedNode
renderCheckboxes(normalizeCheckStates(updatedNode.attrs.checkStates))
return true
},
}
}
},
})
+484
View File
@@ -0,0 +1,484 @@
import { Node, mergeAttributes } from '@tiptap/core'
import type { Component } from 'solid-js'
import { render } from 'solid-js/web'
import { Plus, Minus, MoreHorizontal } from 'lucide-solid'
const normalizeCheckStates = (value: unknown): boolean[] => {
if (Array.isArray(value)) {
return value.map(Boolean)
}
return [false]
}
const getColumnCountFromListNode = (listNode: any): number => {
let columnCount = 1
listNode?.descendants((descendant: any) => {
if (descendant.type?.name === 'multiTaskItem') {
columnCount = Math.max(columnCount, normalizeCheckStates(descendant.attrs?.checkStates).length)
}
})
return columnCount
}
const normalizeColumnLabels = (value: unknown, columnCount: number): string[] => {
const labels = Array.isArray(value)
? value.map(label => typeof label === 'string' ? label : '')
: []
if (labels.length >= columnCount) {
return labels.slice(0, columnCount)
}
return [...labels, ...Array.from({ length: columnCount - labels.length }, () => '')]
}
const parseColumnLabels = (rawValue: string | null, columnCount: number): string[] => {
if (!rawValue) {
return normalizeColumnLabels([], columnCount)
}
try {
return normalizeColumnLabels(JSON.parse(rawValue), columnCount)
} catch {
return normalizeColumnLabels(
rawValue.split(',').map(value => value.trim()),
columnCount,
)
}
}
const findTopmostListDepth = ($from: any, listName: string): number => {
let listDepth = -1
for (let depth = $from.depth; depth > 0; depth -= 1) {
if ($from.node(depth).type.name === listName) {
listDepth = depth
}
}
return listDepth
}
declare module '@tiptap/core' {
interface Commands<ReturnType> {
multiTaskList: {
toggleMultiTaskList: () => ReturnType
setMultiTaskList: () => ReturnType
unsetMultiTaskList: () => ReturnType
addCheckboxColumnToMultiTaskList: () => ReturnType
removeCheckboxColumnFromMultiTaskList: () => ReturnType
toggleMultiTaskListColumnLabels: () => ReturnType
setMultiTaskListColumnLabel: (index: number, label: string) => ReturnType
}
}
}
export const MultiTaskList = Node.create({
name: 'multiTaskList',
addOptions() {
return {
HTMLAttributes: {},
itemTypeName: 'multiTaskItem',
}
},
group: 'block list',
content: 'multiTaskItem+',
addAttributes() {
return {
columnLabels: {
default: [''],
parseHTML: element => parseColumnLabels(element.getAttribute('data-column-labels'), 1),
renderHTML: attributes => ({
'data-column-labels': JSON.stringify(attributes.columnLabels ?? ['']),
}),
},
showColumnLabels: {
default: false,
parseHTML: element => element.getAttribute('data-show-column-labels') === 'true',
renderHTML: attributes => ({
'data-show-column-labels': attributes.showColumnLabels ? 'true' : 'false',
}),
},
}
},
parseHTML() {
return [
{
tag: 'ul[data-type="multiTaskList"]',
priority: 51,
},
]
},
renderHTML({ HTMLAttributes }) {
return [
'ul',
mergeAttributes(this.options.HTMLAttributes, HTMLAttributes, {
'data-type': 'multiTaskList',
}),
0,
]
},
addCommands() {
const updateDescendantItems = (tr: any, listNode: any, listPos: number, transformStates: (states: boolean[]) => boolean[]) => {
listNode.descendants((descendant: any, descendantPos: number) => {
if (descendant.type.name !== this.options.itemTypeName) {
return
}
tr.setNodeMarkup(listPos + 1 + descendantPos, undefined, {
...descendant.attrs,
checkStates: transformStates(normalizeCheckStates(descendant.attrs.checkStates)),
})
})
}
const updateCurrentList = (transformStates: (states: boolean[]) => boolean[], transformLabels?: (labels: string[], columnCount: number, showColumnLabels: boolean) => { labels: string[]; showColumnLabels?: boolean }) => {
return () => ({ state, tr, dispatch }: any) => {
const { $from } = state.selection
const listDepth = findTopmostListDepth($from, this.name)
if (listDepth === -1) {
return false
}
const listPos = $from.before(listDepth)
const listNode = state.doc.nodeAt(listPos)
if (!listNode) {
return false
}
const columnCount = getColumnCountFromListNode(listNode)
const nextColumnCount = transformStates(Array.from({ length: columnCount }, () => false)).length
const currentLabels = normalizeColumnLabels(listNode.attrs?.columnLabels, columnCount)
const currentShowColumnLabels = Boolean(listNode.attrs?.showColumnLabels)
const transformed = transformLabels
? transformLabels(currentLabels, nextColumnCount, currentShowColumnLabels)
: { labels: currentLabels, showColumnLabels: currentShowColumnLabels }
const nextLabels = normalizeColumnLabels(transformed.labels, nextColumnCount)
const nextShowColumnLabels = transformed.showColumnLabels ?? currentShowColumnLabels
tr.setNodeMarkup(listPos, undefined, {
...listNode.attrs,
columnLabels: nextLabels,
showColumnLabels: nextShowColumnLabels,
})
updateDescendantItems(tr, listNode, listPos, transformStates)
if (dispatch) {
dispatch(tr)
}
return true
}
}
return {
toggleMultiTaskList: () => ({ commands }) => {
return commands.toggleList(this.name, this.options.itemTypeName)
},
setMultiTaskList: () => ({ commands }) => {
return commands.wrapInList(this.name)
},
unsetMultiTaskList: () => ({ commands }) => {
return commands.liftListItem(this.options.itemTypeName)
},
addCheckboxColumnToMultiTaskList: updateCurrentList(
states => [...states, false],
(labels, _columnCount, showColumnLabels) => ({ labels: [...labels, ''], showColumnLabels }),
),
removeCheckboxColumnFromMultiTaskList: updateCurrentList(
states => (states.length > 1 ? states.slice(0, -1) : states),
(labels, columnCount, showColumnLabels) => ({ labels: labels.slice(0, columnCount), showColumnLabels }),
),
toggleMultiTaskListColumnLabels: () => ({ state, tr, dispatch }: any) => {
const { $from } = state.selection
const listDepth = findTopmostListDepth($from, this.name)
if (listDepth === -1) {
return false
}
const listPos = $from.before(listDepth)
const listNode = state.doc.nodeAt(listPos)
if (!listNode) {
return false
}
const columnCount = getColumnCountFromListNode(listNode)
const currentLabels = normalizeColumnLabels(listNode.attrs?.columnLabels, columnCount)
tr.setNodeMarkup(listPos, undefined, {
...listNode.attrs,
columnLabels: currentLabels,
showColumnLabels: !listNode.attrs?.showColumnLabels,
})
if (dispatch) {
dispatch(tr)
}
return true
},
setMultiTaskListColumnLabel: (index: number, label: string) => ({ state, tr, dispatch }: any) => {
const { $from } = state.selection
const listDepth = findTopmostListDepth($from, this.name)
if (listDepth === -1) {
return false
}
const listPos = $from.before(listDepth)
const listNode = state.doc.nodeAt(listPos)
if (!listNode) {
return false
}
const columnCount = getColumnCountFromListNode(listNode)
const nextLabels = normalizeColumnLabels(listNode.attrs?.columnLabels, columnCount)
nextLabels[index] = label
tr.setNodeMarkup(listPos, undefined, {
...listNode.attrs,
columnLabels: nextLabels,
showColumnLabels: true,
})
if (dispatch) {
dispatch(tr)
}
return true
},
}
},
addNodeView() {
return ({ editor, node, getPos }) => {
let currentNode = node
const getCurrentListPosition = () => {
if (typeof getPos === 'function') {
const position = getPos()
return typeof position === 'number' ? position : null
}
return null
}
const isRootList = () => {
const position = getCurrentListPosition()
if (position === null) {
return true
}
const resolvedPos = editor.state.doc.resolve(position + 1)
let seenCurrentList = false
for (let depth = resolvedPos.depth; depth > 0; depth -= 1) {
if (resolvedPos.node(depth).type.name !== this.name) {
continue
}
if (!seenCurrentList) {
seenCurrentList = true
continue
}
return false
}
if (!seenCurrentList) {
return true
}
return true
}
const dom = document.createElement('div')
dom.classList.add('relative', 'group/multi-task-list')
const header = document.createElement('div')
header.classList.add('mb-1')
const controls = document.createElement('div')
controls.contentEditable = 'false'
controls.classList.add(
'absolute', 'right-0', 'top-0', 'z-[220]',
'opacity-70', 'transition-opacity',
'hover:opacity-100', 'group-hover/multi-task-list:opacity-100',
'pointer-events-auto'
)
const ControlsMenu: Component = () => (
<div class="flex items-center gap-1">
<button
type="button"
class="h-7 w-7 p-0 rounded-xl border border-border/60 bg-background/95 hover:bg-muted text-muted-foreground hover:text-foreground shadow-md inline-flex items-center justify-center"
onMouseDown={(event: MouseEvent) => {
event.preventDefault()
event.stopPropagation()
}}
onClick={() => {
editor.chain().focus().toggleMultiTaskListColumnLabels().run()
}}
>
<MoreHorizontal size={14} />
</button>
<div class="flex flex-col gap-1">
<button
type="button"
class="h-7 w-7 p-0 rounded-xl border border-border/60 bg-background/95 hover:bg-muted text-muted-foreground hover:text-foreground shadow-md inline-flex items-center justify-center"
onMouseDown={(event: MouseEvent) => {
event.preventDefault()
event.stopPropagation()
}}
onClick={() => {
editor.chain().focus().addCheckboxColumnToMultiTaskList().run()
}}
>
<Plus size={14} />
</button>
<button
type="button"
class="h-7 w-7 p-0 rounded-xl border border-border/60 bg-background/95 hover:bg-muted text-muted-foreground hover:text-foreground shadow-md inline-flex items-center justify-center"
onMouseDown={(event: MouseEvent) => {
event.preventDefault()
event.stopPropagation()
}}
onClick={() => {
editor.chain().focus().removeCheckboxColumnFromMultiTaskList().run()
}}
>
<Minus size={14} />
</button>
</div>
</div>
)
const disposeControls = render(() => <ControlsMenu />, controls)
const contentDOM = document.createElement('ul')
contentDOM.dataset.type = 'multiTaskList'
contentDOM.classList.add('not-prose', 'pl-2')
const renderHeader = () => {
const columnCount = getColumnCountFromListNode(currentNode)
const labels = normalizeColumnLabels(currentNode.attrs?.columnLabels, columnCount)
const showColumnLabels = Boolean(currentNode.attrs?.showColumnLabels)
const rootList = isRootList()
contentDOM.dataset.showColumnLabels = rootList && showColumnLabels ? 'true' : 'false'
if (!rootList || !showColumnLabels) {
header.replaceChildren()
header.classList.add('hidden')
return
}
header.classList.remove('hidden')
const row = document.createElement('div')
row.classList.add('flex', 'items-end', 'pr-10')
const labelsRow = document.createElement('div')
labelsRow.classList.add('flex', 'items-center', 'gap-2', 'mr-3', 'select-none', 'shrink-0')
labels.forEach((label, index) => {
const cell = document.createElement('input')
cell.type = 'text'
cell.value = label
cell.placeholder = `Col ${index + 1}`
cell.classList.add(
'w-5', 'bg-transparent', 'border-b', 'border-border/60',
'text-center', 'text-[9px]', 'font-semibold', 'uppercase',
'tracking-wide', 'text-muted-foreground', 'outline-none',
'focus:border-primary', 'placeholder:text-muted-foreground/40'
)
const commitLabel = () => {
const position = getCurrentListPosition()
if (position === null) {
return
}
editor.commands.command(({ tr }) => {
const listNode = editor.state.doc.nodeAt(position)
if (!listNode) {
return false
}
const columnCount = getColumnCountFromListNode(listNode)
const nextLabels = normalizeColumnLabels(listNode.attrs?.columnLabels, columnCount)
nextLabels[index] = cell.value
tr.setNodeMarkup(position, undefined, {
...listNode.attrs,
columnLabels: nextLabels,
showColumnLabels: true,
})
return true
})
}
cell.addEventListener('mousedown', event => {
event.stopPropagation()
})
cell.addEventListener('keydown', event => {
if (event.key === 'Enter') {
event.preventDefault()
cell.blur()
}
})
cell.addEventListener('blur', () => {
commitLabel()
})
labelsRow.appendChild(cell)
})
const spacer = document.createElement('div')
spacer.classList.add('flex-1', 'min-w-0')
row.appendChild(labelsRow)
row.appendChild(spacer)
header.replaceChildren(row)
}
renderHeader()
controls.style.display = isRootList() ? 'block' : 'none'
dom.appendChild(header)
dom.appendChild(controls)
dom.appendChild(contentDOM)
return {
dom,
contentDOM,
stopEvent: event =>
controls.contains(event.target as globalThis.Node | null) ||
header.contains(event.target as globalThis.Node | null),
update: updatedNode => {
if (updatedNode.type.name !== this.name) {
return false
}
currentNode = updatedNode
controls.style.display = isRootList() ? 'block' : 'none'
renderHeader()
return true
},
destroy: () => {
disposeControls()
},
}
}
},
})
+102 -63
View File
@@ -1,11 +1,12 @@
import { type Component, For, Show } from "solid-js";
import { LayoutDashboard, ListTodo, Settings, Clock, ArrowUpCircle, PanelLeftClose, Snowflake, Pickaxe, ChevronDown, Gauge, TrendingUp, Users, Box, BarChart3, HelpCircle } from "lucide-solid";
import { LayoutDashboard, ListTodo, Settings, Clock, ArrowUpCircle, PanelLeftClose, Snowflake, Pickaxe, ChevronDown, Gauge, TrendingUp, Users, Box, BarChart3, HelpCircle, MessageCircle } from "lucide-solid";
import { cn } from "@/lib/utils";
import { Button } from "./ui/button";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { store, currentTaskContext, setCurrentTaskContext } from "@/store";
import { store, currentTaskContext, setCurrentTaskContext, loadTasksForOwner } from "@/store";
import { pb } from "@/lib/pocketbase";
import { createResource, createSignal, createEffect } from "solid-js";
import { createSignal, createEffect } from "solid-js";
import { AIHelpPanel } from "./AIHelpPanel";
interface NavItem {
icon: any;
@@ -34,11 +35,11 @@ interface MobileNavGroup {
const mobileNavGroups: MobileNavGroup[] = [
{ icon: ListTodo, label: "Focus", view: "critical" },
{ icon: LayoutDashboard, label: "Matrix", view: "matrix" },
{
icon: TrendingUp, label: "Value", items: [
{ icon: ArrowUpCircle, label: "Priority", view: "priority" },
{ icon: Clock, label: "Urgency", view: "urgency" },
{ icon: LayoutDashboard, label: "Matrix", view: "matrix" },
]
},
{
@@ -48,6 +49,7 @@ const mobileNavGroups: MobileNavGroup[] = [
{ icon: BarChart3, label: "Progress", view: "progress" },
]
},
{ icon: MessageCircle, label: "Ask", view: "help_ai" },
{ icon: Settings, label: "Settings", view: "settings" },
];
@@ -58,7 +60,13 @@ export const BottomNav: Component<{ currentView: string; setView: (v: string) =>
};
return (
<nav class="fixed bottom-0 left-0 right-0 bg-background border-t border-border flex justify-around items-center h-16 md:hidden z-50">
<nav
class="fixed bottom-0 left-0 right-0 bg-background border-t border-border flex justify-around items-center md:hidden z-50"
style={{
"padding-bottom": "env(safe-area-inset-bottom, 0px)",
height: "calc(4rem + env(safe-area-inset-bottom, 0px))"
}}
>
<For each={mobileNavGroups}>
{(group) => (
<Show when={group.items} fallback={
@@ -121,7 +129,6 @@ export const ContextSwitcher: Component<{
onOpenChange?: (open: boolean) => void;
isMobile?: boolean;
}> = (props) => {
const currentUserId = pb.authStore.model?.id;
const [open, setOpen] = createSignal(false);
// Sync with parent if needed
@@ -136,41 +143,42 @@ export const ContextSwitcher: Component<{
}
});
const [oversightUsers] = createResource(
() => [...new Set(store.shareRules.filter(r => r.targetUserId === currentUserId && r.type === 'all').map(r => r.ownerId))],
async (ownerIds) => {
if (ownerIds.length === 0) return [];
try {
// Fetch user names for these IDs in a single query
const filter = ownerIds.map(id => `id="${id}"`).join(' || ');
const users = await pb.collection('users').getFullList({
filter,
fields: 'id,name,email',
requestKey: null // Disable auto-cancellation
});
return users.map(u => ({ id: u.id, name: u.name || u.email }));
} catch (e: any) {
if (e.isAbort) return []; // Ignore explicit aborts
console.error("Failed to load oversight users", e);
return [];
}
}
);
const ctx = currentTaskContext; // Accessor
const personalContext = () =>
store.contexts.find(context =>
!context.deletedAt &&
context.kind === "user" &&
context.targetUserId === pb.authStore.model?.id
) || null;
const isPersonalContext = () => {
const current = ctx();
const personal = personalContext();
return current === 'mine' || (
typeof current === "object" &&
"bucketId" in current &&
!!personal &&
current.bucketId === personal.id
);
};
const label = () => {
const c = ctx();
return c === 'mine' ? "My Bucket" : c.name;
return isPersonalContext() ? "My Bucket" : (c === 'mine' ? "My Bucket" : c.name);
};
const handleSwitch = (id: string, name: string, type: 'mine' | 'user' | 'bucket' = 'mine') => {
if (type === 'mine') {
setCurrentTaskContext('mine');
const personal = personalContext();
if (personal) {
setCurrentTaskContext({ bucketId: personal.id, name: "My Bucket", isPersonal: true });
} else {
setCurrentTaskContext('mine');
}
} else if (type === 'bucket') {
setCurrentTaskContext({ bucketId: id, name });
} else {
setCurrentTaskContext({ userId: id, name });
void loadTasksForOwner(id);
}
// Close popover
@@ -182,7 +190,7 @@ export const ContextSwitcher: Component<{
}
};
const hasContexts = () => (oversightUsers() && oversightUsers()!.length > 0) || store.subscribedBuckets.length > 0;
const hasContexts = () => !!personalContext() || store.subscribedBuckets.length > 0 || store.supervisedContexts.length > 0;
return (
<Show when={hasContexts()}>
@@ -197,9 +205,9 @@ export const ContextSwitcher: Component<{
<Show when={props.isMobile} fallback={
<>
<div class="flex items-center gap-3 min-w-0">
<div class={cn(
"w-4 h-4 rounded-full flex items-center justify-center shrink-0",
ctx() === 'mine' ? "bg-primary/20 text-primary" : "bg-orange-500/20 text-orange-500"
<div class={cn(
"w-4 h-4 rounded-full flex items-center justify-center shrink-0",
isPersonalContext() ? "bg-primary/20 text-primary" : "bg-orange-500/20 text-orange-500"
)}>
<div class="w-2 h-2 rounded-full bg-current" />
</div>
@@ -209,8 +217,8 @@ export const ContextSwitcher: Component<{
</>
}>
<div class="relative">
<Users size={18} class={cn("transition-transform duration-300", open() && "scale-110", ctx() !== 'mine' && "text-orange-500")} />
{ctx() !== 'mine' && (
<Users size={18} class={cn("transition-transform duration-300", open() && "scale-110", !isPersonalContext() && "text-orange-500")} />
{!isPersonalContext() && (
<div class="absolute -top-1 -right-1 w-2 h-2 rounded-full bg-orange-500 border border-background" />
)}
</div>
@@ -223,7 +231,7 @@ export const ContextSwitcher: Component<{
onClick={() => handleSwitch('mine', '', 'mine')}
class={cn(
"w-full flex items-center gap-2 p-2 rounded-md text-sm transition-colors",
ctx() === 'mine' ? "bg-primary/10 text-primary font-semibold" : "hover:bg-muted text-foreground"
isPersonalContext() ? "bg-primary/10 text-primary font-semibold" : "hover:bg-muted text-foreground"
)}
>
<div class="w-4 h-4 rounded-full bg-primary/20 flex items-center justify-center">
@@ -260,31 +268,32 @@ export const ContextSwitcher: Component<{
</For>
</Show>
<Show when={oversightUsers() && oversightUsers()!.length > 0}>
<Show when={store.supervisedContexts.length > 0}>
<div class="py-1">
<div class="h-px bg-border/50" />
</div>
<div class="px-2 py-1 text-[0.625rem] font-bold uppercase text-muted-foreground tracking-wider flex items-center gap-2">
<span>Shared User Buckets</span>
<span class="ml-auto bg-muted px-1.5 rounded text-[0.5625rem]">{oversightUsers()?.length}</span>
<span>Supervised Contexts</span>
<span class="ml-auto bg-muted px-1.5 rounded text-[0.5625rem]">{store.supervisedContexts.length}</span>
</div>
<For each={oversightUsers()}>
{(user) => (
<For each={store.supervisedContexts}>
{(supervised) => (
<button
onClick={() => handleSwitch(user.id, user.name, 'user')}
onClick={() => handleSwitch(supervised.ownerUserId, supervised.ownerName, 'user')}
class={cn(
"w-full flex items-center gap-2 p-2 rounded-md text-sm transition-colors",
(ctx() as any).userId === user.id ? "bg-orange-500/10 text-orange-600 font-semibold" : "hover:bg-muted text-foreground"
(ctx() as any).userId === supervised.ownerUserId ? "bg-orange-500/10 text-orange-600 font-semibold" : "hover:bg-muted text-foreground"
)}
>
<div class="w-4 h-4 rounded-full bg-orange-500/20 flex items-center justify-center">
<div class="w-2 h-2 rounded-full bg-orange-500" />
</div>
<span class="truncate">{user.name}</span>
<span class="truncate">{supervised.ownerName}</span>
</button>
)}
</For>
</Show>
</div>
</PopoverContent>
</Popover>
@@ -295,6 +304,9 @@ export const ContextSwitcher: Component<{
export const Sidebar: Component<{
currentView: string;
setView: (v: string) => void;
isAIHelpOpen: boolean;
onToggleAIHelp: () => void;
aiHelpTip?: string | null;
isLocked: boolean;
setIsLocked: (v: boolean) => void;
isPeeking: boolean;
@@ -345,9 +357,19 @@ export const Sidebar: Component<{
</Show>
</div>
<div class="px-3 pb-2">
<ContextSwitcher
isLocked={props.isLocked}
isPeeking={props.isPeeking}
setIsPeeking={props.setIsPeeking}
onOpenChange={(open) => {
setSwitcherOpen(open);
props.onDropdownOpenChange?.(open);
}}
/>
</div>
<nav class="flex-1 px-3 space-y-1 mt-2">
<nav class="flex-1 px-3 space-y-1 mt-2 min-h-0">
<For each={desktopNavItems}>
{(item) => (
<button
@@ -366,23 +388,39 @@ export const Sidebar: Component<{
</For>
</nav>
{/* Context Switcher - Only visible if there are oversight contexts */}
<div class={cn(
"px-3 mb-2 transition-opacity duration-200",
(!props.isLocked && !props.isPeeking) ? "opacity-0 pointer-events-none" : "opacity-100 delay-100"
)}>
<ContextSwitcher
isLocked={props.isLocked}
isPeeking={props.isPeeking}
setIsPeeking={props.setIsPeeking}
onOpenChange={(open) => {
setSwitcherOpen(open);
props.onDropdownOpenChange?.(open);
}}
/>
</div>
<div class="px-3 mt-auto pb-3 space-y-3">
<div
class={cn(
"transition-all duration-300 ease-out overflow-hidden",
props.isAIHelpOpen ? "max-h-[44rem] opacity-100" : "max-h-0 opacity-0 pointer-events-none"
)}
>
<AIHelpPanel variant="sidebar" class="h-[42rem]" />
</div>
<div class="px-3 border-t border-border mt-auto pt-3 space-y-1 pb-3">
<div class="border-t border-border pt-3 space-y-1">
<button
onClick={props.onToggleAIHelp}
class={cn(
"flex w-full items-start gap-3 px-3 py-2.5 rounded-lg transition-all duration-200 group text-sm text-left",
props.isAIHelpOpen
? "bg-primary text-primary-foreground shadow-sm"
: "text-muted-foreground hover:bg-muted hover:text-foreground"
)}
>
<MessageCircle size={16} class={cn("transition-transform group-hover:scale-110 mt-0.5 shrink-0")} />
<div class="min-w-0 flex-1">
<div class="font-medium">Ask Questions</div>
<div
class={cn(
"text-[0.625rem] leading-snug transition-opacity duration-700",
props.aiHelpTip ? "opacity-50" : "opacity-0"
)}
>
{props.aiHelpTip || " "}
</div>
</div>
</button>
<button
onClick={() => props.setView("help")}
class={cn(
@@ -393,7 +431,7 @@ export const Sidebar: Component<{
)}
>
<HelpCircle size={16} class={cn("transition-transform group-hover:scale-110")} />
<span class="font-medium">Help Guide</span>
<span class="font-medium">Help</span>
</button>
<button
onClick={() => props.setView("settings")}
@@ -407,6 +445,7 @@ export const Sidebar: Component<{
<Settings size={16} class={cn("transition-transform group-hover:scale-110")} />
<span class="font-medium">Settings</span>
</button>
</div>
</div>
</aside>
);
+187 -41
View File
@@ -1,36 +1,185 @@
import { type Component, createSignal, createMemo, For, Show } from "solid-js";
import { store } from "@/store";
import { type Component, createMemo, createResource, For, Show } from "solid-js";
import { store, setNoteFilter, getFavoriteTag } from "@/store";
import { pb } from "@/lib/pocketbase";
import { Plus, Lock, Search } from "lucide-solid";
import { Plus, Lock, Star } from "lucide-solid";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
import { NOTES_COLLECTION } from "@/lib/constants";
import { FilterBar } from "./FilterBar";
import { normalizeNoteKey } from "@/lib/tags";
export const NotesSidebar: Component<{
selectedNoteId: string | null;
setSelectedNoteId: (id: string | null) => void;
}> = (props) => {
const [searchQuery, setSearchQuery] = createSignal("");
const currentUserId = pb.authStore.model?.id;
const [accessibleJobs] = createResource(currentUserId, async (userId) => {
if (!userId) return new Set<string>();
try {
const items = await pb.collection('Job_Info_Prod').getFullList({
filter: [
`EstimatorUser = "${userId}"`,
`OfficeRepUser = "${userId}"`,
`ProjectManagerUser = "${userId}"`,
`Estimator = "${userId}"`,
`Office_Rep = "${userId}"`,
`Project_Manager = "${userId}"`
].join(" || "),
fields: "id",
requestKey: null
});
return new Set(items.map((r: any) => String(r.id)));
} catch (e) {
console.warn("Failed to load job access list for notes filter:", e);
return new Set<string>();
}
});
const [jobFilterIds] = createResource(
() => {
const f = store.noteFilter;
const normalize = (items: Array<string | { name: string; excluded: boolean }> | undefined) => {
if (!items) return [] as { name: string; excluded: boolean }[];
return items.map((i: any) => (typeof i === "string" ? { name: i, excluded: false } : { name: i.name, excluded: !!i.excluded }));
};
const status = normalize(f.jobStatus as any).sort((a, b) => a.name.localeCompare(b.name));
const division = normalize(f.jobDivision as any).sort((a, b) => a.name.localeCompare(b.name));
const key = JSON.stringify({ status, division });
return key;
},
async (key) => {
if (!key) return null;
const parsed = JSON.parse(key) as { status: Array<{ name: string; excluded: boolean }>; division: Array<{ name: string; excluded: boolean }> };
const status = parsed.status || [];
const division = parsed.division || [];
if (status.length === 0 && division.length === 0) return null;
const parts: string[] = [];
const statusInclude = status.filter(s => !s.excluded).map(s => s.name);
const statusExclude = status.filter(s => s.excluded).map(s => s.name);
const divisionInclude = division.filter(d => !d.excluded).map(d => d.name);
const divisionExclude = division.filter(d => d.excluded).map(d => d.name);
if (statusInclude.length > 0) {
parts.push(`(${statusInclude.map(s => `Job_Status = "${s}"`).join(" || ")})`);
}
if (statusExclude.length > 0) {
parts.push(`(${statusExclude.map(s => `Job_Status != "${s}"`).join(" && ")})`);
}
if (divisionInclude.length > 0) {
parts.push(`(${divisionInclude.map(d => `Job_Division = "${d}"`).join(" || ")})`);
}
if (divisionExclude.length > 0) {
parts.push(`(${divisionExclude.map(d => `Job_Division != "${d}"`).join(" && ")})`);
}
const filter = parts.join(" && ");
try {
const items = await pb.collection('Job_Info_Prod').getFullList({
filter,
fields: "id",
requestKey: null
});
return new Set(items.map((r: any) => String(r.id)));
} catch (e) {
console.warn("Failed to load job filter list for notes:", e);
return new Set<string>();
}
}
);
const filteredNotes = createMemo(() => {
let n = store.notes;
if (searchQuery()) {
const q = searchQuery().toLowerCase();
n = n.filter(note => note.title.toLowerCase().includes(q) || note.tags.some(t => t.toLowerCase().includes(q)));
const filter = store.noteFilter;
let n = store.notes.filter(note => {
if (note.deletedAt) return false;
// Hide child notes from the main list
if (note.tags && note.tags.some(t => t.startsWith("child-of-"))) return false;
return true;
});
// Query Filter
if (filter.query) {
const q = filter.query.toLowerCase();
n = n.filter(note =>
note.title.toLowerCase().includes(q) ||
note.tags.some(t => t.toLowerCase().includes(q))
);
}
return n;
// Tags Filter
if (filter.tags.length > 0) {
const required = filter.tags.filter(t => !t.excluded).map(t => t.name);
const excluded = filter.tags.filter(t => t.excluded).map(t => t.name);
if (required.length > 0) {
n = n.filter(note => required.some(r => note.tags.includes(r)));
}
if (excluded.length > 0) {
n = n.filter(note => !excluded.some(e => note.tags.includes(e)));
}
}
// Edited Today Filter
if (filter.editedToday) {
const today = new Date();
today.setHours(0, 0, 0, 0);
n = n.filter(note => {
const updated = new Date(note.updated);
return updated >= today;
});
}
// Owned By Me Filter
if (filter.ownedByMe) {
const accessible = accessibleJobs();
n = n.filter(note => {
if (note.tags?.includes("prism_job")) {
if (!accessible) return false;
const jobIdTag = note.tags.find(t => t.startsWith("job_id:"));
const jobId = jobIdTag?.split(":")[1]?.trim();
if (!jobId) return false;
return accessible.has(jobId);
}
return note.user === currentUserId;
});
}
// Job Status / Division Filter (notes only)
if ((filter.jobStatus?.length || 0) > 0 || (filter.jobDivision?.length || 0) > 0) {
const allowedJobIds = jobFilterIds();
n = n.filter(note => {
if (!note.tags?.includes("prism_job")) return false;
if (!allowedJobIds) return false;
const jobIdTag = note.tags.find(t => t.startsWith("job_id:"));
const jobId = jobIdTag?.split(":")[1]?.trim();
if (!jobId) return false;
return allowedJobIds.has(jobId);
});
}
// Starred Filter
if (filter.starred) {
n = n.filter(note => note.tags?.includes(getFavoriteTag()));
}
return n.sort((a, b) => {
const titleA = a.title || "Untitled";
const titleB = b.title || "Untitled";
return titleB.localeCompare(titleA);
});
});
const handleCreateNote = async () => {
if (!currentUserId) return;
try {
const keyBase = normalizeNoteKey("new-note");
const result = await pb.collection(NOTES_COLLECTION).create({
title: "New Note",
key: `${keyBase}-${Date.now()}`,
content: "",
tags: [],
isPrivate: false,
user: currentUserId,
tasks: []
});
props.setSelectedNoteId(result.id);
} catch (e) {
@@ -40,26 +189,34 @@ export const NotesSidebar: Component<{
return (
<div class="flex flex-col h-full bg-card">
{/* Header */}
<div class="p-4 border-b border-border bg-card/50 backdrop-blur flex justify-between items-center gap-2">
<div class="relative flex-1">
<Search size={14} class="absolute left-2.5 top-1/2 -translate-y-1/2 text-muted-foreground/50" />
<input
type="text"
placeholder="Search notes..."
value={searchQuery()}
onInput={(e) => setSearchQuery(e.currentTarget.value)}
class="w-full bg-muted/50 border border-border/50 rounded-lg pl-8 pr-3 py-1.5 text-xs outline-none focus:ring-1 focus:ring-primary/30"
/>
<div class="z-20">
{/* Header */}
<div class="p-4 border-b border-border bg-card/50 backdrop-blur flex justify-between items-center gap-2">
<div class="flex-1">
<input
type="text"
placeholder="Search notes..."
value={store.noteFilter.query}
onInput={(e) => setNoteFilter({ query: e.currentTarget.value })}
class="w-full bg-muted/30 border border-border/50 rounded-xl px-3 py-2 text-sm outline-none focus:ring-2 focus:ring-primary/20 focus:border-primary/40 transition-all placeholder:text-muted-foreground/30"
/>
</div>
<div class="flex items-center gap-2 shrink-0">
<FilterBar
mode="notes"
class="relative top-0 right-0 z-0"
triggerClass="h-9 w-9 p-0 bg-muted/40 hover:bg-muted/60 border-border/40"
/>
<Button onClick={handleCreateNote} size="sm" variant="default" class="h-9 w-9 p-0 shrink-0 shadow-sm rounded-xl">
<Plus size={18} />
</Button>
</div>
</div>
<Button onClick={handleCreateNote} size="sm" variant="default" class="h-8 w-8 p-0 shrink-0 shadow-sm">
<Plus size={16} />
</Button>
</div>
{/* List */}
<div class="flex-1 overflow-y-auto p-2 space-y-1 pb-20 md:pb-2">
<For each={filteredNotes()} fallback={<div class="p-4 text-center text-xs text-muted-foreground">No notes found.</div>}>
<For each={filteredNotes()} fallback={<div class="p-4 text-center text-xs text-muted-foreground">{store.isNotesLoading ? "Loading notes..." : "No notes found."}</div>}>
{(note) => (
<button
onClick={() => props.setSelectedNoteId(note.id)}
@@ -71,27 +228,16 @@ export const NotesSidebar: Component<{
)}
>
<div class="font-semibold text-sm truncate flex items-center justify-between">
<span>{note.title || "Untitled"}</span>
<div class="flex items-center gap-1.5 truncate">
<Show when={note.tags?.includes(getFavoriteTag())}>
<Star size={12} class="fill-amber-500 text-amber-500 shrink-0" />
</Show>
<span class="truncate">{note.title || "Untitled"}</span>
</div>
<Show when={note.isPrivate}>
<Lock size={12} class="text-orange-500 shrink-0 ml-2" />
</Show>
</div>
<Show when={note.tags && note.tags.length > 0}>
<div class="flex gap-1 mt-2 flex-wrap">
<For each={note.tags.slice(0, 3)}>
{(t) => (
<span class="text-[0.6rem] px-1.5 py-0.5 bg-muted rounded-md text-muted-foreground whitespace-nowrap border border-border/50">
{t}
</span>
)}
</For>
<Show when={note.tags.length > 3}>
<span class="text-[0.6rem] px-1.5 py-0.5 text-muted-foreground whitespace-nowrap shrink-0">
+{note.tags.length - 3}
</span>
</Show>
</div>
</Show>
</button>
)}
</For>
+148
View File
@@ -0,0 +1,148 @@
import { type Component, createEffect, onCleanup } from 'solid-js';
import { useRegisterSW } from 'virtual:pwa-register/solid';
import { toast } from 'solid-sonner';
// We export a function so other parts of the app can manually trigger an update
let manualUpdateFn: (() => void) | undefined;
let updateAndReloadFn: (() => void) | undefined;
export const forceServiceWorkerUpdate = () => {
if (manualUpdateFn) {
manualUpdateFn();
} else {
console.warn("PWA Updater not initialized yet");
}
};
export const updateAndReloadApp = () => {
if (updateAndReloadFn) {
updateAndReloadFn();
} else {
console.warn("PWA Updater not initialized yet; reloading directly.");
window.location.reload();
}
};
export const PwaUpdater: Component = () => {
let registration: ServiceWorkerRegistration | undefined;
const {
needRefresh: [needRefresh],
updateServiceWorker,
} = useRegisterSW({
onRegistered(r: ServiceWorkerRegistration | undefined) {
console.log('SW Registered:', r);
registration = r;
// Periodically check for updates (every 1 hour)
if (r) {
setInterval(() => {
console.log('Periodic SW update check...');
r.update();
}, 60 * 60 * 1000);
}
},
onRegisterError(error: Error | any) {
console.error('SW registration error', error);
},
});
createEffect(() => {
// Expose function globally
manualUpdateFn = () => {
if (needRefresh()) {
console.log('User manually forced an update. Reloading now...');
updateServiceWorker(true);
} else if (registration) {
console.log('User manually asked to check for updates...');
registration.update();
}
};
updateAndReloadFn = () => {
if (needRefresh()) {
console.log('Update ready. Reloading now...');
updateServiceWorker(true);
return;
}
if (registration) {
console.log('Checking for updates before reload...');
registration.update();
}
const start = Date.now();
const timeoutMs = 5000;
const poll = window.setInterval(() => {
if (needRefresh()) {
console.log('Update found. Reloading with new SW...');
window.clearInterval(poll);
updateServiceWorker(true);
} else if (Date.now() - start > timeoutMs) {
console.log('No update detected quickly. Reloading now...');
window.clearInterval(poll);
toast.info("No update found");
}
}, 250);
};
});
createEffect(() => {
// Check for updates when the app regains focus/visibility
const handleVisibilityFocus = () => {
if (document.visibilityState === 'visible' && registration) {
console.log('App became visible, checking for SW updates...');
registration.update();
}
};
document.addEventListener('visibilitychange', handleVisibilityFocus);
window.addEventListener('focus', handleVisibilityFocus);
onCleanup(() => {
document.removeEventListener('visibilitychange', handleVisibilityFocus);
window.removeEventListener('focus', handleVisibilityFocus);
});
});
createEffect(() => {
if (needRefresh()) {
console.log('New PWA version available. Monitoring for idle/hidden state to update...');
let idleTimeout: number | undefined;
const triggerUpdate = () => {
console.log('Triggering PWA update...');
updateServiceWorker(true);
};
const resetIdle = () => {
if (idleTimeout) window.clearTimeout(idleTimeout);
// 10 minutes idle
idleTimeout = window.setTimeout(triggerUpdate, 10 * 60 * 1000);
};
const handleVisibility = () => {
if (document.visibilityState === 'hidden') {
triggerUpdate();
}
};
// Start idle timer
resetIdle();
// Listen for user activity
const events = ['mousedown', 'mousemove', 'keydown', 'scroll', 'touchstart'];
events.forEach(e => window.addEventListener(e, resetIdle, { passive: true }));
document.addEventListener('visibilitychange', handleVisibility);
onCleanup(() => {
if (idleTimeout) window.clearTimeout(idleTimeout);
events.forEach(e => window.removeEventListener(e, resetIdle));
document.removeEventListener('visibilitychange', handleVisibility);
});
}
});
return null;
};
+4 -36
View File
@@ -1,5 +1,5 @@
import { type Component, createSignal, createMemo, onCleanup, onMount } from "solid-js";
import { store, currentTaskContext } from "@/store";
import { type Component, createSignal, onCleanup, onMount } from "solid-js";
import { store } from "@/store";
import { Plus, X } from "lucide-solid";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
@@ -7,37 +7,6 @@ import { QuickEntryForm } from "./QuickEntryForm";
export const QuickEntry: Component = () => {
const [isOpen, setIsOpen] = createSignal(false);
// Autofill tags based on context when opening
const initialTags = createMemo(() => {
if (!isOpen()) return [];
const tags: string[] = [];
const ctx = currentTaskContext();
if (store.isNotepadMode) {
// Not in a specific context, but in Notes mode.
// We use global window variable set by Layout to get the active note id
const activeNoteId = (window as any)._activeNoteId;
if (activeNoteId) {
const note = store.notes.find(n => n.id === activeNoteId);
if (note && note.title) {
const noteTagName = note.title.trim();
if (noteTagName) tags.push(noteTagName);
}
}
} else if (typeof ctx === 'object') {
if ('bucketId' in ctx) {
// Bucket View: Autofill bucket name
const bucketName = ctx.name;
if (bucketName) tags.push(`@${bucketName}`);
} else if ('userId' in ctx) {
// Shared User View: Autofill user name (assuming name IS the tag for sharing)
const userName = ctx.name;
if (userName) tags.push(`@${userName}`);
}
}
return tags;
});
const handleKeyDown = (e: KeyboardEvent) => {
if ((e.metaKey || e.ctrlKey) && e.key === "k") {
@@ -66,10 +35,10 @@ export const QuickEntry: Component = () => {
</Button>
{isOpen() && (
<div class="fixed inset-0 z-[100] flex items-end md:items-start justify-center md:pt-[15vh] px-0 md:px-4 pb-0 md:pb-4">
<div class="fixed inset-0 z-[100] flex items-start justify-center md:pt-[15vh] px-0 md:px-4 pb-0 md:pb-4">
<div class="absolute inset-0 bg-background/80 backdrop-blur-sm" onClick={() => setIsOpen(false)} />
<div class="relative w-full max-w-xl max-h-[100vh] md:max-h-[80vh] bg-card border border-border rounded-t-2xl md:rounded-2xl shadow-2xl overflow-hidden animate-in fade-in slide-in-from-bottom-4 md:zoom-in-95 duration-200 flex flex-col">
<div class="relative w-full max-w-xl max-h-[100vh] md:max-h-[80vh] bg-card border border-border rounded-b-2xl md:rounded-2xl shadow-2xl overflow-hidden animate-in fade-in slide-in-from-top-4 md:slide-in-from-bottom-0 md:zoom-in-95 duration-200 flex flex-col">
<div class="absolute top-3 right-4 z-[101]">
<Button
size="icon"
@@ -85,7 +54,6 @@ export const QuickEntry: Component = () => {
<QuickEntryForm
onSuccess={() => setIsOpen(false)}
autoFocus={true}
initialTags={initialTags()}
/>
</div>
</div>
+147 -45
View File
@@ -1,8 +1,9 @@
import { type Component, createSignal, createEffect, onMount, For, Show, Suspense, lazy } from "solid-js";
import { addTask, calculateDateFromUrgency, calculateUrgencyFromDate, store, upsertTagDefinition, type TaskTemplate } from "@/store";
import { Search, Command, Clock, ArrowUpCircle, Copy, ChevronDown, Gauge } from "lucide-solid";
import { addTask, calculateDateFromUrgency, calculateUrgencyFromDate, store, upsertTagDefinition, type TaskTemplate, currentTaskContext, isCurrentUserContextTag } from "@/store";
import { Search, Command, Clock, ArrowUpCircle, Copy, ChevronDown, Gauge, RotateCcw } from "lucide-solid";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { cn } from "@/lib/utils";
import { TextField, TextFieldInput } from "@/components/ui/textfield";
import { Select, SelectContent, SelectItem, SelectTrigger } from "@/components/ui/select";
import { TagPicker } from "@/components/TagPicker";
@@ -11,6 +12,7 @@ import { getThemeAdjustedColor } from "@/lib/colors";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { toast } from "solid-sonner";
import { PRIORITY_OPTIONS, URGENCY_OPTIONS, SIZE_OPTIONS } from "@/lib/constants";
import { buildNoteTag } from "@/lib/tags";
const TaskEditor = lazy(() => import("./TaskEditor").then(m => ({ default: m.TaskEditor })));
@@ -21,19 +23,36 @@ interface QuickEntryFormProps {
initialTags?: string[];
}
// Global state for QuickEntry persistence
const [input, setInput] = createSignal("");
const [priority, setPriority] = createSignal<string>("");
const [urgency, setUrgency] = createSignal<string>("");
const [tags, setTags] = createSignal<string[]>([]);
const [tagShareModes, setTagShareModes] = createSignal<Record<string, "handoff" | "collaborative">>({});
const [description, setDescription] = createSignal("");
const [size, setSize] = createSignal<string>("3");
const [dateString, setDateString] = createSignal<string>("");
const [showValidation, setShowValidation] = createSignal(false);
let lastParsedTags: string[] = [];
const resetQuickEntryState = () => {
setInput("");
setPriority("");
setUrgency("");
setTags([]);
setTagShareModes({});
setDescription("");
setSize("3");
setDateString("");
setShowValidation(false);
lastParsedTags = [];
};
export const QuickEntryForm: Component<QuickEntryFormProps> = (props) => {
const { resolvedTheme } = useTheme();
// Diff tracking for reactive tags
let lastParsedTags: string[] = [];
const [input, setInput] = createSignal(props.initialTitle || "");
const [priority, setPriority] = createSignal<string>("");
const [urgency, setUrgency] = createSignal<string>("");
const [tags, setTags] = createSignal<string[]>(props.initialTags || []);
const [description, setDescription] = createSignal("");
const [editorInstance, setEditorInstance] = createSignal<any>(null);
const [size, setSize] = createSignal<string>("3");
const [dateString, setDateString] = createSignal<string>("");
let inputRef: HTMLInputElement | undefined;
@@ -41,6 +60,9 @@ export const QuickEntryForm: Component<QuickEntryFormProps> = (props) => {
if (props.autoFocus) {
setTimeout(() => inputRef?.focus(), 100);
}
if (props.initialTitle && !input()) {
setInput(props.initialTitle);
}
});
// Reactive sync from props (handles context changes while open)
@@ -124,11 +146,17 @@ export const QuickEntryForm: Component<QuickEntryFormProps> = (props) => {
const parseAndAdd = async () => {
let text = input();
if (!text || !priority() || !urgency()) return;
if (!text || !priority() || !urgency()) {
setShowValidation(true);
toast.error("Please fill in all required fields (Title, Priority, Urgency)");
if (!text) inputRef?.focus();
return;
}
let finalPriority = parseInt(priority());
let finalUrgency = parseInt(urgency());
const finalTags = [...tags()];
const finalTagShareModes = { ...tagShareModes() };
const pMatch = text.match(/\/p\s*(\d+)/);
if (pMatch) {
@@ -187,22 +215,43 @@ export const QuickEntryForm: Component<QuickEntryFormProps> = (props) => {
finalDueDate = new Date(dateStr);
}
// Apply context tags at the time of creation
const ctx = currentTaskContext();
if (store.isNotepadMode) {
const activeNoteId = (window as any)._activeNoteId;
if (activeNoteId) {
const note = store.notes.find(n => n.id === activeNoteId);
if (note) {
const noteTagName = buildNoteTag(note.key || note.title);
if (noteTagName && !finalTags.includes(noteTagName)) finalTags.push(noteTagName);
}
}
} else if (typeof ctx === 'object') {
if ('bucketId' in ctx) {
const bucketName = store.contexts.find(context => context.id === ctx.bucketId)?.displayName || ctx.name;
if (bucketName) {
const tag = `@${bucketName}`;
if (!finalTags.includes(tag)) finalTags.push(tag);
}
} else if ('userId' in ctx) {
const userName = ctx.name;
if (userName) {
const tag = `@${userName}`;
if (!finalTags.includes(tag)) finalTags.push(tag);
}
}
}
addTask(title.replace(/\s+/g, " ").trim(), {
priority: finalPriority,
dueDate: finalDueDate || undefined,
tags: finalTags,
content: description(),
size: parseInt(size())
size: parseInt(size()),
shareModeByTag: finalTagShareModes
});
setInput("");
setPriority("");
setUrgency("");
setTags([]);
setDescription("");
setSize("3");
lastParsedTags = [];
setDateString("");
resetQuickEntryState();
const instance = editorInstance();
if (instance) instance.commands.setContent("");
@@ -232,20 +281,33 @@ export const QuickEntryForm: Component<QuickEntryFormProps> = (props) => {
return (
<div class="flex flex-col h-full bg-card">
<div class="flex items-center px-4 py-3 border-b border-border sticky top-0 bg-card z-10">
<Search class="text-muted-foreground mr-3" size={20} />
<TextField class="flex-1">
<TextFieldInput
id="quick-entry-input"
name="task-title"
ref={inputRef}
value={input()}
onInput={(e) => setInput(e.currentTarget.value)}
onKeyDown={(e) => e.key === "Enter" && parseAndAdd()}
placeholder="Task title... type /p1-10 for priority and /u1-10 for urgency"
class="border-none shadow-none focus-visible:ring-0 text-lg"
/>
</TextField>
<div class={cn("flex flex-col border-b border-border sticky top-0 bg-card z-10 transition-colors", showValidation() && !input() && "bg-destructive/5")}>
<div class="flex items-center px-4 py-3">
<Search class={cn("text-muted-foreground mr-3 transition-colors", showValidation() && !input() && "text-destructive")} size={20} />
<TextField
class="flex-1"
validationState={showValidation() && !input() ? "invalid" : "valid"}
>
<TextFieldInput
id="quick-entry-input"
name="task-title"
ref={inputRef}
value={input()}
onInput={(e) => setInput(e.currentTarget.value)}
onKeyDown={(e) => e.key === "Enter" && parseAndAdd()}
placeholder="Task title... type /p1-10 for priority and /u1-10 for urgency"
class="border-none shadow-none focus-visible:ring-0 text-lg p-0"
/>
</TextField>
</div>
<Show when={showValidation() && !input()}>
<div class="px-4 pb-2 -mt-1 flex items-center gap-1.5 animate-in fade-in slide-in-from-top-1 duration-200">
<div class="w-1.5 h-1.5 rounded-full bg-destructive" />
<span class="text-[0.625rem] font-bold uppercase tracking-widest text-destructive/80">
Title is required to create a task
</span>
</div>
</Show>
</div>
<div class="flex-1 overflow-y-auto overscroll-contain">
@@ -269,11 +331,17 @@ export const QuickEntryForm: Component<QuickEntryFormProps> = (props) => {
{props.item.rawValue.label}
</SelectItem>
)}
validationState={showValidation() && !priority() ? "invalid" : "valid"}
>
<SelectTrigger class="w-full bg-background border-none shadow-sm h-10 px-3 overflow-hidden">
<SelectTrigger
class={cn(
"w-full bg-background border shadow-sm h-10 px-3 overflow-hidden transition-colors border-transparent",
showValidation() && !priority() && "border-destructive/50 bg-destructive/5"
)}
>
<div class="flex items-center gap-2 truncate">
<ArrowUpCircle size={14} class="text-primary shrink-0" />
<span class="text-sm font-medium truncate">
<ArrowUpCircle size={14} class={cn("text-primary shrink-0", showValidation() && !priority() && "text-destructive")} />
<span class={cn("text-sm font-medium truncate", showValidation() && !priority() && "text-destructive")}>
{PRIORITY_OPTIONS.find(o => o.value === priority())?.label || (priority() === "" ? "Pick Priority" : priority())}
</span>
</div>
@@ -328,11 +396,17 @@ export const QuickEntryForm: Component<QuickEntryFormProps> = (props) => {
{props.item.rawValue.label}
</SelectItem>
)}
validationState={showValidation() && !urgency() ? "invalid" : "valid"}
>
<SelectTrigger class="w-full bg-background border-none shadow-sm h-10 px-3 overflow-hidden">
<SelectTrigger
class={cn(
"w-full bg-background border shadow-sm h-10 px-3 overflow-hidden transition-colors border-transparent",
showValidation() && !urgency() && "border-destructive/50 bg-destructive/5"
)}
>
<div class="flex items-center gap-2 truncate">
<Clock size={14} class="text-primary shrink-0" />
<span class="text-sm font-medium truncate">
<Clock size={14} class={cn("text-primary shrink-0", showValidation() && !urgency() && "text-destructive")} />
<span class={cn("text-sm font-medium truncate", showValidation() && !urgency() && "text-destructive")}>
{URGENCY_OPTIONS.find(o => o.value === urgency())?.label || (urgency() === "" ? "Pick Urgency" : urgency())}
</span>
</div>
@@ -344,8 +418,17 @@ export const QuickEntryForm: Component<QuickEntryFormProps> = (props) => {
<div class="flex flex-col md:flex-row items-stretch md:items-center px-4 py-3 gap-4 bg-muted/10 border-b border-border">
<div class="flex-1 flex flex-wrap gap-2 items-center">
<TagPicker selectedTags={tags()} onTagsChange={setTags} />
<For each={tags()}>
<TagPicker
selectedTags={tags()}
getShareModeForTag={(tag) => tagShareModes()[tag.toLowerCase()]}
onTagsChange={(nextTags, shareModeOverrides) => {
setTags(nextTags);
if (shareModeOverrides) {
setTagShareModes(prev => ({ ...prev, ...shareModeOverrides }));
}
}}
/>
<For each={tags().filter(t => !t.endsWith("_favorite__") && !isCurrentUserContextTag(t))}>
{(tag) => (
<Badge
variant="secondary"
@@ -366,7 +449,14 @@ export const QuickEntryForm: Component<QuickEntryFormProps> = (props) => {
return getThemeAdjustedColor(def?.color, def?.theme, resolvedTheme()) || "inherit";
})()
}}
onClick={() => setTags(tags().filter(t => t !== tag))}
onClick={() => {
setTags(tags().filter(t => t !== tag));
setTagShareModes(prev => {
const next = { ...prev };
delete next[tag.toLowerCase()];
return next;
});
}}
>
<div
class="w-1.5 h-1.5 rounded-full transition-colors group-hover/tag:bg-destructive"
@@ -420,6 +510,18 @@ export const QuickEntryForm: Component<QuickEntryFormProps> = (props) => {
</div>
<div class="flex items-center gap-2">
<Button
variant="ghost"
size="icon"
class="h-9 w-9 text-muted-foreground hover:text-destructive"
onClick={() => {
resetQuickEntryState();
editorInstance()?.commands.setContent("");
}}
title="Restart Quick Entry"
>
<RotateCcw size={16} />
</Button>
<Show when={(store.templates || []).length > 0}>
<Popover>
<PopoverTrigger
@@ -457,7 +559,7 @@ export const QuickEntryForm: Component<QuickEntryFormProps> = (props) => {
</Popover>
</Show>
<Button size="sm" onClick={parseAndAdd} disabled={!input() || !priority() || !urgency()}>
<Button size="sm" onClick={parseAndAdd}>
Create Task
</Button>
</div>
+26 -165
View File
@@ -1,17 +1,6 @@
import { type Component, createSignal, For, createEffect } from "solid-js";
import {
Heading1, Heading2, Heading3,
List, ListTodo, Type,
Code, Quote,
Image as ImageIcon, Minus,
Bold, Italic, Underline,
Grid3X3, Highlighter,
AlignLeft, AlignCenter, AlignRight,
Trash2
} from "lucide-solid";
import { cn, convertImageToWebpFile } from "@/lib/utils";
import { activeTaskId, uploadTaskAttachment } from "@/store";
import { toast } from "solid-sonner";
import { Type, Code, Quote, Heading1, Heading2, Heading3, List, ListTodo, FileText, AppWindow, Minus, Grid3X3, Trash2 } from "lucide-solid";
import { cn } from "@/lib/utils";
export interface CommandItem {
title: string;
@@ -31,33 +20,6 @@ export const getSuggestionItems = ({ query }: { query: string }): CommandItem[]
editor.chain().focus().deleteRange(range).setNode("paragraph").run();
},
},
{
title: "Bold",
description: "Strong text. (**text**)",
aliases: ["b", "strong"],
icon: Bold,
command: ({ editor, range }: { editor: any, range: any }) => {
editor.chain().focus().deleteRange(range).toggleBold().run();
},
},
{
title: "Italic",
description: "Emphasized text. (*text*)",
aliases: ["i", "emphasis"],
icon: Italic,
command: ({ editor, range }: { editor: any, range: any }) => {
editor.chain().focus().deleteRange(range).toggleItalic().run();
},
},
{
title: "Underline",
description: "Underlined text.",
aliases: ["u"],
icon: Underline,
command: ({ editor, range }: { editor: any, range: any }) => {
editor.chain().focus().deleteRange(range).toggleUnderline().run();
},
},
{
title: "Heading 1",
description: "Big section heading.",
@@ -101,6 +63,15 @@ export const getSuggestionItems = ({ query }: { query: string }): CommandItem[]
editor.chain().focus().deleteRange(range).toggleTaskList().run();
},
},
{
title: "Multi Checklist",
description: "Insert a row with multiple checkboxes.",
aliases: ["multi", "checkboxes", "multi-checklist"],
icon: ListTodo,
command: ({ editor, range }: { editor: any, range: any }) => {
editor.chain().focus().deleteRange(range).toggleMultiTaskList().run();
},
},
{
title: "Code Block",
description: "Capture a code snippet.",
@@ -118,51 +89,21 @@ export const getSuggestionItems = ({ query }: { query: string }): CommandItem[]
},
},
{
title: "Image",
description: "Upload an image.",
icon: ImageIcon,
title: "Upload",
description: "Upload an image, video, or document.",
aliases: ["image", "video", "file", "upload", "pdf", "movie"],
icon: FileText,
command: ({ editor, range }: { editor: any, range: any }) => {
editor.chain().focus().deleteRange(range).run();
const taskId = activeTaskId();
if (!taskId) {
toast.error("Cannot upload image outside of a task.");
return;
}
const input = document.createElement("input");
input.type = "file";
input.accept = "image/*";
input.onchange = async () => {
if (input.files?.length) {
const originalFile = input.files[0];
toast.promise(
(async () => {
const webpFile = await convertImageToWebpFile(originalFile);
return uploadTaskAttachment(taskId, webpFile);
})(),
{
loading: "Processing & uploading image...",
success: (url) => {
// Insert image, then run enter to add a newline (paragraph) below it.
// Without this, the image stays selected and the next keypress deletes it.
editor.chain()
.focus()
.setImage({ src: url })
.run();
// Manually force a new paragraph at the end so the user can keep typing
editor.chain()
.focus('end')
.insertContent('<p></p>')
.run();
return "Image uploaded.";
},
error: "Failed to upload image."
}
);
}
};
input.click();
editor.chain().focus().deleteRange(range).uploadMedia().run();
},
},
{
title: "Job File",
description: "Embed a job file from Prism.",
aliases: ["job", "jobfile", "prism"],
icon: AppWindow,
command: ({ editor, range }: { editor: any, range: any }) => {
editor.chain().focus().deleteRange(range).insertFileViewer().run();
},
},
{
@@ -182,87 +123,7 @@ export const getSuggestionItems = ({ query }: { query: string }): CommandItem[]
editor.chain().focus().deleteRange(range).insertTable({ rows: 3, cols: 3, withHeaderRow: true }).run();
},
},
{
title: "Highlight",
description: "Highlight selected text.",
aliases: ["mark"],
icon: Highlighter,
command: ({ editor, range }: { editor: any, range: any }) => {
editor.chain().focus().deleteRange(range).toggleHighlight().run();
},
},
{
title: "Align Left",
description: "Align text to the left.",
icon: AlignLeft,
command: ({ editor, range }: { editor: any, range: any }) => {
editor.chain().focus().deleteRange(range).setTextAlign("left").run();
},
},
{
title: "Align Center",
description: "Align text to the center.",
icon: AlignCenter,
command: ({ editor, range }: { editor: any, range: any }) => {
editor.chain().focus().deleteRange(range).setTextAlign("center").run();
},
},
{
title: "Align Right",
description: "Align text to the right.",
icon: AlignRight,
command: ({ editor, range }: { editor: any, range: any }) => {
editor.chain().focus().deleteRange(range).setTextAlign("right").run();
},
},
{
title: "Add Row Above",
description: "Insert a new row above the cursor.",
icon: Grid3X3,
command: ({ editor, range }: { editor: any, range: any }) => {
editor.chain().focus().deleteRange(range).addRowBefore().run();
},
},
{
title: "Add Row Below",
description: "Insert a new row below the cursor.",
icon: Grid3X3,
command: ({ editor, range }: { editor: any, range: any }) => {
editor.chain().focus().deleteRange(range).addRowAfter().run();
},
},
{
title: "Delete Row",
description: "Delete the current row.",
icon: Trash2,
command: ({ editor, range }: { editor: any, range: any }) => {
editor.chain().focus().deleteRange(range).deleteRow().run();
},
},
{
title: "Add Column Left",
description: "Insert a new column to the left.",
icon: Grid3X3,
command: ({ editor, range }: { editor: any, range: any }) => {
editor.chain().focus().deleteRange(range).addColumnBefore().run();
},
},
{
title: "Add Column Right",
description: "Insert a new column to the right.",
icon: Grid3X3,
command: ({ editor, range }: { editor: any, range: any }) => {
editor.chain().focus().deleteRange(range).addColumnAfter().run();
},
},
{
title: "Delete Column",
description: "Delete the current column.",
icon: Trash2,
command: ({ editor, range }: { editor: any, range: any }) => {
editor.chain().focus().deleteRange(range).deleteColumn().run();
},
},
{
title: "Delete Table",
description: "Delete the entire table.",
+34 -3
View File
@@ -1,4 +1,4 @@
import { type Component } from "solid-js";
import { type Component, For } from "solid-js";
import { CheckCircle2 } from "lucide-solid";
import { cn } from "@/lib/utils";
@@ -6,6 +6,8 @@ interface StatusCircleProps {
status: number;
size?: number;
className?: string;
recurrenceProgress?: number | null;
completed?: boolean;
}
export const StatusCircle: Component<StatusCircleProps> = (props) => {
@@ -25,10 +27,39 @@ export const StatusCircle: Component<StatusCircleProps> = (props) => {
return circumference() * (1 - percentage);
};
const dotCount = () => 12;
const dotRadius = () => Math.max(1.4, size() * 0.06);
const dotRingRadius = () => radius() - 1;
const filledDots = () => {
const progress = props.recurrenceProgress ?? 0;
return Math.floor(Math.max(0, Math.min(1, progress)) * dotCount());
};
return (
<div class={cn("relative flex items-center justify-center", props.className)} style={{ width: `${size()}px`, height: `${size()}px` }}>
{props.status >= 10 ? (
<CheckCircle2 size={size()} class="text-green-500" />
{props.completed || props.status >= 10 ? (
props.recurrenceProgress != null ? (
<svg width={size()} height={size()}>
<For each={Array.from({ length: dotCount() })}>
{(_, index) => {
const angle = (index() / dotCount()) * Math.PI * 2 - Math.PI / 2;
const cx = size() / 2 + Math.cos(angle) * dotRingRadius();
const cy = size() / 2 + Math.sin(angle) * dotRingRadius();
const isFilled = index() < filledDots();
return (
<circle
cx={cx}
cy={cy}
r={dotRadius()}
class={isFilled ? "fill-green-500" : "fill-muted-foreground/30"}
/>
);
}}
</For>
</svg>
) : (
<CheckCircle2 size={size()} class="text-green-500" />
)
) : (
// Progress Circle
<div class="relative group">
+183
View File
@@ -0,0 +1,183 @@
import { type Component, createSignal, createEffect, For } from "solid-js";
import {
Trash2,
Type
} from "lucide-solid";
import { cn } from "@/lib/utils";
import type { Editor } from "@tiptap/core";
// Custom Icons
const AddRowAbove = () => (
<svg width="20" height="20" viewBox="0 0 18 18" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect x="3" y="8" width="12" height="7" rx="1.5" stroke="currentColor" stroke-width="1.5" />
<path d="M9 2V6M7 4H11" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" />
</svg>
);
const AddRowBelow = () => (
<svg width="20" height="20" viewBox="0 0 18 18" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect x="3" y="3" width="12" height="7" rx="1.5" stroke="currentColor" stroke-width="1.5" />
<path d="M9 12V16M7 14H11" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" />
</svg>
);
const AddColLeft = () => (
<svg width="20" height="20" viewBox="0 0 18 18" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect x="8" y="3" width="7" height="12" rx="1.5" stroke="currentColor" stroke-width="1.5" />
<path d="M2 9H6M4 7V11" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" />
</svg>
);
const AddColRight = () => (
<svg width="20" height="20" viewBox="0 0 18 18" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect x="3" y="3" width="7" height="12" rx="1.5" stroke="currentColor" stroke-width="1.5" />
<path d="M12 9H16M14 7V11" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" />
</svg>
);
const DeleteRow = () => (
<svg width="20" height="20" viewBox="0 0 18 18" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect x="2" y="7" width="14" height="4" rx="0.5" stroke="currentColor" stroke-width="1.5" />
<path d="M4 3L14 15M14 3L4 15" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" />
</svg>
);
const DeleteCol = () => (
<svg width="20" height="20" viewBox="0 0 18 18" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect x="7" y="2" width="4" height="14" rx="0.5" stroke="currentColor" stroke-width="1.5" />
<path d="M3 4L15 14M15 4L3 14" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" />
</svg>
);
const MergeCells = () => (
<svg width="20" height="20" viewBox="0 0 18 18" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect x="2" y="4" width="14" height="10" rx="1" stroke="currentColor" stroke-width="1.5" />
<path d="M6 9H12" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" />
</svg>
);
const SplitCell = () => (
<svg width="20" height="20" viewBox="0 0 18 18" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect x="2" y="4" width="14" height="10" rx="1" stroke="currentColor" stroke-width="1.5" />
<path d="M9 7V11" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" />
</svg>
);
interface TableAction {
label: string;
icon: Component;
command: () => void;
iconClass?: string;
}
interface TableGroup {
name: string;
actions: TableAction[];
}
interface TableBubbleMenuProps {
editor: Editor;
}
export const TableBubbleMenu: Component<TableBubbleMenuProps> = (props) => {
const [isVisible, setIsVisible] = createSignal(false);
createEffect(() => {
const updateVisibility = () => {
const editor = props.editor;
if (!editor) return;
setIsVisible(editor.isActive("table"));
};
props.editor.on("selectionUpdate", updateVisibility);
props.editor.on("transaction", updateVisibility);
return () => {
props.editor.off("selectionUpdate", updateVisibility);
props.editor.off("transaction", updateVisibility);
};
});
const groups: TableGroup[] = [
{
name: "Rows",
actions: [
{ label: "Insert Row Above", icon: AddRowAbove, command: () => props.editor.chain().focus().addRowBefore().run() },
{ label: "Insert Row Below", icon: AddRowBelow, command: () => props.editor.chain().focus().addRowAfter().run() },
{ label: "Delete Row", icon: DeleteRow, iconClass: "text-destructive", command: () => props.editor.chain().focus().deleteRow().run() },
]
},
{
name: "Cols",
actions: [
{ label: "Insert Column Left", icon: AddColLeft, command: () => props.editor.chain().focus().addColumnBefore().run() },
{ label: "Insert Column Right", icon: AddColRight, command: () => props.editor.chain().focus().addColumnAfter().run() },
{ label: "Delete Column", icon: DeleteCol, iconClass: "text-destructive", command: () => props.editor.chain().focus().deleteColumn().run() },
]
},
{
name: "Cells",
actions: [
{ label: "Merge Cells", icon: MergeCells, command: () => props.editor.chain().focus().mergeCells().run() },
{ label: "Split Cell", icon: SplitCell, command: () => props.editor.chain().focus().splitCell().run() },
{ label: "Header Row", icon: () => <Type size={18} />, command: () => props.editor.chain().focus().toggleHeaderRow().run() },
]
},
{
name: "Table",
actions: [
{ label: "Delete Table", icon: () => <Trash2 size={18} />, iconClass: "text-destructive", command: () => props.editor.chain().focus().deleteTable().run() },
]
}
];
return (
<div
class={cn(
"relative z-[150] w-full bg-background/80 backdrop-blur-md border-b border-border transition-all duration-300 ease-in-out overflow-hidden flex flex-col",
isVisible() ? "h-9 opacity-100" : "h-0 opacity-0 pointer-events-none"
)}
onMouseDown={(e) => {
e.preventDefault();
e.stopPropagation();
}}
>
<div class="flex items-center justify-center h-full px-2 max-w-4xl mx-auto w-full">
<div class="flex items-center gap-1">
<For each={groups}>
{(group, groupIdx) => (
<>
<div class="flex items-center gap-0 px-0.5">
<For each={group.actions}>
{(action) => (
<button
type="button"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
action.command();
}}
class={cn(
"p-0.5 rounded-md hover:bg-muted transition-colors text-muted-foreground hover:text-foreground shrink-0 group relative",
action.iconClass
)}
>
<action.icon />
<span class="absolute top-full left-1/2 -translate-x-1/2 mt-2 px-2 py-1 text-[10px] font-bold bg-foreground text-background rounded opacity-0 group-hover:opacity-100 pointer-events-none transition-opacity whitespace-nowrap z-[200] shadow-lg">
{action.label}
</span>
</button>
)}
</For>
</div>
{groupIdx() < groups.length - 1 && (
<div class="w-[1px] h-4 bg-border/50 mx-1" />
)}
</>
)}
</For>
</div>
</div>
</div>
);
};
+123 -12
View File
@@ -1,13 +1,17 @@
import { type Component, createSignal, For, createEffect } from "solid-js";
import { store, upsertTagDefinition } from "@/store";
import { store, upsertTagDefinition, isCurrentUserContextTag, getNoteSharePolicy } from "@/store";
import { useTheme } from "./ThemeProvider";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { Button } from "@/components/ui/button";
import { Tag, Plus } from "lucide-solid";
import { toast } from "solid-sonner";
import { buildNoteTag, buildShareTag, normalizeContextKey, normalizeNoteKey } from "@/lib/tags";
import { cn } from "@/lib/utils";
interface TagPickerProps {
selectedTags: string[];
onTagsChange: (tags: string[]) => void;
onTagsChange: (tags: string[], shareModeOverrides?: Record<string, "handoff" | "collaborative">) => void;
getShareModeForTag?: (tag: string) => "handoff" | "collaborative" | undefined;
}
export const TagPicker: Component<TagPickerProps> = (props) => {
@@ -15,18 +19,21 @@ export const TagPicker: Component<TagPickerProps> = (props) => {
const [open, setOpen] = createSignal(false);
const [selectedTagName, setSelectedTagName] = createSignal("");
const [valueScore, setValueScore] = createSignal(5);
const [shareMode, setShareMode] = createSignal<"handoff" | "collaborative">("handoff");
// Filter tags for the "Name" part
const localTagNames = () => {
const localDef = store.tagDefinitions.filter(d => !d.isUser && !d.isBucket).map(d => d.name);
const localDef = store.tagDefinitions.filter(d => !d.isUser && !d.isBucket).map(d => d.name).filter(tag => !isCurrentUserContextTag(tag));
return [...new Set(localDef)].sort();
};
const allTagNames = () => {
const definedTags = store.tagDefinitions.map(d => d.name);
const bucketTags = store.buckets.map(b => `@${b.name}`);
const noteTags = store.notes.map(n => `#${n.title}`);
return [...new Set([...definedTags, ...bucketTags, ...noteTags])].sort();
const definedTags = store.tagDefinitions.filter(d => !d.name.endsWith("_favorite__")).map(d => d.name);
const contextTags = store.contexts.filter(c => !c.deletedAt).map(c => buildShareTag(c.displayName));
const noteTags = store.notes
.filter(n => !(n.tags || []).some(t => t.startsWith("child-of-")))
.map(n => buildNoteTag(n.key || n.title));
return [...new Set([...definedTags, ...contextTags, ...noteTags])].filter(tag => !isCurrentUserContextTag(tag)).sort();
};
const suggestedTags = () => {
@@ -36,6 +43,27 @@ export const TagPicker: Component<TagPickerProps> = (props) => {
return allTagNames();
};
const selectedShareContext = () => {
const name = selectedTagName().trim();
if (!name.startsWith("@")) return null;
const normalized = normalizeContextKey(name);
return store.contexts.find(context =>
!context.deletedAt && (
context.key === normalized ||
normalizeContextKey(context.displayName) === normalized
)
) || null;
};
const selectedNoteContext = () => {
const name = selectedTagName().trim();
if (!name.startsWith("#")) return null;
const key = normalizeNoteKey(name);
return store.notes.find(note => !note.deletedAt && note.key === key) || null;
};
// When a tag name is selected, update the value score to match its current definition
createEffect(() => {
const name = selectedTagName().trim();
@@ -47,20 +75,69 @@ export const TagPicker: Component<TagPickerProps> = (props) => {
}
});
const handleApply = () => {
createEffect(() => {
const explicitMode = props.getShareModeForTag?.(selectedTagName().trim());
if (explicitMode) {
setShareMode(explicitMode);
return;
}
const context = selectedShareContext();
if (context) {
setShareMode(context.policy);
return;
}
const note = selectedNoteContext();
if (note) {
setShareMode(getNoteSharePolicy(note));
return;
}
if (selectedTagName().trim().startsWith("@") || selectedTagName().trim().startsWith("#")) {
setShareMode("handoff");
}
});
const handleApply = async () => {
const name = selectedTagName().trim();
if (!name) return;
if (name.startsWith("@")) {
const normalized = normalizeContextKey(name);
const exists = store.contexts.some(context =>
!context.deletedAt && (
context.key === normalized ||
normalizeContextKey(context.displayName) === normalized
)
);
if (!exists) {
toast.error("Create the shared context first before tagging tasks with it.");
return;
}
}
if (name.startsWith("#")) {
const exists = store.notes.some(note => note.key === normalizeNoteKey(name));
if (!exists) {
toast.error("Create the canonical note first before linking it with a #tag.");
return;
}
}
// update global definition
const bucket = store.buckets.find(b => b.name === name || `@${b.name}` === name);
const color = bucket ? bucket.color : undefined;
upsertTagDefinition(name, valueScore(), color, resolvedTheme());
if (!name.startsWith("@") && !name.startsWith("#")) {
upsertTagDefinition(name, valueScore(), color, resolvedTheme());
}
// Toggle in current task selection if not already there
const currentSelected = props.selectedTags;
if (!currentSelected.includes(name)) {
props.onTagsChange([...currentSelected, name]);
}
const nextTags = currentSelected.includes(name) ? currentSelected : [...currentSelected, name];
props.onTagsChange(
nextTags,
name.startsWith("@") || name.startsWith("#")
? { [name.toLowerCase()]: shareMode() }
: undefined
);
// Cleanup
setSelectedTagName("");
@@ -106,6 +183,40 @@ export const TagPicker: Component<TagPickerProps> = (props) => {
</select>
</div>
{(selectedTagName().trim().startsWith("@") || selectedTagName().trim().startsWith("#")) && (
<div class="space-y-1.5">
<label class="text-[0.625rem] font-bold uppercase tracking-wider text-muted-foreground">Share Mode</label>
<div class="bg-background border border-border p-1 rounded-xl flex items-center shadow-sm relative min-w-[160px] h-10">
<div
class={cn(
"absolute top-1 bottom-1 w-[calc(50%-4px)] bg-primary rounded-lg shadow-sm transition-transform duration-200 ease-out z-0",
shareMode() === "handoff" ? "translate-x-0" : "translate-x-[calc(100%+0px)]"
)}
/>
<button
type="button"
class={cn(
"flex-1 h-8 text-[0.7rem] font-bold uppercase tracking-widest z-10 transition-colors duration-200 flex items-center justify-center rounded-lg",
shareMode() === "handoff" ? "text-primary-foreground" : "text-muted-foreground hover:text-foreground"
)}
onClick={() => setShareMode("handoff")}
>
Handoff
</button>
<button
type="button"
class={cn(
"flex-1 h-8 text-[0.7rem] font-bold uppercase tracking-widest z-10 transition-colors duration-200 flex items-center justify-center rounded-lg",
shareMode() === "collaborative" ? "text-primary-foreground" : "text-muted-foreground hover:text-foreground"
)}
onClick={() => setShareMode("collaborative")}
>
Collaborate
</button>
</div>
</div>
)}
<Button size="sm" class="w-full mt-1" onClick={handleApply}>
<Plus size={14} class="mr-2" />
{store.tagDefinitions.find(d => d.name === selectedTagName().trim()) ? "Update & Add" : "Create & Add"}
+136 -62
View File
@@ -1,12 +1,13 @@
import { type Component, createMemo, For, Show } from "solid-js";
import { type Task, calculateUrgencyFromDate, setActiveTaskId, store, copyTask, updateTask, currentTaskContext, now } from "@/store";
import { type Task, calculateUrgencyFromDate, setActiveTaskId, store, copyTask, updateTask, currentTaskContext, now, getFavoriteTag, dismissTaskUpdateIndicator, isTaskUpdatedByAnotherUser, collapseTaskUntilUpdated, expandCollapsedTask, isTaskCollapsedUntilUpdated, isTaskCollaborativelyShared, getVisibleTaskTags } from "@/store";
import { cn } from "@/lib/utils";
import { Clock, ArrowUpCircle, Copy, Users2 } from "lucide-solid";
import { Clock, ArrowUpCircle, Copy, Users2, Star, ChevronRight } from "lucide-solid";
import { Badge } from "@/components/ui/badge";
import { useTheme } from "./ThemeProvider";
import { getThemeAdjustedColor } from "@/lib/colors";
import { Select, SelectContent, SelectItem, SelectTrigger } from "@/components/ui/select";
import { getStatusOptionsForTask } from "@/lib/constants";
import { getRecurrenceProgress } from "@/lib/recurrence";
import { StatusCircle } from "./StatusCircle";
export const TaskCard: Component<{ task: Task, isShaking?: boolean }> = (props) => {
@@ -26,19 +27,8 @@ export const TaskCard: Component<{ task: Task, isShaking?: boolean }> = (props)
return date.toLocaleDateString(undefined, { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' });
};
// Filter out bucket tags if we are currently INSIDE that bucket view
const visibleTags = createMemo(() => {
const tags = props.task.tags || [];
const ctx = currentTaskContext();
if (typeof ctx === 'object' && 'bucketId' in ctx) {
// We are in a bucket view. Hide the tag that matches this bucket's name.
// Buckets are now tagged with '@' + name.
const bucketTagName = `@${(ctx as { name: string }).name.toLowerCase()}`;
return tags.filter(t => t.toLowerCase() !== bucketTagName);
}
return tags;
return getVisibleTaskTags(props.task, currentTaskContext());
});
const isDueWithin12Hours = createMemo(() => {
@@ -53,53 +43,105 @@ export const TaskCard: Component<{ task: Task, isShaking?: boolean }> = (props)
}
});
const statusOptions = createMemo(() => getStatusOptionsForTask(props.task.recurrence));
const canCollapseUntilUpdated = createMemo(() => isTaskCollaborativelyShared(props.task));
const statusOptions = createMemo(() => {
const baseOptions = getStatusOptionsForTask(props.task.recurrence);
if (!canCollapseUntilUpdated()) return baseOptions;
return [
...baseOptions,
{ value: "__collapse_until_updated__", label: "Collapse until updated" }
];
});
const recurrenceProgress = createMemo(() => {
if (!props.task.completed && props.task.status !== 10) return null;
return getRecurrenceProgress(props.task.recurrence, props.task.updated, now());
});
const shouldQuickloadFade = createMemo(() => store.quickloadFadeTaskIds.includes(props.task.id));
const showUpdateIndicator = createMemo(() => isTaskUpdatedByAnotherUser(props.task));
const isCollapsed = createMemo(() => isTaskCollapsedUntilUpdated(props.task));
return (
<div
class={cn(
"group relative flex items-center p-3 sm:p-4 bg-card border border-border rounded-2xl transition-[background-color,border-color,box-shadow,opacity] duration-500 hover:duration-100 hover:shadow-xl hover:shadow-primary/5 hover:-translate-y-0.5 cursor-pointer w-full min-w-0 overflow-hidden",
"group relative flex items-center bg-card border border-border rounded-2xl transition-[background-color,border-color,box-shadow,opacity] duration-500 hover:duration-100 hover:shadow-xl hover:shadow-primary/5 hover:-translate-y-0.5 cursor-pointer w-full min-w-0 overflow-hidden",
isCollapsed()
? "py-1.5 pl-5 pr-5 sm:py-2 sm:pl-6 sm:pr-6"
: "py-3 pl-6 pr-6 sm:py-4 sm:pl-7 sm:pr-7",
props.task.completed && "opacity-60",
props.isShaking && "animate-task-shake bg-accent/20"
props.isShaking && "animate-task-shake bg-accent/20",
shouldQuickloadFade() && "animate-task-quickload-fade"
)}
onClick={() => setActiveTaskId(props.task.id)}
>
{/* Today indicator */}
<Show when={isDueWithin12Hours() && !props.task.completed}>
<div class="absolute left-0 top-3 bottom-3 w-1 bg-primary rounded-r-md" />
<div class="absolute left-0 top-3 bottom-3 w-3 sm:w-[0.875rem] rounded-r-md bg-primary/65 text-primary-foreground/90">
<span
class="absolute inset-0 flex items-center justify-center text-[0.4375rem] sm:text-[0.5rem] font-semibold uppercase tracking-[0.08em] [writing-mode:vertical-rl] [text-orientation:mixed]"
>
{!isCollapsed() && "Today"}
</span>
</div>
</Show>
<Show when={showUpdateIndicator()}>
<button
type="button"
class="absolute right-0 top-3 bottom-3 w-3 sm:w-[0.875rem] rounded-l-md bg-primary/65 text-primary-foreground/90 transition-colors hover:bg-primary/80 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/30"
title="Hide updates until the next change from another user"
onClick={(e) => {
e.stopPropagation();
dismissTaskUpdateIndicator(props.task.id, props.task.updated);
}}
>
<span
class="absolute inset-0 flex items-center justify-center text-[0.4375rem] sm:text-[0.5rem] font-semibold uppercase tracking-[0.08em] [writing-mode:vertical-rl] [text-orientation:mixed]"
>
{!isCollapsed() && "Updates"}
</span>
</button>
</Show>
{/* Status Dropdown */}
<div class="mr-2 sm:mr-3 shrink-0" onClick={(e) => e.stopPropagation()}>
<Select<any>
value={(props.task.status ?? 0).toString()}
onChange={(val: any) => {
const value = typeof val === 'object' ? val?.value : val;
if (value) {
<Show when={!isCollapsed()}>
<div class="mr-2 sm:mr-3 shrink-0" onClick={(e) => e.stopPropagation()}>
<Select<any>
value={(props.task.status ?? 0).toString()}
onChange={(val: any) => {
const value = typeof val === 'object' ? val?.value : val;
if (!value) return;
if (value === "__collapse_until_updated__") {
collapseTaskUntilUpdated(props.task.id, props.task.updated);
return;
}
const s = parseInt(value);
if (!isNaN(s)) updateTask(props.task.id, { status: s });
}
}}
options={statusOptions()}
optionValue="value"
optionTextValue="label"
placeholder="Status"
itemComponent={(props: any) => <SelectItem item={props.item}>{props.item.rawValue.label}</SelectItem>}
>
<SelectTrigger
class="h-9 w-9 p-0 bg-transparent border-transparent hover:bg-muted/50 data-[expanded]:bg-muted/50 shadow-none focus:ring-0 shrink-0 overflow-hidden flex items-center justify-center [&>svg]:hidden"
onDoubleClick={(e: MouseEvent) => {
e.preventDefault();
e.stopPropagation();
updateTask(props.task.id, { status: 10 });
}}
options={statusOptions()}
optionValue="value"
optionTextValue="label"
placeholder="Status"
itemComponent={(props: any) => <SelectItem item={props.item}>{props.item.rawValue.label}</SelectItem>}
>
<StatusCircle status={props.task.status ?? 0} size={26} />
</SelectTrigger>
<SelectContent />
</Select>
</div>
<SelectTrigger
class="h-9 w-9 p-0 bg-transparent border-transparent hover:bg-muted/50 data-[expanded]:bg-muted/50 shadow-none focus:ring-0 shrink-0 overflow-hidden flex items-center justify-center [&>svg]:hidden"
onDoubleClick={(e: MouseEvent) => {
e.preventDefault();
e.stopPropagation();
updateTask(props.task.id, { status: 10 });
}}
>
<StatusCircle status={props.task.status ?? 0} completed={props.task.completed} size={26} recurrenceProgress={recurrenceProgress()} />
</SelectTrigger>
<SelectContent />
</Select>
</div>
</Show>
<div class="flex-1 min-w-0 flex flex-col">
<div class={cn("flex-1 min-w-0", isCollapsed() ? "flex items-center justify-between gap-2" : "flex flex-col")}>
<Show
when={isCollapsed()}
fallback={
<>
<h3 class={cn(
"text-sm sm:text-base font-semibold truncate transition-all",
props.task.completed && "line-through"
@@ -134,23 +176,13 @@ export const TaskCard: Component<{ task: Task, isShaking?: boolean }> = (props)
<span class="text-[0.625rem] font-medium">P{props.task.priority}</span>
</div>
{/* Shared Indicator - shows for individual shares OR tag-based ShareRules */}
{/* Shared Indicator - driven by task context tags */}
{(() => {
const hasIndividualShares = props.task.sharedWith && props.task.sharedWith.length > 0;
// Check if any tag-based ShareRules match this task's tags (exclude 'all' type)
const hasTagRuleShares = store.shareRules.some(rule =>
rule.type === 'tag' &&
rule.tagName &&
props.task.tags?.includes(rule.tagName)
);
const isShared = hasIndividualShares || hasTagRuleShares;
if (props.task.shareRefs.length === 0) return null;
if (!isShared) return null;
const shareCount = (props.task.sharedWith?.length || 0);
const tooltip = hasIndividualShares
? `Shared with ${shareCount} user${shareCount > 1 ? 's' : ''}`
: 'Shared via tag rule';
const tooltip = props.task.shareRefs.length === 1
? 'Shared via 1 context'
: `Shared via ${props.task.shareRefs.length} contexts`;
return (
<div class="flex items-center gap-1 shrink-0" title={tooltip}>
@@ -216,11 +248,33 @@ export const TaskCard: Component<{ task: Task, isShaking?: boolean }> = (props)
</div>
</div>
</div>
</>
}
>
<h3 class={cn(
"text-[0.6875rem] sm:text-xs font-semibold truncate text-muted-foreground/90",
props.task.completed && "line-through"
)}>
{props.task.title}
</h3>
<button
type="button"
class="shrink-0 rounded-full p-1 text-muted-foreground/70 transition-colors hover:bg-muted hover:text-foreground"
title="Expand task"
onClick={(e) => {
e.stopPropagation();
expandCollapsedTask(props.task.id);
}}
>
<ChevronRight size={14} />
</button>
</Show>
</div>
<div class="ml-2 sm:ml-4 opacity-100 sm:opacity-0 group-hover:opacity-100 transition-opacity shrink-0">
<Show when={!isCollapsed()}>
<div class="ml-2 sm:ml-4 shrink-0 flex items-center gap-1">
<button
class="p-1.5 hover:bg-muted rounded-full transition-colors text-muted-foreground hover:text-primary"
class="p-1.5 hover:bg-muted rounded-full transition-all text-muted-foreground hover:text-primary opacity-0 group-hover:opacity-100"
onClick={(e) => {
e.stopPropagation();
copyTask(props.task.id);
@@ -229,7 +283,27 @@ export const TaskCard: Component<{ task: Task, isShaking?: boolean }> = (props)
>
<Copy size={16} />
</button>
</div>
<button
class={cn(
"p-1.5 rounded-full transition-all",
props.task.tags?.includes(getFavoriteTag())
? "text-amber-500 bg-amber-500/10 hover:bg-amber-500/20 opacity-100"
: "text-muted-foreground hover:text-amber-500 hover:bg-muted opacity-0 group-hover:opacity-100"
)}
onClick={(e) => {
e.stopPropagation();
const favTag = getFavoriteTag();
const currentTags = props.task.tags || [];
const isFav = currentTags.includes(favTag);
const newTags = isFav ? currentTags.filter(t => t !== favTag) : [...currentTags, favTag];
updateTask(props.task.id, { tags: newTags });
}}
title="Toggle Favorite"
>
<Star size={16} class={cn(props.task.tags?.includes(getFavoriteTag()) && "fill-amber-500")} />
</button>
</div>
</Show>
</div>
);
};
+166 -165
View File
@@ -1,9 +1,9 @@
import { type Component, createEffect, createSignal, For, createMemo } from "solid-js";
import { type Component, createEffect, createSignal, For, createMemo, onCleanup, Show } from "solid-js";
import { Sheet, SheetContent } from "@/components/ui/sheet";
import { type Task, removeTask, restoreTask, updateTask, saveTaskAsTemplate, shareTask, revokeShare, splitTask, loadTaskContent, currentTaskContext } from "@/store";
import { type Task, removeTask, restoreTask, updateTask, saveTaskAsTemplate, loadTaskContent, currentTaskContext, cleanupTaskAttachments, getFavoriteTag, now, isCurrentUserContextTag, getVisibleTaskTags, isTaskLockedNoteTag, deriveTaskRelationsFromTags, getTaskShareModeForTag, getTaskSharePolicy, canHandoffCollaborativelySharedTask, handoffCollaborativelySharedTask } from "@/store";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { ArrowUpCircle, Clock, Calendar, Type, Trash2, X, Copy, MoreHorizontal, Gauge, Share2, UserMinus, GitBranch, Tag, Settings } from "lucide-solid";
import { ArrowUpCircle, Clock, Calendar, Type, Trash2, X, Copy, MoreHorizontal, Gauge, Star, FileText } from "lucide-solid";
import { StatusCircle } from "./StatusCircle";
import { calculateDateFromUrgency, calculateUrgencyFromDate } from "@/store";
import { Button } from "./ui/button";
@@ -16,7 +16,10 @@ import { toast } from "solid-sonner";
import { lazy, Suspense } from "solid-js";
import { useTheme } from "./ThemeProvider";
import { getThemeAdjustedColor } from "@/lib/colors";
import { pb } from "@/lib/pocketbase";
import { getRecurrenceProgress } from "@/lib/recurrence";
import { buildNoteTag, buildShareTag } from "@/lib/tags";
import { TextBubbleMenu } from "@/components/TextBubbleMenu";
import { TableBubbleMenu } from "@/components/TableBubbleMenu";
const TaskEditor = lazy(() => import("./TaskEditor").then(m => ({ default: m.TaskEditor })));
@@ -110,9 +113,31 @@ export const TaskDetail: Component<TaskDetailProps> = (props) => {
}, 1000);
};
// Removed onCleanup to avoid lockup
// Sync activeTaskId for slash commands and run cleanup on unmount/change
createEffect(() => {
const currentTaskId = props.task.id;
onCleanup(() => {
// 1. Immediately flush any pending debounce updates for the outgoing task
if (debounceTimer) {
clearTimeout(debounceTimer);
if (pendingContent !== null) {
updateTask(currentTaskId, { content: pendingContent });
}
}
// 2. Clear pending state
debounceTimer = undefined;
pendingContent = null;
// 3. Trigger cleanup after a tiny delay so the updateTask above processes first
if (currentTaskId) {
setTimeout(() => {
cleanupTaskAttachments(currentTaskId).catch(console.error);
}, 100);
}
});
});
const updateTitle = (val: string) => {
setTitle(val);
updateTask(props.task.id, { title: val });
@@ -143,20 +168,39 @@ export const TaskDetail: Component<TaskDetailProps> = (props) => {
}
};
const updateTags = (tags: string[]) => {
const updateTags = (tags: string[], shareModeOverrides?: Record<string, "handoff" | "collaborative">) => {
const ctx = currentTaskContext();
const currentTags = props.task.tags || [];
let newTags = [...tags];
currentTags
.filter(tag => isCurrentUserContextTag(tag))
.forEach(tag => {
if (!newTags.some(existing => existing.toLowerCase() === tag.toLowerCase())) {
newTags.push(tag);
}
});
// If in a bucket view, ensure the bucket tag is preserved (it was hidden from the UI so it's missing from 'tags')
if (typeof ctx === 'object' && 'bucketId' in ctx) {
const bucketTagName = `@${(ctx as { name: string }).name}`;
const bucketContext = store.contexts.find(context => context.id === ctx.bucketId);
const bucketTagName = buildShareTag(bucketContext?.displayName || ctx.name);
// Check case-insensitive existence
if (!newTags.some(t => t.toLowerCase() === bucketTagName.toLowerCase())) {
newTags.push(bucketTagName);
}
}
updateTask(props.task.id, { tags: newTags });
props.task.tags
.filter(tag => isTaskLockedNoteTag(props.task, tag))
.forEach(tag => {
if (!newTags.some(existing => existing.toLowerCase() === tag.toLowerCase())) {
newTags.push(tag);
}
});
const { labelTags, shareRefs, noteRefs } = deriveTaskRelationsFromTags(newTags, props.task.shareRefs, shareModeOverrides);
updateTask(props.task.id, { tags: newTags, labelTags, shareRefs, noteRefs });
};
const onDateInputChange = (e: Event) => {
@@ -172,20 +216,14 @@ export const TaskDetail: Component<TaskDetailProps> = (props) => {
// Filter out bucket tags if we are currently INSIDE that bucket view
const visibleTags = createMemo(() => {
const tags = props.task.tags || [];
const ctx = currentTaskContext();
if (typeof ctx === 'object' && 'bucketId' in ctx) {
// We are in a bucket view. Hide the tag that matches this bucket's name.
// Buckets are now tagged with '@' + name.
const bucketTagName = `@${(ctx as { name: string }).name.toLowerCase()}`;
return tags.filter(t => t.toLowerCase() !== bucketTagName);
}
return tags;
return getVisibleTaskTags(props.task, currentTaskContext());
});
const statusOptions = createMemo(() => getStatusOptionsForTask(props.task.recurrence));
const recurrenceProgress = createMemo(() => {
if (!props.task.completed && props.task.status !== 10) return null;
return getRecurrenceProgress(props.task.recurrence, props.task.updated, now());
});
return (
<Sheet open={props.isOpen} onOpenChange={(open) => {
@@ -206,10 +244,10 @@ export const TaskDetail: Component<TaskDetailProps> = (props) => {
e.preventDefault();
}
}}
class="w-full sm:max-w-2xl px-0 sm:px-0 flex flex-col h-full bg-background border-l border-border shadow-2xl"
class="w-full sm:max-w-2xl p-0 gap-0 flex flex-col h-full bg-background border-l border-border shadow-2xl"
>
{/* Header Area */}
<div class="px-6 pt-6 pb-2 shrink-0">
<div class="px-6 pt-6 pb-0 shrink-0">
<div class="flex items-start gap-4">
<div class="shrink-0 mt-0.5">
<Select<any>
@@ -229,7 +267,7 @@ export const TaskDetail: Component<TaskDetailProps> = (props) => {
updateStatus("10");
}}
>
<StatusCircle status={props.task.status ?? 0} size={28} />
<StatusCircle status={props.task.status ?? 0} completed={props.task.completed} size={28} recurrenceProgress={recurrenceProgress()} />
</SelectTrigger>
<SelectContent />
</Select>
@@ -339,6 +377,26 @@ export const TaskDetail: Component<TaskDetailProps> = (props) => {
</Select>
</div>
{/* Favorite */}
<div class="flex items-center gap-1 group shrink-0">
<span class="text-[0.625rem] uppercase font-bold tracking-wider text-muted-foreground/70 hidden sm:inline"></span>
<Button
variant="ghost"
size="sm"
class={cn("h-8 px-1 sm:px-2 text-[0.625rem] font-bold uppercase tracking-wider transition-colors shrink-0", props.task.tags?.includes(getFavoriteTag()) ? "text-amber-500 hover:bg-amber-500/10" : "text-muted-foreground hover:bg-muted/50 hover:text-foreground")}
onClick={() => {
const favTag = getFavoriteTag();
const currentTags = props.task.tags || [];
const isFav = currentTags.includes(favTag);
const newTags = isFav ? currentTags.filter(t => t !== favTag) : [...currentTags, favTag];
updateTags(newTags);
}}
>
<Star size={14} class={cn("opacity-70 group-hover:opacity-100 transition-opacity shrink-0", props.task.tags?.includes(getFavoriteTag()) && "fill-amber-500")} />
<span class="hidden sm:inline sm:ml-1.5">{props.task.tags?.includes(getFavoriteTag()) ? "Starred" : "Star"}</span>
</Button>
</div>
{/* Commands Shortcut */}
<div class="flex items-center gap-1 shrink-0">
<Button
@@ -356,6 +414,24 @@ export const TaskDetail: Component<TaskDetailProps> = (props) => {
<span class="hidden sm:inline">Commands</span>
</Button>
</div>
{/* Unified Media Upload Shortcut */}
<div class="flex items-center gap-1 shrink-0">
<Button
variant="ghost"
size="sm"
class="h-8 px-1 sm:px-2 text-[0.625rem] font-bold uppercase tracking-wider hover:bg-muted/50 flex items-center gap-1 sm:gap-1.5 text-muted-foreground hover:text-foreground transition-colors"
onClick={() => {
const instance = editorInstance();
if (instance) {
instance.chain().focus().uploadMedia().run();
}
}}
>
<FileText size={14} class="opacity-70" />
<span class="hidden sm:inline">Upload</span>
</Button>
</div>
</div>
{/* Metadata Row (Dates/Timestamps) */}
@@ -392,10 +468,19 @@ export const TaskDetail: Component<TaskDetailProps> = (props) => {
</div>
<div class="h-px w-full bg-border mt-5" />
<Show when={editorInstance()}>
{(editor: any) => (
<div class="flex flex-col pt-1 pb-1 relative z-30">
<TextBubbleMenu editor={editor()} />
<TableBubbleMenu editor={editor()} />
</div>
)}
</Show>
</div>
{/* Content Area */}
<div class="flex-1 overflow-y-auto px-6 py-4 pb-20 flex flex-col gap-6">
<div class="flex-1 overflow-y-auto px-6 pb-20 pt-4 flex flex-col">
<Suspense fallback={<div class="h-32 animate-pulse bg-muted/20 rounded-lg" />}>
<TaskEditor
content={props.task.content}
@@ -426,6 +511,7 @@ export const TaskDetail: Component<TaskDetailProps> = (props) => {
<div class="flex flex-nowrap overflow-x-auto no-scrollbar items-center gap-1.5 min-w-0 flex-1 pb-1">
<TagPicker
selectedTags={visibleTags()}
getShareModeForTag={(tag) => getTaskShareModeForTag(props.task, tag)}
onTagsChange={updateTags}
/>
<div class="flex flex-nowrap items-center gap-1.5 shrink-0 pr-2">
@@ -606,17 +692,68 @@ export const TaskDetail: Component<TaskDetailProps> = (props) => {
{/* Sharing Section */}
<div class="p-2 border-t border-border/50">
<div class="text-[0.625rem] font-bold uppercase tracking-wider text-muted-foreground/70 px-2 py-1 mb-1">Sharing</div>
<UserSharePicker
taskId={props.task.id}
sharedWith={props.task.sharedWith || []}
tags={props.task.tags || []}
/>
<div class="px-2 py-1.5 rounded-lg bg-muted/20 border border-border/30 space-y-2">
<p class="text-[0.6875rem] text-muted-foreground leading-relaxed">
Sharing is driven by the task&apos;s <span class="font-semibold text-foreground">@tags</span>. Add or remove
<span class="font-semibold text-foreground"> @people</span>, <span class="font-semibold text-foreground">@buckets</span>, and <span class="font-semibold text-foreground">#notes</span> in the tag list above.
</p>
<Show
when={props.task.shareRefs.length > 0}
fallback={<p class="text-[0.6875rem] text-muted-foreground/70">No shared contexts on this task.</p>}
>
<div class="flex flex-wrap gap-1.5">
<For each={props.task.shareRefs.filter(ref => {
if (ref.kind === "note") {
const label = buildNoteTag(store.notes.find(note => note.id === ref.contextId || note.key === ref.key)?.key || ref.key);
return !isTaskLockedNoteTag(props.task, label);
}
const context = store.contexts.find(existing => existing.id === ref.contextId) || store.contexts.find(existing => existing.key === ref.key);
return !isCurrentUserContextTag(`@${context?.displayName || ref.key}`);
})}>
{(ref) => {
const note = ref.kind === "note"
? store.notes.find(existing => existing.id === ref.contextId || existing.key === ref.key)
: null;
const context = ref.kind === "note"
? null
: store.contexts.find(existing => existing.id === ref.contextId) || store.contexts.find(existing => existing.key === ref.key);
const label = ref.kind === "note"
? buildNoteTag(note?.key || ref.key)
: `@${context?.displayName || ref.key}`;
const policy = getTaskSharePolicy(ref);
return (
<Badge variant="secondary" class="h-6 px-2 text-[0.625rem] gap-1 bg-muted/40 border-border/40">
<span>{label}</span>
<span class="uppercase text-[0.5rem] tracking-wider text-muted-foreground">{policy}</span>
</Badge>
);
}}
</For>
</div>
</Show>
<Show when={canHandoffCollaborativelySharedTask(props.task)}>
<Button
variant="outline"
size="sm"
class="w-full h-8 text-[0.6875rem] justify-center"
onClick={async () => {
const didHandoff = await handoffCollaborativelySharedTask(props.task.id);
if (didHandoff) {
props.onClose();
}
}}
>
Handoff to collaborators
</Button>
</Show>
</div>
</div>
</div>
</PopoverContent>
</Popover>
{/* Close button (Mobile only) */}
<Button
variant="ghost"
size="sm"
@@ -681,139 +818,3 @@ const MetadataItem: Component<{
);
};
// User Share Picker Component
const UserSharePicker: Component<{
taskId: string;
sharedWith: Array<{ userId: string; access: 'view' | 'edit' }>;
tags: string[];
}> = (props) => {
const [users, setUsers] = createSignal<Array<{ id: string; name: string }>>([]);
const [selectedUserId, setSelectedUserId] = createSignal<string>("");
const [loading, setLoading] = createSignal(true);
// Get matching ShareRules for this task
const matchingRules = () => {
const currentUserId = pb.authStore.model?.id;
return store.shareRules.filter(rule => {
if (rule.ownerId !== currentUserId) return false;
if (rule.type === 'all') return true;
if (rule.type === 'tag' && props.tags.includes(rule.tagName!)) return true;
return false;
});
};
const getUserName = (userId: string) => {
return users().find(u => u.id === userId)?.name || userId;
};
// Fetch users on mount
createEffect(async () => {
try {
const records = await pb.collection('users').getFullList({
fields: 'id,name'
});
setUsers(records.map(r => ({ id: r.id, name: r.name || r.email || r.id })));
} catch (err) {
console.error("Failed to load users:", err);
} finally {
setLoading(false);
}
});
const availableUsers = () => {
const sharedUserIds = props.sharedWith.map(s => s.userId);
const currentUserId = pb.authStore.model?.id;
return users().filter(u => !sharedUserIds.includes(u.id) && u.id !== currentUserId);
};
return (
<>
<div class="flex gap-1 mb-2">
<Select<{ id: string; name: string }>
value={users().find(u => u.id === selectedUserId()) || null}
onChange={(user) => setSelectedUserId(user?.id || "")}
options={availableUsers()}
optionValue="id"
optionTextValue="name"
placeholder={loading() ? "Loading..." : "Select user..."}
itemComponent={(itemProps: any) => (
<SelectItem item={itemProps.item}>{itemProps.item.rawValue.name}</SelectItem>
)}
>
<SelectTrigger class="flex-1 h-8 text-xs">
<SelectValue<{ id: string; name: string }>>
{(state) => state.selectedOption()?.name || "Select user..."}
</SelectValue>
</SelectTrigger>
<SelectContent />
</Select>
<Button
variant="ghost"
size="icon"
class="h-8 w-8"
disabled={!selectedUserId()}
onClick={async () => {
if (selectedUserId()) {
await shareTask(props.taskId, selectedUserId(), 'edit');
setSelectedUserId("");
}
}}
>
<Share2 size={14} />
</Button>
</div>
<For each={props.sharedWith}>
{(share) => (
<div class="flex items-center justify-between py-1 px-2 rounded-lg bg-muted/30 mb-1">
<span class="text-xs truncate flex-1">{getUserName(share.userId)}</span>
<div class="flex gap-1">
<Button
variant="ghost"
size="icon"
class="h-6 w-6 hover:bg-amber-500/10 hover:text-amber-600"
title="Split - Give them an independent copy"
onClick={() => splitTask(props.taskId, share.userId)}
>
<GitBranch size={12} />
</Button>
<Button
variant="ghost"
size="icon"
class="h-6 w-6 hover:bg-destructive/10 hover:text-destructive"
title="Revoke access"
onClick={() => revokeShare(props.taskId, share.userId)}
>
<UserMinus size={12} />
</Button>
</div>
</div>
)}
</For>
{/* ShareRule-based sharing */}
{matchingRules().length > 0 && (
<div class="mt-3 pt-3 border-t border-border/30">
<div class="text-[9px] font-bold uppercase tracking-widest text-muted-foreground/40 mb-2 px-1">Automatic Rule Shares</div>
<For each={matchingRules()}>
{(rule) => (
<div class="flex items-center gap-2 py-1.5 px-2 rounded-lg bg-muted/10 mb-1 border border-border/5">
<div class="shrink-0 text-muted-foreground/40">
{rule.type === 'tag' ? <Tag size={10} /> : <Settings size={10} />}
</div>
<div class="flex flex-col min-w-0">
<span class="text-[0.6875rem] font-medium leading-none truncate">
{getUserName(rule.targetUserId)}
</span>
<span class="text-[9px] text-muted-foreground/50 mt-0.5 uppercase tracking-tighter">
{rule.type === 'all' ? 'All Tasks Rule' : `Matched Tag: ${rule.tagName}`}
</span>
</div>
</div>
)}
</For>
</div>
)}
</>
);
};
+123 -28
View File
@@ -4,11 +4,16 @@ import StarterKit from "@tiptap/starter-kit";
import Placeholder from "@tiptap/extension-placeholder";
import TaskList from "@tiptap/extension-task-list";
import { CustomTaskItem } from "./CustomTaskItem";
import Image from "@tiptap/extension-image";
import { MultiTaskList } from "./MultiTaskList.tsx";
import { MultiTaskItem } from "./MultiTaskItem";
import { CustomImage as Image } from "@/lib/extensions/image";
import Underline from "@tiptap/extension-underline";
import { X } from "lucide-solid";
import { cn } from "@/lib/utils";
import { store } from "@/store";
import { store, activeTaskId, activeNoteId, uploadTaskAttachment, uploadNoteAttachment } from "@/store";
import { convertImageToWebpFile } from "@/lib/utils";
import { toast } from "solid-sonner";
import type { Editor } from "@tiptap/core";
import { SlashCommands } from "@/lib/slash-command";
import { suggestion } from "@/lib/slash-renderer";
@@ -26,6 +31,10 @@ import { Typography } from "@tiptap/extension-typography";
import { Dropcursor } from "@tiptap/extension-dropcursor";
import { Mention } from "@tiptap/extension-mention";
import { userSuggestion, noteSuggestion } from "@/lib/mention-suggestion";
import { FileViewer } from "@/lib/extensions/file-viewer";
import { Video } from "@/lib/extensions/video";
import { FileAttachment } from "@/lib/extensions/file-attachment";
import { MediaUpload } from "@/lib/extensions/media-upload";
interface TaskEditorProps {
content?: string;
@@ -51,6 +60,51 @@ export const TaskEditor: Component<TaskEditorProps> = (props) => {
setZoomOffset({ x: 0, y: 0 });
};
const handleFileUploads = async (editor: Editor, files: File[]) => {
const taskId = activeTaskId();
const noteId = activeNoteId();
if (!taskId && !noteId) {
toast.error("Cannot upload files outside of a task or note.");
return;
}
for (const file of files) {
const isImage = file.type.startsWith('image/');
const isVideo = file.type.startsWith('video/');
toast.promise(
(async () => {
let fileToUpload = file;
if (isImage) {
fileToUpload = await convertImageToWebpFile(file);
}
if (taskId) {
return uploadTaskAttachment(taskId, fileToUpload);
} else {
return uploadNoteAttachment(noteId!, fileToUpload);
}
})(),
{
loading: `Uploading ${file.name}...`,
success: ({ url, filename }) => {
if (isImage) {
// @ts-ignore
editor.chain().focus().setImage({ src: url, filename }).run();
} else if (isVideo) {
editor.chain().focus().setVideo({ src: url, filename }).run();
} else {
editor.chain().focus().insertFileAttachment({ src: url, filename, filesize: file.size.toString() }).run();
}
return `${file.name} uploaded.`;
},
error: `Failed to upload ${file.name}.`
}
);
}
};
const editor = createTiptapEditor(() => ({
element: editorRef!,
extensions: [
@@ -80,6 +134,17 @@ export const TaskEditor: Component<TaskEditorProps> = (props) => {
class: "flex gap-2 items-start my-1",
},
}),
MultiTaskList.configure({
HTMLAttributes: {
class: "not-prose pl-2",
},
}),
MultiTaskItem.configure({
nested: true,
HTMLAttributes: {
class: "flex gap-2 items-start my-1",
},
}),
SlashCommands.configure({
suggestion,
}),
@@ -159,6 +224,10 @@ export const TaskEditor: Component<TaskEditorProps> = (props) => {
color: "var(--primary)",
width: 2,
}),
FileViewer,
Video,
FileAttachment,
MediaUpload,
],
// Use untrack so the editor doesn't re-initialize when props.content changes
content: untrack(() => props.content) || "",
@@ -167,6 +236,27 @@ export const TaskEditor: Component<TaskEditorProps> = (props) => {
props.onUpdate(editor.getHTML());
},
editorProps: {
handlePaste: (_view, event) => {
const items = Array.from(event.clipboardData?.items || []);
const files = items
.map(item => item.getAsFile())
.filter((file): file is File => file !== null);
if (files.length > 0) {
event.preventDefault();
handleFileUploads(editor()!, files);
return true;
}
return false;
},
handleDrop: (_view, event, _slice, moved) => {
if (!moved && event.dataTransfer?.files?.length) {
event.preventDefault();
handleFileUploads(editor()!, Array.from(event.dataTransfer.files));
return true;
}
return false;
},
attributes: {
class: cn(
"prose prose-sm dark:prose-invert max-w-none focus:outline-none min-h-[150px]",
@@ -185,10 +275,13 @@ export const TaskEditor: Component<TaskEditorProps> = (props) => {
// Custom Task List Styling
"[&_ul[data-type='taskList']]:list-none [&_ul[data-type='taskList']]:p-0",
"[&_li[data-type='taskItem']]:flex [&_li[data-type='taskItem']]:items-start",
"[&_ul[data-type='multiTaskList']]:list-none [&_ul[data-type='multiTaskList']]:p-0",
"[&_li[data-type='multiTaskItem']]:flex [&_li[data-type='multiTaskItem']]:items-start",
"[&_.tiptap-task-content]:flex-1 [&_.tiptap-task-content]:min-w-0 [&_.tiptap-task-content]:min-h-[1.5rem]",
"[&_.tiptap-task-content>p]:min-h-[1.5rem] [&_.tiptap-task-content>p]:m-0 [&_.tiptap-task-content>p]:w-full",
"[&_.tiptap-task-content>p:empty]:after:content-['\\200B'] [&_.tiptap-task-content>p:empty]:after:inline-block",
"[&_.tiptap-task-content>p>br]:after:content-['\\200B'] [&_.tiptap-task-content>p>br]:after:inline-block", // if tiptap adds br
"[&>.file-viewer-wrapper:first-child]:mt-0 [&>.file-viewer-wrapper:first-child]:border-t-0 [&>.file-viewer-wrapper:first-child]:border-x-0 [&>.file-viewer-wrapper:first-child]:rounded-none [&>.file-viewer-wrapper:first-child]:bg-transparent [&>.file-viewer-wrapper:first-child]:h-[80vh]",
props.class
),
},
@@ -239,33 +332,35 @@ export const TaskEditor: Component<TaskEditorProps> = (props) => {
// Handle full screen images as before
return (
<div class="relative w-full flex flex-col flex-1">
<div
class="absolute -top-6 -left-4 -right-4 h-8 cursor-pointer group flex items-center justify-center z-10"
onClick={() => {
const e = editor();
if (!e) return;
e.chain().insertContentAt(0, '<p></p>').focus('start').run();
}}
title="Add empty line at the top"
>
<span class="opacity-0 group-hover:opacity-100 text-[10px] uppercase font-bold text-muted-foreground tracking-widest transition-opacity mt-1">
Click to insert newline above
</span>
<div class="w-full h-full flex flex-col">
<div class="w-full flex-1 flex flex-col">
<div
class="w-full h-8 cursor-pointer group flex items-center justify-center shrink-0 transition-all"
onClick={() => {
const e = editor();
if (!e) return;
e.chain().insertContentAt(0, '<p></p>').focus('start').run();
}}
title="Add empty line at the top"
>
<span class="opacity-0 group-hover:opacity-100 text-[10px] uppercase font-bold text-muted-foreground tracking-widest transition-opacity">
Click to insert newline above
</span>
</div>
<div
ref={editorRef}
class="w-full flex-1 cursor-text"
onClick={(e) => {
const target = e.target as HTMLElement;
if (target.tagName.toLowerCase() === 'img') {
setFullscreenImage((target as HTMLImageElement).src);
} else if (e.target === editorRef) {
// Focus end if clicking on padding below content
editor()?.chain().focus().run();
}
}}
/>
</div>
<div
ref={editorRef}
class="w-full flex-1 cursor-text"
onClick={(e) => {
const target = e.target as HTMLElement;
if (target.tagName.toLowerCase() === 'img') {
setFullscreenImage((target as HTMLImageElement).src);
} else if (e.target === editorRef) {
// Focus end if clicking on padding below content
editor()?.chain().focus().run();
}
}}
/>
{/* Note Preview Popover */}
<Show when={previewNote() && store.notes.find(n => n.id === previewNote()!.noteId)}>
+512
View File
@@ -0,0 +1,512 @@
import { type Component, createEffect, createSignal, For, onCleanup, Show } from "solid-js";
import { Search, X, Tag, ArrowUpCircle, Clock, Minus, Save, Trash2, Bookmark, User, Star, SlidersHorizontal } from "lucide-solid";
import { store, setFilter, clearFilter, saveFilterTemplate, applyFilterTemplate, removeFilterTemplate, loadAllHistory } from "@/store";
import { Button } from "./ui/button";
import { cn } from "@/lib/utils";
import { Badge } from "./ui/badge";
import { Select, SelectContent, SelectItem, SelectTrigger } from "./ui/select";
import { Command, CommandInput, CommandList, CommandEmpty, CommandGroup, CommandItem } from "./ui/command";
import { Popover, PopoverContent, PopoverTrigger } from "./ui/popover";
import { useTheme } from "./ThemeProvider";
import { getThemeAdjustedColor } from "@/lib/colors";
export const TaskSearchPopover: Component = () => {
const { resolvedTheme } = useTheme();
const [isOpen, setIsOpen] = createSignal(false);
const [isExpanded, setIsExpanded] = createSignal(false);
const [isTagOpen, setIsTagOpen] = createSignal(false);
const [tagSearch, setTagSearch] = createSignal("");
const [newTemplateName, setNewTemplateName] = createSignal("");
const [isSaving, setIsSaving] = createSignal(false);
let triggerRef: HTMLDivElement | undefined;
let panelRef: HTMLDivElement | undefined;
let searchInputRef: HTMLInputElement | undefined;
const hasActiveFilters = () => {
const f = store.filter;
return (f.query || "") !== ""
|| (f.tags || []).length > 0
|| f.editedToday
|| f.ownedByMe
|| f.starred
|| f.priorityMin !== 1
|| f.priorityMax !== 10
|| f.urgencyMin !== 1
|| f.urgencyMax !== 10;
};
const hasExpandedFilters = () => {
const f = store.filter;
return (f.tags || []).length > 0
|| f.editedToday
|| f.ownedByMe
|| f.starred
|| f.priorityMin !== 1
|| f.priorityMax !== 10
|| f.urgencyMin !== 1
|| f.urgencyMax !== 10;
};
const allTags = () => store.tagDefinitions.map(d => d.name).sort();
const closePanel = () => {
setIsOpen(false);
setIsExpanded(false);
setIsSaving(false);
};
createEffect(() => {
if (!isOpen()) return;
const handlePointerDown = (event: PointerEvent) => {
const target = event.target as Node | null;
if (!target) return;
if (triggerRef?.contains(target)) return;
if (panelRef?.contains(target)) return;
closePanel();
};
document.addEventListener("pointerdown", handlePointerDown);
onCleanup(() => document.removeEventListener("pointerdown", handlePointerDown));
});
return (
<>
<div ref={triggerRef} class="fixed top-4 right-5 sm:right-6 z-[60]">
<Button
variant="ghost"
size="icon"
class={cn(
"bg-background/80 backdrop-blur-sm border border-border hover:bg-muted shadow-sm transition-all duration-300",
hasActiveFilters() && "border-primary/50 ring-1 ring-primary/20",
isOpen() && "bg-muted ring-1 ring-border"
)}
title="Search & Filter Tasks"
onClick={() => {
const next = !isOpen();
setIsOpen(next);
if (!next) {
setIsExpanded(false);
setIsSaving(false);
}
}}
>
<Search
size={18}
class={cn(
"transition-transform duration-300",
isOpen() && "scale-110",
hasActiveFilters() && "text-primary"
)}
/>
</Button>
</div>
<Show when={isOpen()}>
<div ref={panelRef} class="fixed top-[4.25rem] left-1/2 -translate-x-1/2 z-[95] w-[min(calc(100vw-2rem),32rem)]">
<div class="overflow-hidden rounded-2xl border border-border/70 bg-background/95 shadow-2xl backdrop-blur-md">
<div class="flex items-center gap-2 p-2">
<div class="relative flex-1 group">
<Search size={16} class="absolute left-3 top-1/2 -translate-y-1/2 text-muted-foreground/50 group-focus-within:text-primary transition-colors" />
<input
ref={(el) => {
searchInputRef = el;
if (el) setTimeout(() => el.focus(), 50);
}}
type="text"
placeholder="Search tasks, descriptions, tags..."
value={store.filter.query}
onInput={(e) => setFilter({ query: e.currentTarget.value })}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
closePanel();
}
}}
class="w-full bg-muted/30 border border-border/50 rounded-xl pl-10 pr-4 py-2.5 text-sm outline-none focus:ring-2 focus:ring-primary/20 focus:border-primary/40 transition-all placeholder:text-muted-foreground/30"
/>
</div>
<Button
variant="ghost"
size="icon"
class={cn(
"h-10 w-10 rounded-xl border border-border/50 bg-muted/30 hover:bg-muted/60 shrink-0",
(isExpanded() || hasExpandedFilters()) && "border-primary/30 bg-primary/5 text-primary"
)}
title="More filters"
onClick={() => setIsExpanded(!isExpanded())}
>
<SlidersHorizontal size={16} />
</Button>
<Button
variant="ghost"
size="sm"
class="h-10 rounded-xl shrink-0 px-3 text-xs font-bold uppercase tracking-wide"
title="Clear search"
disabled={!store.filter.query}
onClick={() => {
setFilter({ query: "" });
searchInputRef?.focus();
}}
>
Clear
</Button>
</div>
<Show when={isExpanded()}>
<>
<div class="px-4 py-3 border-t border-border/50 bg-muted/20 flex flex-wrap items-center justify-between gap-3">
<div class="flex flex-wrap items-center gap-2">
<h3 class="text-xs font-bold uppercase tracking-widest text-muted-foreground flex items-center gap-2 mr-1">
<SlidersHorizontal size={12} />
Filters
</h3>
<button
onClick={() => setFilter({ editedToday: !store.filter.editedToday })}
class={cn(
"flex items-center gap-1.5 px-2 py-0.5 rounded-full text-[0.5625rem] font-black uppercase tracking-tight transition-all border",
store.filter.editedToday
? "bg-primary/10 text-primary border-primary/20"
: "bg-muted/50 text-muted-foreground border-transparent hover:bg-muted"
)}
>
<div class={cn("w-1.5 h-1.5 rounded-full", store.filter.editedToday ? "bg-primary" : "bg-muted-foreground/30")} />
Edited Today
</button>
<button
onClick={() => setFilter({ ownedByMe: !store.filter.ownedByMe })}
class={cn(
"flex items-center gap-1.5 px-2 py-0.5 rounded-full text-[0.5625rem] font-black uppercase tracking-tight transition-all border",
store.filter.ownedByMe
? "bg-primary/10 text-primary border-primary/20"
: "bg-muted/50 text-muted-foreground border-transparent hover:bg-muted"
)}
>
<User size={10} class={cn(store.filter.ownedByMe ? "text-primary" : "text-muted-foreground/50")} />
Mine
</button>
<button
onClick={() => setFilter({ starred: !store.filter.starred })}
class={cn(
"flex items-center gap-1.5 px-2 py-0.5 rounded-full text-[0.5625rem] font-black uppercase tracking-tight transition-all border",
store.filter.starred
? "bg-amber-500/10 text-amber-500 border-amber-500/20"
: "bg-muted/50 text-muted-foreground border-transparent hover:bg-muted"
)}
>
<Star size={10} class={cn(store.filter.starred ? "fill-amber-500 text-amber-500" : "text-muted-foreground/50")} />
Starred
</button>
</div>
<div class="flex items-center gap-2">
<Button
variant="ghost"
size="sm"
class="h-7 px-2 text-[0.625rem] font-bold uppercase tracking-wider text-muted-foreground hover:text-primary"
onClick={() => {
loadAllHistory();
closePanel();
}}
title="Load all completed tasks"
>
Load All
</Button>
<Show when={hasActiveFilters()}>
<Button
variant="ghost"
size="sm"
class="h-7 px-2 text-[0.625rem] font-bold uppercase tracking-wider text-muted-foreground hover:text-destructive"
onClick={clearFilter}
>
Clear
</Button>
</Show>
</div>
</div>
<div class="max-h-[min(70vh,40rem)] overflow-y-auto">
<div class="p-4 space-y-6">
<div class="grid grid-cols-1 sm:grid-cols-2 gap-6">
<div class="space-y-2.5">
<label class="text-[0.625rem] font-black uppercase tracking-widest text-muted-foreground ml-1">Priority Range</label>
<div class="flex items-center gap-2 bg-muted/20 p-1 rounded-xl border border-border/30">
<Select
value={store.filter.priorityMin.toString()}
onChange={(val) => val && setFilter({ priorityMin: parseInt(val) })}
options={["1", "2", "3", "4", "5", "6", "7", "8", "9", "10"]}
itemComponent={(props) => <SelectItem item={props.item}>{props.item.rawValue}</SelectItem>}
>
<SelectTrigger class="h-9 flex-1 bg-transparent border-none shadow-none focus:ring-0 text-xs font-bold text-foreground">
<div class="flex items-center gap-2 justify-center">
<ArrowUpCircle size={14} class="text-muted-foreground/70" />
<span>Min: {store.filter.priorityMin}</span>
</div>
</SelectTrigger>
<SelectContent />
</Select>
<div class="w-px h-4 bg-border/50" />
<Select
value={store.filter.priorityMax.toString()}
onChange={(val) => val && setFilter({ priorityMax: parseInt(val) })}
options={["1", "2", "3", "4", "5", "6", "7", "8", "9", "10"]}
itemComponent={(props) => <SelectItem item={props.item}>{props.item.rawValue}</SelectItem>}
>
<SelectTrigger class="h-9 flex-1 bg-transparent border-none shadow-none focus:ring-0 text-xs font-bold text-foreground">
<div class="flex items-center gap-2 justify-center">
<span>Max: {store.filter.priorityMax}</span>
</div>
</SelectTrigger>
<SelectContent />
</Select>
</div>
</div>
<div class="space-y-2.5">
<label class="text-[0.625rem] font-black uppercase tracking-widest text-muted-foreground ml-1">Urgency Range</label>
<div class="flex items-center gap-2 bg-muted/20 p-1 rounded-xl border border-border/30">
<Select
value={store.filter.urgencyMin.toString()}
onChange={(val) => val && setFilter({ urgencyMin: parseInt(val) })}
options={["1", "2", "3", "4", "5", "6", "7", "8", "9", "10"]}
itemComponent={(props) => <SelectItem item={props.item}>{props.item.rawValue}</SelectItem>}
>
<SelectTrigger class="h-9 flex-1 bg-transparent border-none shadow-none focus:ring-0 text-xs font-bold text-foreground">
<div class="flex items-center gap-2 justify-center">
<Clock size={14} class="text-muted-foreground/70" />
<span>Min: {store.filter.urgencyMin}</span>
</div>
</SelectTrigger>
<SelectContent />
</Select>
<div class="w-px h-4 bg-border/50" />
<Select
value={store.filter.urgencyMax.toString()}
onChange={(val) => val && setFilter({ urgencyMax: parseInt(val) })}
options={["1", "2", "3", "4", "5", "6", "7", "8", "9", "10"]}
itemComponent={(props) => <SelectItem item={props.item}>{props.item.rawValue}</SelectItem>}
>
<SelectTrigger class="h-9 flex-1 bg-transparent border-none shadow-none focus:ring-0 text-xs font-bold text-foreground">
<div class="flex items-center gap-2 justify-center">
<span>Max: {store.filter.urgencyMax}</span>
</div>
</SelectTrigger>
<SelectContent />
</Select>
</div>
</div>
</div>
<div class="space-y-2.5">
<label class="text-[0.625rem] font-black uppercase tracking-widest text-muted-foreground ml-1 flex items-center justify-between">
Filter by Tags
<span class="text-[0.5625rem] lowercase font-medium opacity-50 tracking-normal text-right">
Click tag name to toggle exclude<br />
Click X to remove
</span>
</label>
<div class="flex flex-wrap gap-2 min-h-[44px] p-2 bg-muted/10 border border-dashed border-border/60 rounded-xl">
<For each={store.filter.tags}>
{(tag) => (
<Badge
variant="secondary"
class={cn(
"h-7 px-2.5 rounded-lg text-xs gap-2 transition-all border shadow-sm",
tag.excluded
? "bg-red-500/10 text-red-600 dark:text-red-400 border-red-500/20 hover:bg-red-500/20"
: "bg-indigo-500/10 text-indigo-600 dark:text-indigo-300 border-indigo-500/20 hover:bg-indigo-500/20"
)}
style={!tag.excluded ? (() => {
const def = store.tagDefinitions.find(d => d.name === tag.name);
const color = getThemeAdjustedColor(def?.color, def?.theme, resolvedTheme());
return color ? {
"background-color": `${color}15`,
"color": color,
"border-color": `${color}30`
} : {};
})() : {}}
>
<div
class="flex items-center gap-2 cursor-pointer h-full"
onClick={() => {
setFilter({
tags: store.filter.tags.map(t =>
t.name === tag.name ? { ...t, excluded: !t.excluded } : t
)
});
}}
>
<Show when={tag.excluded} fallback={<Tag size={12} class="opacity-70" />}>
<Minus size={12} class="opacity-100" />
</Show>
<span class={cn(tag.excluded && "line-through opacity-70")}>{tag.name}</span>
</div>
<button
class="p-0.5 hover:bg-foreground/10 rounded transition-colors"
onClick={(e) => {
e.stopPropagation();
setFilter({ tags: store.filter.tags.filter(t => t.name !== tag.name) });
}}
>
<X size={10} />
</button>
</Badge>
)}
</For>
<Popover open={isTagOpen()} onOpenChange={setIsTagOpen}>
<PopoverTrigger class="h-7 px-3 bg-muted/40 border-none shadow-none focus:ring-0 text-[0.625rem] font-bold uppercase tracking-wider rounded-lg hover:bg-muted/60 transition-colors">
<span>+ Tag</span>
</PopoverTrigger>
<PopoverContent class="w-[200px] p-0" align="start">
<Command>
<CommandInput
placeholder="Search tags..."
value={tagSearch()}
onValueChange={setTagSearch}
/>
<CommandList>
<Show when={allTags().filter(t => {
if (t.startsWith("_")) return false;
const search = tagSearch().toLowerCase();
if (!search && (t.startsWith("@") || t.startsWith("#"))) return false;
return t.toLowerCase().includes(search);
}).length === 0}>
<CommandEmpty>No tags found.</CommandEmpty>
</Show>
<CommandGroup>
<For each={allTags().filter(t => {
if (t.startsWith("_")) return false;
const search = tagSearch().toLowerCase();
if (!search && (t.startsWith("@") || t.startsWith("#"))) return false;
return t.toLowerCase().includes(search);
})}>
{(tag) => (
<CommandItem
value={tag}
onSelect={() => {
if (!store.filter.tags.some(t => t.name === tag)) {
setFilter({ tags: [...store.filter.tags, { name: tag, excluded: false }] });
}
setIsTagOpen(false);
setTagSearch("");
}}
>
{tag}
</CommandItem>
)}
</For>
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
</div>
</div>
</div>
<div class="px-4 pb-4 pt-4 border-t border-border/50 space-y-4 bg-muted/5">
<div class="flex items-center justify-between ml-1">
<label class="text-[0.625rem] font-black uppercase tracking-widest text-muted-foreground flex items-center gap-2">
<Bookmark size={12} class="text-primary" />
Saved Filters
</label>
<Show when={!isSaving() && hasActiveFilters()}>
<button
onClick={() => setIsSaving(true)}
class="text-[0.5625rem] font-bold uppercase tracking-wider text-primary hover:underline flex items-center gap-1"
>
<Save size={10} />
Save Current
</button>
</Show>
</div>
<Show when={isSaving()}>
<div class="flex items-center gap-2 animate-in fade-in slide-in-from-top-1">
<input
type="text"
placeholder="Template name..."
value={newTemplateName()}
onInput={(e) => setNewTemplateName(e.currentTarget.value)}
class="flex-1 bg-muted/30 border border-primary/20 rounded-lg px-3 py-1.5 text-xs outline-none focus:ring-1 focus:ring-primary/30"
onKeyDown={(e) => {
if (e.key === "Enter" && newTemplateName().trim()) {
saveFilterTemplate(newTemplateName().trim());
setNewTemplateName("");
setIsSaving(false);
} else if (e.key === "Escape") {
setIsSaving(false);
}
}}
/>
<Button
size="sm"
class="h-8 px-3"
disabled={!newTemplateName().trim()}
onClick={() => {
saveFilterTemplate(newTemplateName().trim());
setNewTemplateName("");
setIsSaving(false);
}}
>
Save
</Button>
<Button
variant="ghost"
size="icon"
class="h-8 w-8"
onClick={() => setIsSaving(false)}
>
<X size={14} />
</Button>
</div>
</Show>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-2">
<For each={store.filterTemplates} fallback={
<div class="col-span-2 py-4 text-center border border-dashed border-border/40 rounded-xl bg-muted/5">
<p class="text-[0.625rem] text-muted-foreground uppercase tracking-widest">No saved filters yet</p>
</div>
}>
{(template) => (
<div class="group flex items-center justify-between p-2 rounded-xl bg-muted/20 border border-border/30 hover:bg-muted/40 transition-all">
<button
class="flex-1 text-left px-1 min-w-0"
onClick={() => applyFilterTemplate(template.id)}
>
<div class="flex items-center gap-2">
<p class="text-[0.6875rem] font-bold text-foreground truncate">{template.name}</p>
<div class="flex gap-1 opacity-60 shrink-0">
<Show when={template.filter.query}>
<div class="w-1 h-1 rounded-full bg-primary" />
</Show>
<Show when={template.filter.tags.length > 0}>
<div class="w-1 h-1 rounded-full bg-indigo-500" />
</Show>
<Show when={template.filter.priorityMin > 1 || template.filter.priorityMax < 10}>
<div class="w-1 h-1 rounded-full bg-amber-500" />
</Show>
</div>
</div>
</button>
<button
class="opacity-0 group-hover:opacity-100 p-1.5 hover:bg-destructive/10 hover:text-destructive rounded-lg transition-all"
onClick={(e) => {
e.stopPropagation();
removeFilterTemplate(template.id);
}}
>
<Trash2 size={12} />
</button>
</div>
)}
</For>
</div>
</div>
</div>
</>
</Show>
</div>
</div>
</Show>
</>
);
};
+208
View File
@@ -0,0 +1,208 @@
import { type Component, createSignal, createEffect, For } from "solid-js";
import {
Bold,
Italic,
Underline,
Highlighter,
AlignLeft,
AlignCenter,
AlignRight,
Heading1,
Heading2,
Heading3,
Heading,
Eraser,
ListTodo,
Grid3X3,
AlignJustify
} from "lucide-solid";
import { cn } from "@/lib/utils";
import type { Editor } from "@tiptap/core";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
interface GenericAction {
label: string;
icon: Component;
isActive?: () => boolean;
command: () => void;
iconClass?: string;
}
interface FormatGroup {
name: string;
actions: GenericAction[];
collapseOnMobile?: boolean;
mobileIcon?: Component;
}
export const TextBubbleMenu: Component<{ editor: Editor }> = (props) => {
// Track state of active styles so we can highlight the buttons
const [activeFormats, setActiveFormats] = createSignal<Record<string, boolean>>({});
createEffect(() => {
const updateState = () => {
const editor = props.editor;
if (!editor) return;
setActiveFormats({
bold: editor.isActive('bold'),
italic: editor.isActive('italic'),
underline: editor.isActive('underline'),
highlight: editor.isActive('highlight'),
alignLeft: editor.isActive({ textAlign: 'left' }),
alignCenter: editor.isActive({ textAlign: 'center' }),
alignRight: editor.isActive({ textAlign: 'right' }),
h1: editor.isActive('heading', { level: 1 }),
h2: editor.isActive('heading', { level: 2 }),
h3: editor.isActive('heading', { level: 3 }),
checklist: editor.isActive('taskList'),
multiChecklist: editor.isActive('multiTaskList'),
table: editor.isActive('table'),
});
};
props.editor.on("selectionUpdate", updateState);
props.editor.on("transaction", updateState);
return () => {
props.editor.off("selectionUpdate", updateState);
props.editor.off("transaction", updateState);
};
});
const groups: FormatGroup[] = [
{
name: "Style",
actions: [
{ label: "Bold", icon: () => <Bold size={16} />, isActive: () => activeFormats().bold, command: () => props.editor.chain().focus().toggleBold().run() },
{ label: "Italic", icon: () => <Italic size={16} />, isActive: () => activeFormats().italic, command: () => props.editor.chain().focus().toggleItalic().run() },
{ label: "Underline", icon: () => <Underline size={16} />, isActive: () => activeFormats().underline, command: () => props.editor.chain().focus().toggleUnderline().run() },
{ label: "Highlight", icon: () => <Highlighter size={16} />, isActive: () => activeFormats().highlight, command: () => props.editor.chain().focus().toggleHighlight().run() },
]
},
{
name: "Headings",
collapseOnMobile: true,
mobileIcon: () => <Heading size={16} />,
actions: [
{ label: "Heading 1", icon: () => <Heading1 size={16} />, isActive: () => activeFormats().h1, command: () => props.editor.chain().focus().toggleHeading({ level: 1 }).run() },
{ label: "Heading 2", icon: () => <Heading2 size={16} />, isActive: () => activeFormats().h2, command: () => props.editor.chain().focus().toggleHeading({ level: 2 }).run() },
{ label: "Heading 3", icon: () => <Heading3 size={16} />, isActive: () => activeFormats().h3, command: () => props.editor.chain().focus().toggleHeading({ level: 3 }).run() },
]
},
{
name: "Alignment",
collapseOnMobile: true,
mobileIcon: () => <AlignJustify size={16} />,
actions: [
{ label: "Align Left", icon: () => <AlignLeft size={16} />, isActive: () => activeFormats().alignLeft, command: () => props.editor.chain().focus().setTextAlign('left').run() },
{ label: "Align Center", icon: () => <AlignCenter size={16} />, isActive: () => activeFormats().alignCenter, command: () => props.editor.chain().focus().setTextAlign('center').run() },
{ label: "Align Right", icon: () => <AlignRight size={16} />, isActive: () => activeFormats().alignRight, command: () => props.editor.chain().focus().setTextAlign('right').run() },
]
},
{
name: "Blocks",
actions: [
{ label: "Checklist", icon: () => <ListTodo size={16} />, isActive: () => activeFormats().checklist, command: () => props.editor.chain().focus().toggleTaskList().run() },
{ label: "Multi Checklist", icon: () => <ListTodo size={16} />, isActive: () => activeFormats().multiChecklist, command: () => props.editor.chain().focus().toggleMultiTaskList().run() },
{ label: "Table", icon: () => <Grid3X3 size={16} />, isActive: () => activeFormats().table, command: () => props.editor.chain().focus().insertTable({ rows: 3, cols: 3, withHeaderRow: true }).run() },
]
},
{
name: "Clear",
actions: [
{ label: "Clear Formatting", icon: () => <Eraser size={16} />, command: () => props.editor.chain().focus().clearNodes().unsetAllMarks().run() },
]
}
];
return (
<div
class={cn(
"relative z-[140] w-full bg-background/80 backdrop-blur-md border-b border-border transition-all duration-300 ease-in-out overflow-hidden flex flex-col h-9 opacity-100"
)}
onMouseDown={(e) => {
e.preventDefault();
e.stopPropagation();
}}
>
<div class="flex items-center justify-center h-full px-2 max-w-4xl mx-auto w-full">
<div class="flex items-center gap-1">
<For each={groups}>
{(group, groupIdx) => (
<>
<div class={cn("flex items-center gap-0 px-0.5", group.collapseOnMobile ? "hidden sm:flex" : "")}>
<For each={group.actions}>
{(action) => (
<button
type="button"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
action.command();
}}
class={cn(
"p-1.5 rounded-md transition-colors group relative shrink-0",
action.isActive?.()
? "bg-primary/20 text-primary"
: "text-muted-foreground hover:bg-muted hover:text-foreground",
action.iconClass
)}
title={action.label}
>
<action.icon />
<span class="absolute top-full left-1/2 -translate-x-1/2 mt-2 px-2 py-1 text-[10px] font-bold bg-foreground text-background rounded opacity-0 group-hover:opacity-100 pointer-events-none transition-opacity whitespace-nowrap z-[200] shadow-lg">
{action.label}
</span>
</button>
)}
</For>
</div>
{group.collapseOnMobile && group.mobileIcon && (
<div class="flex sm:hidden items-center gap-0 px-0.5">
<Popover>
<PopoverTrigger class={cn(
"p-1.5 rounded-md transition-colors group relative shrink-0 text-muted-foreground hover:bg-muted hover:text-foreground",
group.actions.some(a => a.isActive?.()) && "bg-primary/20 text-primary"
)}>
<group.mobileIcon />
</PopoverTrigger>
<PopoverContent class="w-auto p-1 flex items-center gap-1 shadow-xl" align="center">
<For each={group.actions}>
{(action) => (
<button
type="button"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
action.command();
}}
class={cn(
"p-1.5 rounded-md transition-colors relative shrink-0",
action.isActive?.()
? "bg-primary text-primary-foreground"
: "text-muted-foreground hover:bg-muted hover:text-foreground",
action.iconClass
)}
title={action.label}
>
<action.icon />
</button>
)}
</For>
</PopoverContent>
</Popover>
</div>
)}
{groupIdx() < groups.length - 1 && (
<div class="w-[1px] h-4 bg-border/50 mx-1" />
)}
</>
)}
</For>
</div>
</div>
</div>
);
};
+15 -1
View File
@@ -1,5 +1,5 @@
import { createSignal, createEffect, onCleanup, untrack } from "solid-js";
import type { Task } from "@/store";
import { store, type Task } from "@/store";
export function useDelayedSort(
sourceTasks: () => Task[],
@@ -16,6 +16,20 @@ export function useDelayedSort(
const sorted = [...sourceTasks()].sort(sortFn);
const currentDisplayed = untrack(() => displayedTasks());
// Startup hydration loads tasks in stages; animating each reorder causes
// the entire visible list to flash even when only later-stage tasks changed.
if (store.isInitializing) {
if (timer) {
clearTimeout(timer);
timer = undefined;
}
if (shakingTaskIds().length > 0) {
setShakingTaskIds([]);
}
setDisplayedTasks(sorted);
return;
}
// Initial load (though now handled by initial state, kept for safety if it resets)
if (currentDisplayed.length === 0 && sorted.length > 0) {
setDisplayedTasks(sorted);
+17
View File
@@ -0,0 +1,17 @@
import autoAnimate from "@formkit/auto-animate";
import { createEffect } from "solid-js";
import { store } from "@/store";
export function useTaskListAutoAnimate(getElement: () => HTMLElement | undefined) {
let hasInitialized = false;
createEffect(() => {
if (hasInitialized || store.isInitializing) return;
const element = getElement();
if (!element) return;
autoAnimate(element, { duration: 300, easing: "ease-out" });
hasInitialized = true;
});
}
+44 -6
View File
@@ -57,6 +57,7 @@
}
--animate-task-shake: task-shake 0.3s ease-in-out;
--animate-task-quickload-fade: task-quickload-fade 0.2s ease-out forwards;
@keyframes task-shake {
@@ -78,17 +79,27 @@
}
}
--animate-task-enter: task-enter 0.25s ease-out backwards;
@keyframes task-enter {
0% {
@keyframes task-quickload-fade {
from {
opacity: 0;
}
100% {
to {
opacity: 1;
}
}
--animate-progress-fast: loading-progress 2s infinite linear;
@keyframes loading-progress {
0% {
transform: translateX(-100%);
}
100% {
transform: translateX(300%);
}
}
}
:root {
@@ -184,4 +195,31 @@
.fade-enter,
.fade-exit-to {
opacity: 0;
}
}
.mobile-footer-clearance {
padding-bottom: calc(5rem + env(safe-area-inset-bottom, 0px));
}
@media (min-width: 640px) {
.mobile-footer-clearance {
padding-bottom: 2rem;
}
}
/* --- Tiptap Table Selection --- */
.ProseMirror td.selectedCell,
.ProseMirror th.selectedCell {
background: color-mix(in srgb, var(--primary), transparent 90%);
position: relative;
}
.ProseMirror td.selectedCell::after,
.ProseMirror th.selectedCell::after {
content: "";
position: absolute;
inset: -1px;
border: 2px solid var(--primary);
pointer-events: none;
z-index: 10;
}
+2
View File
@@ -3,12 +3,14 @@ import './index.css'
import App from './App.tsx'
import { ThemeProvider } from './components/ThemeProvider'
import { Toaster } from './components/ui/sonner'
import { PwaUpdater } from './components/PwaUpdater'
const root = document.getElementById('root')
render(() => {
return (
<ThemeProvider>
<PwaUpdater />
<App />
<Toaster />
</ThemeProvider>
+324
View File
@@ -0,0 +1,324 @@
import { FIREWORKS_API_KEY, FIREWORKS_API_URL, FIREWORKS_HELP_MODEL } from "@/lib/app-config";
import helpGuide from "../../docs/HELP_GUIDE.md?raw";
type ChatRequestMessage = {
role: "system" | "user" | "assistant";
content: string;
};
const HELP_SYSTEM_PROMPT = `You answer questions about TasGrid using the reference guide below.
You must follow this instruction exactly: "use only this guide for all information and never assume you know something that isn't mentioned in the guide"
If the guide does not contain the answer, say that the guide does not mention it.
Do not use outside product knowledge.
Do not invent buttons, workflows, settings, or behaviors.
Answer in the most direct and concise way possible initially.
After the main answer, end with a short follow-up line like "If you would like, I can explain the intricacies in more detail."
When helpful, mention the relevant section name from the guide.
TASGRID REFERENCE GUIDE
${helpGuide}`;
const buildSectionPrompt = (sectionTitle: string, sectionContent: string) => `You answer questions about TasGrid using only the guide section below.
You must follow this instruction exactly: "use only this guide for all information and never assume you know something that isn't mentioned in the guide"
If the section does not contain the answer, say that the guide section does not mention it.
Do not use outside product knowledge.
Do not invent buttons, workflows, settings, or behaviors.
Prefer concise, practical answers.
TASGRID GUIDE SECTION
## ${sectionTitle}
${sectionContent}`;
const normalizeLine = (value: string) =>
value
.trim()
.replace(/^tip:\s*/i, "")
.replace(/^["'\s]+|["'\s]+$/g, "")
.replace(/\s+/g, " ")
.toLowerCase();
const keepSingleSentence = (value: string) => {
const trimmed = value.trim();
const sentenceMatch = trimmed.match(/.+?[.!?](?=\s|$)/);
return (sentenceMatch?.[0] || trimmed).trim();
};
const isReasoningLikeType = (value: unknown) =>
typeof value === "string" && /reasoning|thought/i.test(value);
export const hasFireworksApiKey = () => FIREWORKS_API_KEY.trim().length > 0;
const extractGuideSections = (markdown: string) => {
const lines = markdown.split(/\r?\n/);
const sections: Array<{ title: string; content: string }> = [];
const excludedTopLevelTitles = new Set([
"Overview",
"Reference Notes",
"Troubleshooting Checklist"
]);
const excludedSubsectionTitles = new Set([
"Explicit Exclusions",
"Summary for Future Help-Agent Use",
"Help Access",
"Main App Modes",
"Account Header"
]);
let currentTopLevelTitle: string | null = null;
let currentSection: { title: string; lines: string[] } | null = null;
const pushCurrentSection = () => {
if (!currentSection) return;
const content = currentSection.lines.join("\n").trim();
if (!content) return;
if (excludedSubsectionTitles.has(currentSection.title)) return;
sections.push({
title: currentSection.title,
content
});
};
for (const line of lines) {
const topLevelHeadingMatch = line.match(/^##\s+(.+?)\s*$/);
if (topLevelHeadingMatch) {
pushCurrentSection();
currentTopLevelTitle = topLevelHeadingMatch[1].trim();
currentSection = null;
continue;
}
const subsectionHeadingMatch = line.match(/^###\s+(.+?)\s*$/);
if (subsectionHeadingMatch) {
pushCurrentSection();
if (!currentTopLevelTitle || excludedTopLevelTitles.has(currentTopLevelTitle)) {
currentSection = null;
continue;
}
currentSection = {
title: `${currentTopLevelTitle}: ${subsectionHeadingMatch[1].trim()}`,
lines: []
};
continue;
}
if (currentSection) {
currentSection.lines.push(line);
}
}
pushCurrentSection();
return sections;
};
const HELP_GUIDE_SECTIONS = extractGuideSections(helpGuide);
export const getAIHelpTipSectionDebug = (sectionIndex: number) => {
if (HELP_GUIDE_SECTIONS.length === 0) {
return { requestedIndex: sectionIndex, resolvedIndex: -1, title: null as string | null };
}
const resolvedIndex = ((sectionIndex % HELP_GUIDE_SECTIONS.length) + HELP_GUIDE_SECTIONS.length) % HELP_GUIDE_SECTIONS.length;
return {
requestedIndex: sectionIndex,
resolvedIndex,
title: HELP_GUIDE_SECTIONS[resolvedIndex]?.title || null
};
};
const cleanRawCompletion = (value: string) =>
value
.replace(/<think>[\s\S]*?<\/think>/gi, " ")
.replace(/<reasoning>[\s\S]*?<\/reasoning>/gi, " ")
.replace(/<\|[^>]+?\|>/g, " ")
.replace(/<\/?s>/gi, " ")
.replace(/\s+/g, " ")
.trim();
const extractTextFromContent = (content: unknown): string => {
if (typeof content === "string") {
return content.trim();
}
if (Array.isArray(content)) {
return content
.map(part => {
if (typeof part === "string") return part;
if (part && typeof part === "object") {
if (isReasoningLikeType((part as any).type)) {
return "";
}
if ("text" in part && typeof (part as any).text === "string") {
return (part as any).text;
}
if ("content" in part && typeof (part as any).content === "string") {
return (part as any).content;
}
}
return "";
})
.filter(Boolean)
.join("\n")
.trim();
}
if (content && typeof content === "object") {
if (isReasoningLikeType((content as any).type)) {
return "";
}
if ("text" in content && typeof (content as any).text === "string") {
return (content as any).text.trim();
}
if ("content" in content) {
return extractTextFromContent((content as any).content);
}
}
return "";
};
const extractTextFromOutputArray = (output: unknown): string => {
if (!Array.isArray(output)) return "";
return output
.map(item => {
if (!item || typeof item !== "object") return "";
if ("content" in item) {
return extractTextFromContent((item as any).content);
}
if ("text" in item && typeof (item as any).text === "string") {
return (item as any).text;
}
return "";
})
.filter(Boolean)
.join("\n")
.trim();
};
const requestFireworks = async (messages: ChatRequestMessage[], options?: { maxTokens?: number; temperature?: number; reasoningEffort?: "none" | "low" | "medium" | "high" }) => {
const response = await fetch(FIREWORKS_API_URL, {
method: "POST",
headers: {
"Content-Type": "application/json",
"Authorization": `Bearer ${FIREWORKS_API_KEY}`
},
body: JSON.stringify({
model: FIREWORKS_HELP_MODEL,
reasoning_effort: options?.reasoningEffort ?? "low",
temperature: options?.temperature ?? 0.2,
max_tokens: options?.maxTokens ?? 700,
raw_output: true,
messages
})
});
if (!response.ok) {
const bodyText = await response.text();
throw new Error(bodyText || `Request failed with status ${response.status}`);
}
const data = await response.json();
const choice = data?.choices?.[0];
const content = extractTextFromContent(choice?.message?.content)
|| extractTextFromContent(choice?.delta?.content)
|| extractTextFromContent(choice?.text)
|| extractTextFromContent(data?.output_text);
const rawCompletion = cleanRawCompletion(
extractTextFromContent(choice?.raw_output?.completion)
|| extractTextFromContent(data?.raw_output?.completion)
);
const outputArrayText = extractTextFromOutputArray(data?.output);
const answer = content || outputArrayText || rawCompletion;
if (!answer) {
const finishReason = choice?.finish_reason ? ` finish_reason=${String(choice.finish_reason)}` : "";
throw new Error(`The help assistant did not return any text.${finishReason}`);
}
return answer;
};
const looksLikePromptEcho = (value: string) => {
const normalized = normalizeLine(value);
return [
"we need to produce",
"create one short tasgrid desktop tip",
"maximum one sentence",
"return plain text only",
"do not repeat or closely paraphrase",
"previous tips to avoid",
"use only this guide",
"the guide does not mention",
"guide section does not mention",
"section does not mention",
"specific desktop tip",
"tasgrid desktop tip",
"based only on the guide"
].some(fragment => normalized.includes(fragment));
};
export const askAIHelp = async (messages: Array<{ role: "user" | "assistant"; content: string }>) =>
requestFireworks(
[
{ role: "system", content: HELP_SYSTEM_PROMPT },
...messages
],
{ maxTokens: 700, temperature: 0.2, reasoningEffort: "low" }
);
export const generateAIHelpTip = async (sectionIndex: number) => {
const sectionDebug = getAIHelpTipSectionDebug(sectionIndex);
const section = sectionDebug.resolvedIndex >= 0
? HELP_GUIDE_SECTIONS[sectionDebug.resolvedIndex]
: null;
const sectionLabel = section?.title || "TasGrid";
const tipPrompt = `Write one short practical TasGrid tip for a person already using the app.
Rules:
- Maximum one sentence.
- Return plain text only.
- No title, no bullet, no quotes.
- Keep it subtle and practical.
- State a real TasGrid behavior or workflow the user can use.
- Do not talk about the guide, the section, the prompt, or what is not mentioned.
- Do not explain your limitations.
- Use only this guide for all information and never assume you know something that isn't mentioned in the guide.
- Start the sentence with "Tip: ".
- Base the tip on this topic: ${sectionLabel}.
`;
for (let attempt = 0; attempt < 2; attempt += 1) {
const response = await requestFireworks(
[
{
role: "system",
content: section
? buildSectionPrompt(section.title, section.content)
: HELP_SYSTEM_PROMPT
},
{ role: "user", content: tipPrompt }
],
{ maxTokens: 192, temperature: 0.5, reasoningEffort: "low" }
);
const firstLine = response.split("\n")[0]?.trim() || "";
const cleaned = keepSingleSentence(
firstLine
.replace(/^[\-\*\u2022\d.\s]+/, "")
.trim()
);
const normalized = normalizeLine(cleaned);
if (!cleaned || !normalized) continue;
if (looksLikePromptEcho(cleaned)) continue;
return cleaned.startsWith("Tip:") ? cleaned : `Tip: ${cleaned.replace(/^tip:\s*/i, "")}`;
}
return null;
};
+21
View File
@@ -0,0 +1,21 @@
const rawMode = (import.meta.env.VITE_TASGRID_DATA_MODE || "prod").toLowerCase();
export const TASGRID_DATA_MODE = rawMode === "dev" ? "dev" : "prod";
export const TASGRID_IS_DEV_DATA = TASGRID_DATA_MODE === "dev";
const rawMigrationAdmins = import.meta.env.VITE_TASGRID_MIGRATION_ADMIN_EMAILS || "";
const rawMigrationEnabled = (import.meta.env.VITE_TASGRID_ENABLE_PROD_MIGRATION || "").toLowerCase();
export const POCKETBASE_URL = import.meta.env.VITE_POCKETBASE_URL || "https://pocketbase.ccllc.pro";
export const FIREWORKS_API_URL = import.meta.env.VITE_FIREWORKS_API_URL || "https://api.fireworks.ai/inference/v1/chat/completions";
export const FIREWORKS_API_KEY = import.meta.env.VITE_FIREWORKS_API_KEY || "";
export const FIREWORKS_HELP_MODEL = import.meta.env.VITE_FIREWORKS_HELP_MODEL || "accounts/fireworks/models/gpt-oss-20b";
export const AUTH_STORE_KEY = TASGRID_IS_DEV_DATA ? "tasgrid_dev_auth" : "tasgrid_auth";
export const STORAGE_KEY_PREFIX = TASGRID_IS_DEV_DATA ? "tasgrid_dev" : "tasgrid";
export const PWA_CACHE_SUFFIX = TASGRID_IS_DEV_DATA ? "dev" : "prod";
export const PROD_MIGRATION_ENABLED = rawMigrationEnabled === "true";
export const PROD_MIGRATION_ADMIN_EMAILS = rawMigrationAdmins
.split(",")
.map((email: string) => email.trim().toLowerCase())
.filter(Boolean);
export const withDataSuffix = (base: string) => TASGRID_IS_DEV_DATA ? `${base}_Dev` : base;
+8 -5
View File
@@ -1,3 +1,5 @@
import { withDataSuffix } from "@/lib/app-config";
export const PRIORITY_OPTIONS = [
{ value: "10", label: "10 - Necessary" },
{ value: "9", label: "9" },
@@ -36,11 +38,12 @@ export const URGENCY_HOURS: Record<number, number> = {
1: 4320
};
export const TASGRID_COLLECTION = 'TasGrid';
export const TAGS_COLLECTION = 'TasGrid_Tags';
export const SHARE_RULES_COLLECTION = 'TasGrid_ShareRules';
export const BUCKETS_COLLECTION = 'buckets';
export const NOTES_COLLECTION = 'TasGrid_Notes';
export const TASGRID_COLLECTION = withDataSuffix('TasGrid');
export const CONTEXTS_COLLECTION = withDataSuffix('TasGrid_Contexts');
export const NOTES_COLLECTION = withDataSuffix('TasGrid_Notes');
export const TAGS_COLLECTION = CONTEXTS_COLLECTION;
export const SHARE_RULES_COLLECTION = CONTEXTS_COLLECTION;
export const BUCKETS_COLLECTION = CONTEXTS_COLLECTION;
export const SIZE_OPTIONS = [
{ value: "10", label: "10 - Two weeks" },
+237
View File
@@ -0,0 +1,237 @@
import { Node, mergeAttributes } from '@tiptap/core';
export interface FileAttachmentOptions {
HTMLAttributes: Record<string, any>;
}
export const FileAttachment = Node.create<FileAttachmentOptions>({
name: 'fileAttachment',
group: 'block',
selectable: true,
draggable: true,
addOptions() {
return {
HTMLAttributes: {
class: 'file-attachment-node w-full max-w-sm rounded-lg border border-border bg-muted/30 hover:bg-muted/50 transition-colors my-4 overflow-hidden relative group cursor-pointer shadow-sm',
},
};
},
addAttributes() {
return {
src: {
default: null,
parseHTML: element => element.getAttribute('data-src'),
renderHTML: attributes => {
if (!attributes.src) return {};
return { 'data-src': attributes.src };
},
},
filename: {
default: null,
parseHTML: element => element.getAttribute('data-filename'),
renderHTML: attributes => {
if (!attributes.filename) return {};
return { 'data-filename': attributes.filename };
},
},
filesize: {
default: null,
parseHTML: element => element.getAttribute('data-filesize'),
renderHTML: attributes => {
if (!attributes.filesize) return {};
return { 'data-filesize': attributes.filesize };
},
}
};
},
parseHTML() {
return [
{
tag: 'div[data-type="file-attachment"]',
},
{
tag: 'a.file-attachment-link',
}
];
},
renderHTML({ HTMLAttributes }) {
const filename = HTMLAttributes.filename || '';
const isPdf = filename.toLowerCase().endsWith('.pdf');
if (isPdf) {
return ['div', mergeAttributes(this.options.HTMLAttributes, HTMLAttributes, {
'data-type': 'file-attachment',
class: 'file-attachment-wrapper relative w-full my-4 rounded-xl overflow-hidden border border-border flex min-h-[500px] bg-muted/20 group'
}),
['iframe', { src: HTMLAttributes.src, class: 'w-full h-full border-0 absolute inset-0' }]
];
}
return ['div', mergeAttributes(this.options.HTMLAttributes, HTMLAttributes, {
'data-type': 'file-attachment',
}),
['a', { href: HTMLAttributes.src, target: '_blank', rel: 'noopener noreferrer', class: 'file-attachment-link flex items-center p-3 gap-3 w-full h-full' },
['div', { class: 'flex-shrink-0 flex items-center justify-center w-10 h-10 rounded-md bg-primary/10 text-primary' },
// SVG for FileText
['svg', { xmlns: 'http://www.w3.org/2000/svg', width: '20', height: '20', viewBox: '0 0 24 24', fill: 'none', stroke: 'currentColor', 'stroke-width': '2', 'stroke-linecap': 'round', 'stroke-linejoin': 'round' },
['path', { d: 'M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z' }],
['path', { d: 'M14 2v4a2 2 0 0 0 2 2h4' }],
['path', { d: 'M10 9H8' }],
['path', { d: 'M16 13H8' }],
['path', { d: 'M16 17H8' }]
]
],
['div', { class: 'flex flex-col overflow-hidden w-full text-left' },
['span', { class: 'text-sm font-medium truncate w-full text-foreground' }, HTMLAttributes.filename || 'Attachment'],
['span', { class: 'text-xs text-muted-foreground mt-0.5' }, 'View File']
]
]
];
},
addKeyboardShortcuts() {
return {
Backspace: () => {
const { selection } = this.editor.state;
if (!selection.empty) {
const selectedNode = this.editor.state.doc.nodeAt(selection.from);
if (selectedNode?.type.name === this.name) {
return true; // Prevent default deletion
}
}
return false;
},
Delete: () => {
const { selection } = this.editor.state;
if (!selection.empty) {
const selectedNode = this.editor.state.doc.nodeAt(selection.from);
if (selectedNode?.type.name === this.name) {
return true; // Prevent default deletion
}
}
return false;
},
};
},
addNodeView() {
return ({ node, getPos, editor }) => {
const filename = node.attrs.filename || '';
const isPdf = filename.toLowerCase().endsWith('.pdf');
const isTxt = filename.toLowerCase().endsWith('.txt');
const isFramable = isPdf || isTxt;
const wrapper = document.createElement('div');
wrapper.setAttribute('data-type', 'file-attachment');
wrapper.setAttribute('data-src', node.attrs.src);
wrapper.setAttribute('data-filename', node.attrs.filename);
wrapper.setAttribute('data-filesize', node.attrs.filesize);
if (isFramable) {
// Framed mode matching FileViewer
wrapper.classList.add('file-viewer-wrapper', 'relative', 'w-full', 'my-4', 'rounded-xl', 'overflow-hidden', 'border', 'border-border', 'flex', 'bg-muted/10', 'group');
wrapper.style.minHeight = '500px';
const iframe = document.createElement('iframe');
iframe.src = node.attrs.src;
iframe.classList.add('w-full', 'h-full', 'border-0', 'absolute', 'inset-0');
wrapper.appendChild(iframe);
} else {
// Card mode
wrapper.classList.add('file-attachment-node', 'w-full', 'max-w-sm', 'rounded-lg', 'border', 'border-border', 'bg-muted/30', 'hover:bg-muted/50', 'transition-colors', 'my-4', 'overflow-hidden', 'relative', 'group', 'cursor-pointer', 'shadow-sm');
const anchor = document.createElement('a');
anchor.href = node.attrs.src || '#';
anchor.target = '_blank';
anchor.rel = 'noopener noreferrer';
anchor.classList.add('flex', 'items-center', 'p-3', 'gap-3', 'w-full', 'h-full', 'no-underline');
const iconContainer = document.createElement('div');
iconContainer.classList.add('flex-shrink-0', 'flex', 'items-center', 'justify-center', 'w-10', 'h-10', 'rounded-md', 'bg-primary/10', 'text-primary');
iconContainer.innerHTML = '<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z"></path><path d="M14 2v4a2 2 0 0 0 2 2h4"></path><path d="M10 9H8"></path><path d="M16 13H8"></path><path d="M16 17H8"></path></svg>';
const textContainer = document.createElement('div');
textContainer.classList.add('flex', 'flex-col', 'overflow-hidden', 'w-full', 'text-left');
const filenameSpan = document.createElement('span');
filenameSpan.classList.add('text-sm', 'font-medium', 'truncate', 'w-full', 'text-foreground');
filenameSpan.innerText = node.attrs.filename || 'Attachment';
const subTextSpan = document.createElement('span');
subTextSpan.classList.add('text-xs', 'text-muted-foreground', 'mt-0.5');
if (node.attrs.filesize) {
const mb = (parseInt(node.attrs.filesize) / (1024 * 1024)).toFixed(2);
subTextSpan.innerText = `${mb} MB • View File`;
} else {
subTextSpan.innerText = `View File`;
}
textContainer.appendChild(filenameSpan);
textContainer.appendChild(subTextSpan);
anchor.appendChild(iconContainer);
anchor.appendChild(textContainer);
wrapper.appendChild(anchor);
}
// Delete button overlay
const overlay = document.createElement('div');
overlay.classList.add('absolute', 'top-1.5', 'left-1.5', 'flex', '[@media(hover:hover)]:hidden', '[@media(hover:hover)]:group-hover:flex', 'bg-background/90', 'backdrop-blur-sm', 'p-1', 'rounded-lg', 'border', 'border-border', 'shadow-sm', 'z-10');
const deleteBtn = document.createElement('button');
deleteBtn.innerHTML = '<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="text-muted-foreground hover:text-destructive transition-colors"><path d="M3 6h18"></path><path d="M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6"></path><path d="M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2"></path></svg>';
deleteBtn.classList.add('p-1', 'cursor-pointer');
deleteBtn.onclick = (e) => {
e.preventDefault();
e.stopPropagation();
if (typeof getPos === 'function') {
const pos = getPos();
if (typeof pos === 'number') {
editor.commands.deleteRange({ from: pos, to: pos + node.nodeSize });
}
}
};
overlay.appendChild(deleteBtn);
wrapper.appendChild(overlay);
return {
dom: wrapper,
stopEvent: (e) => {
// Stop event propagation if click is exactly on delete button, to prevent anchor following
if (overlay.contains(e.target as unknown as globalThis.Node)) {
return true;
}
return false;
},
ignoreMutation: () => true,
};
};
},
addCommands() {
return {
insertFileAttachment: (options: { src: string, filename: string, filesize?: string }) => ({ commands }) => {
return commands.insertContent({
type: this.name,
attrs: options,
});
},
};
},
});
declare module '@tiptap/core' {
interface Commands<ReturnType> {
fileAttachment: {
insertFileAttachment: (options: { src: string, filename: string, filesize?: string }) => ReturnType;
};
}
}
+73
View File
@@ -0,0 +1,73 @@
import { Extension } from '@tiptap/core';
import { activeTaskId, uploadTaskAttachment, activeNoteId, uploadNoteAttachment } from "@/store";
import { toast } from "solid-sonner";
declare module '@tiptap/core' {
interface Commands<ReturnType> {
fileUpload: {
uploadFile: () => ReturnType;
};
}
}
export const FileUpload = Extension.create({
name: 'fileUpload',
addCommands() {
return {
uploadFile: () => ({ editor }) => {
const taskId = activeTaskId();
const noteId = activeNoteId();
if (!taskId && !noteId) {
toast.error("Cannot upload a file outside of a task or note.");
return false;
}
const input = document.createElement("input");
input.type = "file";
// Accept any file type for generic attachments
input.accept = "*/*";
input.onchange = async () => {
if (input.files?.length) {
const file = input.files[0];
const filesize = file.size.toString();
toast.promise(
(async () => {
if (taskId) {
return uploadTaskAttachment(taskId, file);
} else {
return uploadNoteAttachment(noteId!, file);
}
})(),
{
loading: "Uploading file...",
success: ({ url, filename: pbFilename }) => {
// Insert file attchment UI card
editor.chain()
.focus()
.insertFileAttachment({ src: url, filename: pbFilename, filesize })
.run();
// Manually force a new paragraph at the end so the user can keep typing
// Add paragraph after insertion
editor.chain()
.focus('end')
.insertContent('<p></p>')
.run();
return "File uploaded successfully.";
},
error: "Failed to upload file."
}
);
}
};
input.click();
return true;
},
};
},
});
+157
View File
@@ -0,0 +1,157 @@
import { Node, mergeAttributes } from '@tiptap/core';
import { render } from 'solid-js/web';
import { JobFileSelector } from '@/components/JobFileSelector';
export interface FileViewerOptions {
HTMLAttributes: Record<string, any>;
}
declare module '@tiptap/core' {
interface Commands<ReturnType> {
fileViewer: {
insertFileViewer: (options?: { src?: string }) => ReturnType;
};
}
}
export const FileViewer = Node.create<FileViewerOptions>({
name: 'fileViewer',
group: 'block',
selectable: true,
draggable: true,
addOptions() {
return {
HTMLAttributes: {
class: 'file-viewer-node group relative',
},
};
},
addAttributes() {
return {
jobId: {
default: null,
parseHTML: element => element.getAttribute('data-job-id'),
renderHTML: attributes => {
if (!attributes.jobId) return {};
return { 'data-job-id': attributes.jobId };
},
},
collectionId: {
default: 'Job_Info_Prod',
parseHTML: element => element.getAttribute('data-collection-id') || 'Job_Info_Prod',
renderHTML: attributes => {
return { 'data-collection-id': attributes.collectionId || 'Job_Info_Prod' };
},
},
filename: {
default: null,
parseHTML: element => element.getAttribute('data-filename'),
renderHTML: attributes => {
if (!attributes.filename) return {};
return { 'data-filename': attributes.filename };
},
},
};
},
parseHTML() {
return [
{
tag: 'div[data-type="file-viewer-wrapper"]',
}
];
},
renderHTML({ HTMLAttributes }) {
const { jobId, collectionId, filename, ...rest } = HTMLAttributes;
let src = '';
if (jobId && filename) {
src = `https://pocketbase.ccllc.pro/api/files/${collectionId}/${jobId}/${filename}`;
}
return ['div', mergeAttributes(this.options.HTMLAttributes, rest, { 'data-type': 'file-viewer-wrapper', class: 'file-viewer-wrapper relative w-full my-4 rounded-xl overflow-hidden border border-border flex min-h-[500px] bg-muted/20' }),
['iframe', { 'data-type': 'file-viewer', src: src, class: 'w-full h-full border-0 absolute inset-0' }]
];
},
addNodeView() {
return ({ node, getPos, editor }) => {
const wrapper = document.createElement('div');
wrapper.classList.add('file-viewer-wrapper', 'relative', 'w-full', 'my-4', 'rounded-xl', 'overflow-hidden', 'border', 'border-border', 'flex', 'bg-muted/10', 'group');
const hasFile = node.attrs.jobId && node.attrs.filename;
if (hasFile) {
// Viewer mode
wrapper.style.minHeight = '500px';
const iframe = document.createElement('iframe');
iframe.src = `https://pocketbase.ccllc.pro/api/files/${node.attrs.collectionId}/${node.attrs.jobId}/${node.attrs.filename}`;
iframe.classList.add('w-full', 'h-full', 'border-0', 'absolute', 'inset-0');
// Delete button overlay
const overlay = document.createElement('div');
overlay.classList.add('absolute', 'top-2', 'left-2', 'flex', '[@media(hover:hover)]:hidden', '[@media(hover:hover)]:group-hover:flex', 'bg-background/80', 'backdrop-blur-sm', 'p-1', 'rounded-lg', 'border', 'border-border', 'shadow-sm', 'z-10');
const deleteBtn = document.createElement('button');
deleteBtn.innerHTML = '<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="text-muted-foreground hover:text-destructive transition-colors"><path d="M3 6h18"></path><path d="M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6"></path><path d="M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2"></path></svg>';
deleteBtn.classList.add('p-1', 'cursor-pointer');
deleteBtn.onclick = (e) => {
e.preventDefault();
if (typeof getPos === 'function') {
const pos = getPos();
if (typeof pos === 'number') {
editor.commands.deleteRange({ from: pos, to: pos + node.nodeSize });
}
}
};
overlay.appendChild(deleteBtn);
wrapper.appendChild(iframe);
wrapper.appendChild(overlay);
return {
dom: wrapper,
stopEvent: () => true,
ignoreMutation: () => true,
};
} else {
render(() => JobFileSelector({
onSelectFile: (jobId: string, filename: string) => {
if (typeof getPos === 'function') {
editor.commands.command(({ tr }) => {
const pos = getPos();
if (typeof pos === 'number') {
tr.setNodeMarkup(pos, undefined, { jobId, collectionId: 'Job_Info_Prod', filename });
return true;
}
return false;
});
}
}
}), wrapper);
return {
dom: wrapper,
stopEvent: () => true,
ignoreMutation: () => true,
};
}
};
},
addCommands() {
return {
insertFileViewer: (options?: { src?: string }) => ({ commands }) => {
return commands.insertContent({
type: this.name,
attrs: options || {},
});
},
};
},
});
+71
View File
@@ -0,0 +1,71 @@
import { Extension } from '@tiptap/core';
import { activeTaskId, uploadTaskAttachment, activeNoteId, uploadNoteAttachment } from "@/store";
import { convertImageToWebpFile } from "@/lib/utils";
import { toast } from "solid-sonner";
declare module '@tiptap/core' {
interface Commands<ReturnType> {
imageUpload: {
uploadImage: () => ReturnType;
};
}
}
export const ImageUpload = Extension.create({
name: 'imageUpload',
addCommands() {
return {
uploadImage: () => ({ editor }) => {
const taskId = activeTaskId();
const noteId = activeNoteId();
if (!taskId && !noteId) {
toast.error("Cannot upload image outside of a task or note.");
return false;
}
const input = document.createElement("input");
input.type = "file";
input.accept = "image/*,.heic,.heif";
input.onchange = async () => {
if (input.files?.length) {
const originalFile = input.files[0];
toast.promise(
(async () => {
const webpFile = await convertImageToWebpFile(originalFile);
if (taskId) {
return uploadTaskAttachment(taskId, webpFile);
} else {
return uploadNoteAttachment(noteId!, webpFile);
}
})(),
{
loading: "Processing & uploading image...",
success: ({ url, filename }) => {
// Insert image
editor.chain()
.focus()
// @ts-ignore
.setImage({ src: url, filename })
.run();
// Manually force a new paragraph at the end so the user can keep typing
editor.chain()
.focus('end')
.insertContent('<p></p>')
.run();
return "Image uploaded.";
},
error: "Failed to upload image."
}
);
}
};
input.click();
return true;
},
};
},
});
+201
View File
@@ -0,0 +1,201 @@
import Image from "@tiptap/extension-image";
export const CustomImage = Image.extend({
name: "image",
group: "block",
selectable: true,
draggable: true,
addAttributes() {
return {
...this.parent?.(),
src: {
default: null,
},
width: {
default: null,
parseHTML: element => element.getAttribute('width'),
renderHTML: attributes => {
if (!attributes.width) return {};
return { width: attributes.width };
},
},
filename: {
default: null,
parseHTML: element => element.getAttribute('data-filename'),
renderHTML: attributes => {
if (!attributes.filename) return {};
return { 'data-filename': attributes.filename };
},
},
};
},
addCommands() {
return {
setImage: (options: { src: string, alt?: string, title?: string, filename?: string }) => ({ commands }) => {
return commands.insertContent({
type: this.name,
attrs: options,
});
},
};
},
addNodeView() {
return ({ node, getPos, editor }) => {
const wrapper = document.createElement('div');
// Give it position: relative and flex/inline-block to wrap the image tightly
wrapper.classList.add('image-wrapper', 'relative', 'inline-block', 'my-4', 'group', 'max-w-full');
wrapper.style.display = 'block'; // Block level to not mess up flow
wrapper.style.marginInline = 'auto'; // Center if desired, or let it flow
if (node.attrs.width) {
const w = typeof node.attrs.width === 'number' ? `${node.attrs.width}px` : node.attrs.width;
wrapper.style.width = w;
} else {
wrapper.style.width = 'fit-content';
}
const img = document.createElement('img');
Object.entries(node.attrs).forEach(([key, value]) => {
if (value !== null && value !== undefined) {
if (key === 'filename') {
img.setAttribute('data-filename', value);
} else {
img.setAttribute(key, value);
}
}
});
img.classList.add('rounded-lg', 'border', 'border-border', 'max-w-full', 'h-auto', 'cursor-default');
img.style.width = '100%';
// Resize handle
const resizer = document.createElement('div');
// Adding style.touchAction = 'none' to prevent mobile browser scrolling when dragging
resizer.style.touchAction = 'none';
resizer.innerHTML = '<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="text-white"><path d="m21 21-6-6m6 6v-4.8m0 4.8h-4.8M3 16.2V21m0 0h4.8M3 21l6-6M21 7.8V3m0 0h-4.8M21 3l-6 6M3 7.8V3m0 0h4.8M3 3l6 6"/></svg>';
resizer.classList.add('absolute', 'bottom-2', 'right-2', 'w-6', 'h-6', 'bg-black/50', 'hover:bg-black/80', 'backdrop-blur-sm', 'cursor-nwse-resize', 'transition-opacity', 'z-20', 'rounded-md', 'flex', 'items-center', 'justify-center', 'shadow-sm');
resizer.contentEditable = 'false';
let isResizing = false;
let startX = 0;
let startWidth = 0;
const initResize = (clientX: number) => {
isResizing = true;
startX = clientX;
startWidth = wrapper.offsetWidth;
};
const doResize = (clientX: number, minW: number) => {
if (!isResizing) return;
const dx = clientX - startX;
const newWidth = Math.max(minW, startWidth + dx);
wrapper.style.width = `${newWidth}px`;
};
const stopResize = () => {
if (!isResizing) return;
isResizing = false;
if (typeof getPos === 'function') {
const pos = getPos();
if (typeof pos === 'number') {
editor.commands.command(({ tr }) => {
tr.setNodeMarkup(pos, undefined, {
...node.attrs,
width: wrapper.offsetWidth,
});
return true;
});
}
}
};
resizer.addEventListener('mousedown', (e) => {
e.preventDefault();
e.stopPropagation();
initResize(e.clientX);
const onMouseMove = (ev: MouseEvent) => {
ev.preventDefault();
doResize(ev.clientX, 100);
};
const onMouseUp = (ev: MouseEvent) => {
ev.preventDefault();
stopResize();
document.removeEventListener('mousemove', onMouseMove);
document.removeEventListener('mouseup', onMouseUp);
};
document.addEventListener('mousemove', onMouseMove);
document.addEventListener('mouseup', onMouseUp);
});
resizer.addEventListener('touchstart', (e) => {
e.preventDefault();
e.stopPropagation();
initResize(e.touches[0].clientX);
const onTouchMove = (ev: TouchEvent) => {
ev.preventDefault();
doResize(ev.touches[0].clientX, 100);
};
const onTouchEnd = () => {
// touchend doesn't have touches[0] usually
stopResize();
document.removeEventListener('touchmove', onTouchMove);
document.removeEventListener('touchend', onTouchEnd);
document.removeEventListener('touchcancel', onTouchEnd);
};
document.addEventListener('touchmove', onTouchMove, { passive: false });
document.addEventListener('touchend', onTouchEnd);
document.addEventListener('touchcancel', onTouchEnd);
}, { passive: false });
// Delete button overlay
const overlay = document.createElement('div');
overlay.classList.add('absolute', 'top-2', 'left-2', 'flex', '[@media(hover:hover)]:hidden', '[@media(hover:hover)]:group-hover:flex', 'bg-background/80', 'backdrop-blur-sm', 'p-1', 'rounded-lg', 'border', 'border-border', 'shadow-sm', 'z-10');
const deleteBtn = document.createElement('button');
deleteBtn.innerHTML = '<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="text-muted-foreground hover:text-destructive transition-colors"><path d="M3 6h18"></path><path d="M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6"></path><path d="M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2"></path></svg>';
deleteBtn.classList.add('p-1', 'cursor-pointer');
deleteBtn.onclick = (e) => {
e.preventDefault();
e.stopPropagation();
if (typeof getPos === 'function') {
const pos = getPos();
if (typeof pos === 'number') {
editor.commands.deleteRange({ from: pos, to: pos + node.nodeSize });
}
}
};
overlay.appendChild(deleteBtn);
wrapper.appendChild(img);
wrapper.appendChild(resizer);
wrapper.appendChild(overlay);
return {
dom: wrapper,
stopEvent: () => true,
ignoreMutation: () => true,
};
};
},
});
// @ts-ignore
declare module '@tiptap/core' {
interface Commands<ReturnType> {
// @ts-ignore
image: {
setImage: (options: { src: string, alt?: string, title?: string, filename?: string }) => ReturnType;
};
}
}
+79
View File
@@ -0,0 +1,79 @@
import { Extension } from '@tiptap/core';
import { activeTaskId, uploadTaskAttachment, activeNoteId, uploadNoteAttachment } from "@/store";
import { convertImageToWebpFile } from "@/lib/utils";
import { toast } from "solid-sonner";
declare module '@tiptap/core' {
interface Commands<ReturnType> {
mediaUpload: {
uploadMedia: () => ReturnType;
};
}
}
export const MediaUpload = Extension.create({
name: 'mediaUpload',
addCommands() {
return {
uploadMedia: () => ({ editor }) => {
const taskId = activeTaskId();
const noteId = activeNoteId();
if (!taskId && !noteId) {
toast.error("Cannot upload media outside of a task or note.");
return false;
}
const input = document.createElement("input");
input.type = "file";
input.onchange = async () => {
if (input.files?.length) {
const originalFile = input.files[0];
const isImage = originalFile.type.startsWith('image/');
const isVideo = originalFile.type.startsWith('video/');
toast.promise(
(async () => {
let fileToUpload = originalFile;
if (isImage && !originalFile.type.includes('svg') && !originalFile.type.includes('gif')) {
fileToUpload = await convertImageToWebpFile(originalFile);
}
if (taskId) {
return uploadTaskAttachment(taskId, fileToUpload);
} else {
return uploadNoteAttachment(noteId!, fileToUpload);
}
})(),
{
loading: "Processing & uploading media...",
success: ({ url, filename }) => {
if (isImage) {
// @ts-ignore
editor.chain().focus().setImage({ src: url, filename }).run();
} else if (isVideo) {
editor.chain().focus().setVideo({ src: url, filename }).run();
} else {
editor.chain().focus().insertFileAttachment({ src: url, filename, filesize: originalFile.size.toString() }).run();
}
// Manually force a new paragraph at the end so the user can keep typing
editor.chain()
.focus('end')
.insertContent('<p></p>')
.run();
return "Media uploaded.";
},
error: "Failed to upload media."
}
);
}
};
input.click();
return true;
},
};
},
});
+69
View File
@@ -0,0 +1,69 @@
import { Extension } from '@tiptap/core';
import { activeTaskId, uploadTaskAttachment, activeNoteId, uploadNoteAttachment } from "@/store";
import { toast } from "solid-sonner";
declare module '@tiptap/core' {
interface Commands<ReturnType> {
videoUpload: {
uploadVideo: () => ReturnType;
};
}
}
export const VideoUpload = Extension.create({
name: 'videoUpload',
addCommands() {
return {
uploadVideo: () => ({ editor }) => {
const taskId = activeTaskId();
const noteId = activeNoteId();
if (!taskId && !noteId) {
toast.error("Cannot upload video outside of a task or note.");
return false;
}
const input = document.createElement("input");
input.type = "file";
input.accept = "video/*";
input.onchange = async () => {
if (input.files?.length) {
const originalFile = input.files[0];
toast.promise(
(async () => {
// For video, we don't necessarily convert to webp. We just upload it directly.
if (taskId) {
return uploadTaskAttachment(taskId, originalFile);
} else {
return uploadNoteAttachment(noteId!, originalFile);
}
})(),
{
loading: "Uploading video...",
success: ({ url, filename }) => {
// Insert video
editor.chain()
.focus()
.setVideo({ src: url, filename })
.run();
// Manually force a new paragraph at the end so the user can keep typing
editor.chain()
.focus('end')
.insertContent('<p></p>')
.run();
return "Video uploaded.";
},
error: "Failed to upload video."
}
);
}
};
input.click();
return true;
},
};
},
});
+253
View File
@@ -0,0 +1,253 @@
import { Node, mergeAttributes } from '@tiptap/core';
export interface VideoOptions {
HTMLAttributes: Record<string, any>;
}
export const Video = Node.create<VideoOptions>({
name: 'video',
group: 'block',
// Allows it to be selected and manipulated as a single block
selectable: true,
draggable: true,
addOptions() {
return {
HTMLAttributes: {
class: 'w-full rounded-lg border border-border/50 shadow-sm overflow-hidden my-4',
controls: true,
},
};
},
addAttributes() {
return {
src: {
default: null,
},
width: {
default: null,
parseHTML: element => element.getAttribute('width'),
renderHTML: attributes => {
if (!attributes.width) return {};
return { width: attributes.width };
},
},
filename: {
default: null,
parseHTML: element => element.getAttribute('data-filename'),
renderHTML: attributes => {
if (!attributes.filename) return {};
return { 'data-filename': attributes.filename };
},
},
};
},
addCommands() {
return {
setVideo: (options: { src: string, filename?: string }) => ({ commands }) => {
return commands.insertContent({
type: this.name,
attrs: options,
});
},
};
},
parseHTML() {
return [
{
tag: 'video',
},
];
},
renderHTML({ HTMLAttributes }) {
return ['video', mergeAttributes(this.options.HTMLAttributes, HTMLAttributes)];
},
addKeyboardShortcuts() {
return {
Backspace: () => {
const { selection } = this.editor.state;
if (!selection.empty) {
const selectedNode = this.editor.state.doc.nodeAt(selection.from);
if (selectedNode?.type.name === this.name) {
return true; // Prevent default deletion
}
}
return false;
},
Delete: () => {
const { selection } = this.editor.state;
if (!selection.empty) {
const selectedNode = this.editor.state.doc.nodeAt(selection.from);
if (selectedNode?.type.name === this.name) {
return true; // Prevent default deletion
}
}
return false;
},
};
},
addNodeView() {
return ({ node, getPos, editor }) => {
const wrapper = document.createElement('div');
wrapper.classList.add('video-wrapper', 'relative', 'inline-block', 'my-4', 'group', 'max-w-full');
wrapper.style.display = 'block';
wrapper.style.marginInline = 'auto'; // Center if desired
if (node.attrs.width) {
const w = typeof node.attrs.width === 'number' ? `${node.attrs.width}px` : node.attrs.width;
wrapper.style.width = w;
} else {
wrapper.style.width = '100%';
}
const video = document.createElement('video');
Object.entries(node.attrs).forEach(([key, value]) => {
if (value !== null && value !== undefined) {
if (key === 'filename') {
video.setAttribute('data-filename', value);
} else {
video.setAttribute(key, value);
}
}
});
// Apply default attributes if not present
if (!video.hasAttribute('controls')) {
video.setAttribute('controls', 'true');
}
video.classList.add('w-full', 'rounded-lg', 'border', 'border-border/50', 'shadow-sm', 'overflow-hidden');
video.style.width = '100%';
// Resize handle
const resizer = document.createElement('div');
// Adding style.touchAction = 'none' to prevent mobile browser scrolling when dragging
resizer.style.touchAction = 'none';
resizer.innerHTML = '<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="text-white"><path d="m21 21-6-6m6 6v-4.8m0 4.8h-4.8M3 16.2V21m0 0h4.8M3 21l6-6M21 7.8V3m0 0h-4.8M21 3l-6 6M3 7.8V3m0 0h4.8M3 3l6 6"/></svg>';
resizer.classList.add('absolute', 'bottom-2', 'right-2', 'w-6', 'h-6', 'bg-black/50', 'hover:bg-black/80', 'backdrop-blur-sm', 'cursor-nwse-resize', 'transition-opacity', 'z-20', 'rounded-md', 'flex', 'items-center', 'justify-center', 'shadow-sm');
resizer.contentEditable = 'false';
let isResizing = false;
let startX = 0;
let startWidth = 0;
const initResize = (clientX: number) => {
isResizing = true;
startX = clientX;
startWidth = wrapper.offsetWidth;
};
const doResize = (clientX: number, minW: number) => {
if (!isResizing) return;
const dx = clientX - startX;
const newWidth = Math.max(minW, startWidth + dx);
wrapper.style.width = `${newWidth}px`;
};
const stopResize = () => {
if (!isResizing) return;
isResizing = false;
if (typeof getPos === 'function') {
const pos = getPos();
if (typeof pos === 'number') {
editor.commands.command(({ tr }) => {
tr.setNodeMarkup(pos, undefined, {
...node.attrs,
width: wrapper.offsetWidth,
});
return true;
});
}
}
};
resizer.addEventListener('mousedown', (e) => {
// Prevent event from dragging the whole block
e.preventDefault();
e.stopPropagation();
initResize(e.clientX);
const onMouseMove = (ev: MouseEvent) => {
ev.preventDefault();
doResize(ev.clientX, 200);
};
const onMouseUp = (ev: MouseEvent) => {
ev.preventDefault();
stopResize();
document.removeEventListener('mousemove', onMouseMove);
document.removeEventListener('mouseup', onMouseUp);
};
document.addEventListener('mousemove', onMouseMove);
document.addEventListener('mouseup', onMouseUp);
});
resizer.addEventListener('touchstart', (e) => {
e.preventDefault();
e.stopPropagation();
initResize(e.touches[0].clientX);
const onTouchMove = (ev: TouchEvent) => {
ev.preventDefault();
doResize(ev.touches[0].clientX, 200);
};
const onTouchEnd = () => {
stopResize();
document.removeEventListener('touchmove', onTouchMove);
document.removeEventListener('touchend', onTouchEnd);
document.removeEventListener('touchcancel', onTouchEnd);
};
document.addEventListener('touchmove', onTouchMove, { passive: false });
document.addEventListener('touchend', onTouchEnd);
document.addEventListener('touchcancel', onTouchEnd);
}, { passive: false });
// Delete button overlay
const overlay = document.createElement('div');
overlay.classList.add('absolute', 'top-2', 'left-2', 'flex', '[@media(hover:hover)]:hidden', '[@media(hover:hover)]:group-hover:flex', 'bg-background/80', 'backdrop-blur-sm', 'p-1', 'rounded-lg', 'border', 'border-border', 'shadow-sm', 'z-10');
const deleteBtn = document.createElement('button');
deleteBtn.innerHTML = '<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="text-muted-foreground hover:text-destructive transition-colors"><path d="M3 6h18"></path><path d="M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6"></path><path d="M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2"></path></svg>';
deleteBtn.classList.add('p-1', 'cursor-pointer');
deleteBtn.onclick = (e) => {
e.preventDefault();
e.stopPropagation();
if (typeof getPos === 'function') {
const pos = getPos();
if (typeof pos === 'number') {
editor.commands.deleteRange({ from: pos, to: pos + node.nodeSize });
}
}
};
overlay.appendChild(deleteBtn);
wrapper.appendChild(video);
wrapper.appendChild(resizer);
wrapper.appendChild(overlay);
return {
dom: wrapper,
stopEvent: () => true, // Stops events from propagating to editor (makes the input usable)
ignoreMutation: () => true,
};
};
},
});
declare module '@tiptap/core' {
interface Commands<ReturnType> {
video: {
setVideo: (options: { src: string, filename?: string }) => ReturnType;
};
}
}
+12 -8
View File
@@ -1,15 +1,18 @@
import { store } from "@/store";
import { store, isCurrentUserContextTag } from "@/store";
import { render } from "solid-js/web";
import tippy, { type Instance as TippyInstance } from "tippy.js";
import { MentionList } from "@/components/MentionList";
import { buildShareTag } from "@/lib/tags";
export const userSuggestion = {
items: ({ query }: { query: string }) => {
return store.tagDefinitions
.filter(tag => tag.isUser && tag.name.toLowerCase().substring(1).startsWith(query.toLowerCase()))
.map(tag => ({
id: tag.name, // Use the tag name as ID for sharing logic
label: tag.name.substring(1),
return store.contexts
.filter(context => context.kind === "user" && !context.deletedAt)
.filter(context => !isCurrentUserContextTag(buildShareTag(context.displayName)))
.filter(context => context.displayName.toLowerCase().startsWith(query.toLowerCase()))
.map(context => ({
id: buildShareTag(context.displayName),
label: context.displayName,
type: 'user' as const
}));
},
@@ -89,10 +92,11 @@ export const noteSuggestion = {
char: '#',
items: ({ query }: { query: string }) => {
return store.notes
.filter(note => note.title.toLowerCase().includes(query.toLowerCase()))
.filter(note => !(note.tags || []).some(t => t.startsWith("child-of-")))
.filter(note => (note.key || note.title).toLowerCase().includes(query.toLowerCase()))
.map(note => ({
id: note.id,
label: note.title,
label: note.key || note.title,
sublabel: note.content?.substring(0, 50).replace(/<[^>]*>?/gm, ''),
type: 'note' as const
}));
+6 -4
View File
@@ -34,12 +34,13 @@ export const MobileIndent = Extension.create({
if (!parentNode) return false;
const parentType = parentNode.type.name;
const isListItem = parentType === 'taskItem' || parentType === 'listItem';
const isListItem = parentType === 'taskItem' || parentType === 'multiTaskItem' || parentType === 'listItem';
if (!isListItem) return false;
// Sink the list item (indent)
const sunk = editor.chain().sinkListItem('taskItem').run() ||
editor.chain().sinkListItem('multiTaskItem').run() ||
editor.chain().sinkListItem('listItem').run();
// Return true to prevent the space from being inserted
@@ -64,7 +65,7 @@ export const MobileIndent = Extension.create({
if (!parentNode) return false;
const parentType = parentNode.type.name;
const isListItem = parentType === 'taskItem' || parentType === 'listItem';
const isListItem = parentType === 'taskItem' || parentType === 'multiTaskItem' || parentType === 'listItem';
if (!isListItem) return false;
@@ -74,13 +75,14 @@ export const MobileIndent = Extension.create({
const greatGrandparent = $from.node(-3);
// If grandparent is a list and greatGrandparent is also a list item, we're nested
const grandparentIsList = grandparent?.type.name === 'taskList' || grandparent?.type.name === 'bulletList' || grandparent?.type.name === 'orderedList';
const greatGrandparentIsListItem = greatGrandparent?.type.name === 'taskItem' || greatGrandparent?.type.name === 'listItem';
const grandparentIsList = grandparent?.type.name === 'taskList' || grandparent?.type.name === 'multiTaskList' || grandparent?.type.name === 'bulletList' || grandparent?.type.name === 'orderedList';
const greatGrandparentIsListItem = greatGrandparent?.type.name === 'taskItem' || greatGrandparent?.type.name === 'multiTaskItem' || greatGrandparent?.type.name === 'listItem';
if (!grandparentIsList || !greatGrandparentIsListItem) return false;
// Lift the list item (outdent)
const lifted = editor.chain().liftListItem('taskItem').run() ||
editor.chain().liftListItem('multiTaskItem').run() ||
editor.chain().liftListItem('listItem').run();
if (lifted) {
+5 -5
View File
@@ -1,6 +1,6 @@
import PocketBase from 'pocketbase';
import PocketBase, { LocalAuthStore } from 'pocketbase';
import { AUTH_STORE_KEY, POCKETBASE_URL } from "@/lib/app-config";
import { CONTEXTS_COLLECTION, NOTES_COLLECTION, TASGRID_COLLECTION } from "@/lib/constants";
export const pb = new PocketBase('https://pocketbase.ccllc.pro');
export const TASGRID_COLLECTION = 'TasGrid';
export const TAGS_COLLECTION = 'TasGrid_Tags';
export const pb = new PocketBase(POCKETBASE_URL, new LocalAuthStore(AUTH_STORE_KEY));
export { TASGRID_COLLECTION, CONTEXTS_COLLECTION, NOTES_COLLECTION };
+67
View File
@@ -0,0 +1,67 @@
export type Recurrence = {
type: 'daily' | 'weekly' | 'monthly';
days?: number[];
dayOfMonth?: number;
};
const startOfDay = (date: Date) => new Date(date.getFullYear(), date.getMonth(), date.getDate());
const addDays = (date: Date, days: number) => {
const d = new Date(date);
d.setDate(d.getDate() + days);
return d;
};
const getNextDaily = (completedAt: Date) => addDays(startOfDay(completedAt), 1);
const getNextWeekly = (completedAt: Date, days: number[]) => {
if (!days || days.length === 0) return null;
const base = startOfDay(completedAt);
for (let offset = 1; offset <= 7; offset++) {
const candidate = addDays(base, offset);
if (days.includes(candidate.getDay())) return candidate;
}
return null;
};
const getNextMonthly = (completedAt: Date, dayOfMonth?: number) => {
if (!dayOfMonth || dayOfMonth < 1 || dayOfMonth > 31) return null;
const base = startOfDay(completedAt);
const baseDay = base.getDate();
const startOffset = dayOfMonth > baseDay ? 0 : 1;
for (let offset = startOffset; offset <= 12; offset++) {
const year = base.getFullYear();
const monthIndex = base.getMonth() + offset;
const candidate = new Date(year, monthIndex, dayOfMonth);
if (candidate.getMonth() === (monthIndex % 12 + 12) % 12) {
if (candidate.getTime() > completedAt.getTime()) {
return startOfDay(candidate);
}
}
}
return null;
};
export const getNextRecurrenceDate = (recurrence: Recurrence, completedAt: Date) => {
if (recurrence.type === 'daily') return getNextDaily(completedAt);
if (recurrence.type === 'weekly') return getNextWeekly(completedAt, recurrence.days || []);
if (recurrence.type === 'monthly') return getNextMonthly(completedAt, recurrence.dayOfMonth);
return null;
};
export const getRecurrenceProgress = (
recurrence: Recurrence | null | undefined,
completedAtIso: string | null | undefined,
nowMs: number
) => {
if (!recurrence || !completedAtIso) return null;
const completedAt = new Date(completedAtIso);
if (isNaN(completedAt.getTime())) return null;
const next = getNextRecurrenceDate(recurrence, completedAt);
if (!next) return null;
const total = next.getTime() - completedAt.getTime();
if (total <= 0) return null;
const progress = (nowMs - completedAt.getTime()) / total;
return Math.max(0, Math.min(1, progress));
};
+41
View File
@@ -0,0 +1,41 @@
export type ParsedTag =
| { kind: "share"; raw: string; key: string; display: string }
| { kind: "note"; raw: string; key: string; display: string }
| { kind: "label"; raw: string; key: string; display: string };
const normalizeWhitespace = (value: string) => value.trim().replace(/\s+/g, " ");
export const normalizeKey = (value: string) =>
normalizeWhitespace(value)
.toLowerCase()
.replace(/^[@#]+/, "")
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "");
export const normalizeContextKey = (value: string) => normalizeKey(value);
export const normalizeNoteKey = (value: string) => normalizeKey(value);
export const parseTag = (rawTag: string): ParsedTag => {
const raw = normalizeWhitespace(rawTag);
const prefix = raw[0];
const display = prefix === "@" || prefix === "#" ? raw.slice(1).trim() : raw;
const key = normalizeKey(display);
if (prefix === "@") {
return { kind: "share", raw, key, display };
}
if (prefix === "#") {
return { kind: "note", raw, key, display };
}
return { kind: "label", raw, key, display };
};
export const parseTags = (rawTags: string[] | undefined | null) =>
(rawTags || [])
.map(parseTag)
.filter(tag => !!tag.display);
export const buildShareTag = (displayName: string) => `@${normalizeWhitespace(displayName)}`;
export const buildNoteTag = (keyOrTitle: string) => `#${normalizeWhitespace(keyOrTitle)}`;
+24 -2
View File
@@ -1,5 +1,6 @@
import { type ClassValue, clsx } from "clsx"
import { twMerge } from "tailwind-merge"
import heic2any from "heic2any"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
@@ -38,7 +39,28 @@ export const compressImageBase64 = (base64Str: string, maxWidth = 800, quality =
};
export const convertImageToWebpFile = async (file: File): Promise<File> => {
return new Promise((resolve) => {
return new Promise(async (resolve) => {
let processableFile: File | Blob = file;
// Convert HEIC/HEIF to JPEG first so the browser can read it
const isHeic = file.name.toLowerCase().endsWith('.heic') || file.name.toLowerCase().endsWith('.heif');
if (isHeic) {
try {
const converted = await heic2any({
blob: file,
toType: "image/jpeg",
quality: 0.8 // high quality intermediate step
});
// heic2any can return an array of blobs if it's an image sequence, just take the first
processableFile = Array.isArray(converted) ? converted[0] : converted;
} catch (err) {
console.error("Failed to convert HEIC image:", err);
// If conversion fails, just fall back to returning the original file
return resolve(file);
}
}
const reader = new FileReader();
reader.onload = (e) => {
const img = new Image();
@@ -92,6 +114,6 @@ export const convertImageToWebpFile = async (file: File): Promise<File> => {
img.src = e.target?.result as string;
};
reader.onerror = () => resolve(file); // Fallback
reader.readAsDataURL(file);
reader.readAsDataURL(processableFile);
});
};
+929
View File
@@ -0,0 +1,929 @@
import { createEffect, createRoot } from "solid-js";
import { toast } from "solid-sonner";
import { pb } from "@/lib/pocketbase";
import { CONTEXTS_COLLECTION, NOTES_COLLECTION, TASGRID_COLLECTION } from "@/lib/constants";
import { PROD_MIGRATION_ADMIN_EMAILS, PROD_MIGRATION_ENABLED, TASGRID_IS_DEV_DATA } from "@/lib/app-config";
import { buildNoteTag, buildShareTag, normalizeNoteKey } from "@/lib/tags";
import {
activeNoteId,
applyScopedPrefsToStore,
currentTaskContext,
getPersistedStoreSnapshot,
getStorageKey,
type Note,
readScopedPrefs,
setCurrentTaskContext,
setNow,
setStore,
store,
type Task,
unwrapRelationId,
writeScopedPrefs
} from "./state";
import {
buildPersonalAccessFilter,
getNoteByRef,
getPersonalContext,
getSubscribedBucketContexts,
loadContextsIntoStore,
mapContextToShareRule,
mapRecordToContext,
relationFilter,
setContexts,
shouldTaskBeVisible,
syncSupervisedContexts,
syncSystemTagsAndRules,
syncUserContexts,
taskHasPersonalContextAccess
} from "./contexts";
import { deleteNotePermanently, loadedNoteDetailIds, mapRecordToNote, scheduleDeferredNotesMetadataLoad } from "./notes";
import { loadFilterTemplates, syncPreferences } from "./preferences";
import {
buildNoteLinkMap,
buildTaskMigrationPayload,
checkRecurringTasks,
deleteTaskPermanently,
ensureUpdatedByFieldSupport,
getCombinedScore,
getTaskListFields,
mapRecordToTask,
upsertTagDefinition
} from "./tasks";
let realtimeRecoveryAttached = false;
let realtimeRecoveryTimer: number | undefined;
let lastRealtimeRecoveryAt = 0;
let contextSyncInterval: number | undefined;
let heartbeatInterval: number | undefined;
let quickloadFadeTimer: number | undefined;
const scheduleRealtimeRecovery = (reason: string, delayMs = 400) => {
if (typeof window === "undefined") return;
if (!pb.authStore.isValid) return;
const nowTs = Date.now();
if (nowTs - lastRealtimeRecoveryAt < 10000) return;
if (realtimeRecoveryTimer) {
window.clearTimeout(realtimeRecoveryTimer);
}
realtimeRecoveryTimer = window.setTimeout(async () => {
realtimeRecoveryTimer = undefined;
if (!pb.authStore.isValid || store.isInitializing) return;
lastRealtimeRecoveryAt = Date.now();
console.log(`[REALTIME] Recovering subscriptions after ${reason}`);
try {
await initStore();
} catch (err) {
console.warn("[REALTIME] Recovery failed:", err);
}
}, delayMs);
};
const ensureRealtimeRecovery = () => {
if (realtimeRecoveryAttached || typeof window === "undefined") return;
realtimeRecoveryAttached = true;
const recoverIfDisconnected = (reason: string) => {
if (!pb.authStore.isValid) return;
if (pb.realtime.isConnected) return;
scheduleRealtimeRecovery(reason);
};
document.addEventListener("visibilitychange", () => {
if (document.visibilityState === "visible") {
recoverIfDisconnected("visibilitychange");
}
});
window.addEventListener("focus", () => recoverIfDisconnected("focus"));
window.addEventListener("online", () => recoverIfDisconnected("online"));
pb.realtime.onDisconnect = () => {
if (!pb.authStore.isValid) return;
};
};
export const startContextPolling = () => {
if (contextSyncInterval) {
clearInterval(contextSyncInterval);
contextSyncInterval = undefined;
}
const ctx = currentTaskContext();
const personalOrUserId =
typeof ctx === "object" && "userId" in ctx
? ctx.userId
: typeof ctx === "object" && "bucketId" in ctx
? store.contexts.find(context => context.id === ctx.bucketId && context.kind === "user")?.targetUserId
: null;
if (personalOrUserId) {
contextSyncInterval = window.setInterval(async () => {
if (!pb.authStore.isValid) return;
try {
const timeStr = new Date(Date.now() - 30000).toISOString();
const contextFilter = buildPersonalAccessFilter([personalOrUserId]);
const records = await pb.collection(TASGRID_COLLECTION).getFullList({
filter: `${contextFilter} && updated >= "${timeStr}"`,
sort: "-updated",
fields: getTaskListFields(),
requestKey: "context-poll"
});
if (records.length === 0) return;
setStore("tasks", currentTasks => {
const taskMap = new Map(currentTasks.map(t => [t.id, t]));
let changed = false;
records.forEach((r: any) => {
if (r.tags?.includes("__template__")) return;
const existing = taskMap.get(r.id);
if (existing && existing.updated === r.updated) return;
const newTask = mapRecordToTask(r);
if (existing && existing.content) {
newTask.content = existing.content;
}
taskMap.set(r.id, newTask);
changed = true;
});
if (changed) return Array.from(taskMap.values());
return currentTasks;
});
} catch (err: any) {
if (!err.isAbort) {
console.warn("Context poll failed:", err);
}
}
}, 5000);
}
};
createRoot(() => {
createEffect(() => {
currentTaskContext();
startContextPolling();
});
});
const runDailyCleanupIfNeeded = () => {
const today = new Date().toLocaleDateString("en-CA");
if (store.lastCompletedTaskCleanupDate === today) return;
setStore("lastCompletedTaskCleanupDate", today);
return true;
};
const STALE_COMPLETED_TASK_DAYS = 90;
const MS_PER_DAY = 24 * 60 * 60 * 1000;
const getStaleCompletedTaskDeletedAt = (task: Pick<Task, "completed" | "updated" | "deletedAt">) => {
if (task.deletedAt || !task.completed) return null;
const updatedAt = new Date(task.updated).getTime();
if (Number.isNaN(updatedAt)) return null;
const deletedAt = updatedAt + (STALE_COMPLETED_TASK_DAYS * MS_PER_DAY);
return deletedAt <= Date.now() ? deletedAt : null;
};
const taskHasAnyActiveContextAssignment = (task: Pick<Task, "ownerId" | "shareRefs" | "noteRefs">) => {
const currentUserId = pb.authStore.model?.id || "";
if (currentUserId && taskHasPersonalContextAccess(task, currentUserId, true)) {
return true;
}
const hasActiveShareContext = task.shareRefs.some(ref => !!store.contexts.find(context => context.id === ref.contextId && !context.deletedAt));
if (hasActiveShareContext) {
return true;
}
return task.noteRefs.some(ref => !!getNoteByRef(ref));
};
const runFinalInitTaskAutoTrash = async () => {
if (!pb.authStore.isValid) return;
const currentUserId = pb.authStore.model?.id;
if (!currentUserId) return;
const visibleTasks = store.tasks.filter(task =>
!task.deletedAt &&
!task.tags?.includes("__template__") &&
shouldTaskBeVisible(task, currentUserId)
);
for (const task of visibleTasks) {
if (task.deletedAt) continue;
const staleCompletedDeletedAt = getStaleCompletedTaskDeletedAt(task);
if (staleCompletedDeletedAt) {
const { updateTask } = await import("./tasks");
await updateTask(task.id, { deletedAt: staleCompletedDeletedAt });
continue;
}
if (!taskHasAnyActiveContextAssignment(task)) {
const { updateTask } = await import("./tasks");
await updateTask(task.id, { deletedAt: Date.now() });
}
}
};
const purgeExpiredTrashIfNeeded = async () => {
if (!runDailyCleanupIfNeeded()) return;
const expiryMs = 7 * 24 * 60 * 60 * 1000;
const cutoff = Date.now() - expiryMs;
const cutoffIso = new Date(cutoff).toISOString();
const expiredTaskRecords = await pb.collection(TASGRID_COLLECTION).getFullList({
filter: `deletedAt != "" && deletedAt <= "${cutoffIso}"`,
fields: "id,tags",
requestKey: null
}).catch(() => []);
const expiredTaskIds = expiredTaskRecords
.filter((record: any) => !record.tags?.includes("__template__"))
.map((record: any) => record.id as string);
const expiredNoteRecords = await pb.collection(NOTES_COLLECTION).getFullList({
filter: `deletedAt != "" && deletedAt <= "${cutoffIso}"`,
fields: "id,title,key,content,tags,isPrivate,user,tasks,created,updated,deletedAt",
requestKey: null
}).catch(() => []);
const expiredNotes = expiredNoteRecords.map((record: any) => mapRecordToNote(record));
for (const taskId of expiredTaskIds) {
await deleteTaskPermanently(taskId);
}
for (const note of expiredNotes) {
await deleteNotePermanently(note.id);
}
};
export const subscribeToRealtime = async () => {
const currentUserId = pb.authStore.model?.id;
await pb.collection(TASGRID_COLLECTION).unsubscribe("*");
pb.collection(TASGRID_COLLECTION).subscribe("*", e => {
console.log("Realtime event:", e.action, e.record.id);
if (e.action === "create") {
const isTemplate = e.record.tags?.includes("__template__");
if (isTemplate) {
const templateExists = store.templates?.some(t => t.id === e.record.id);
if (templateExists) return;
let meta: any = {};
try { meta = JSON.parse(e.record.content || "{}"); } catch { }
const newTemplate = {
id: e.record.id,
name: e.record.title,
title: meta.title || "",
priority: e.record.priority,
urgency: meta.urgency || 5,
tags: meta.tags || [],
content: meta.content || ""
};
setStore("templates", t => [...(t || []), newTemplate]);
return;
}
const exists = store.tasks.find(t => t.id === e.record.id);
if (!exists) {
const authUserId = pb.authStore.model?.id;
const isOwnTask = unwrapRelationId(e.record.user) === authUserId;
const newTask = mapRecordToTask(e.record);
if (isOwnTask || (authUserId && shouldTaskBeVisible(newTask, authUserId))) {
setStore("tasks", t => [newTask, ...t]);
}
}
}
if (e.action === "update") {
const isTemplate = e.record.tags?.includes("__template__");
if (isTemplate) {
let meta: any = {};
try { meta = JSON.parse(e.record.content || "{}"); } catch { }
setStore("templates", t => t.id === e.record.id, {
name: e.record.title,
title: meta.title || "",
priority: e.record.priority,
urgency: meta.urgency || 5,
tags: meta.tags || [],
content: meta.content || ""
});
return;
}
const authUserId = pb.authStore.model?.id;
const existingTask = store.tasks.find(t => t.id === e.record.id);
const isOwnTask = unwrapRelationId(e.record.user) === authUserId;
const incomingUpdated = new Date(e.record.updated).getTime();
const updatedTask = mapRecordToTask(e.record);
if (existingTask) {
const localUpdated = new Date(existingTask.updated).getTime();
if (!existingTask.updated || incomingUpdated >= localUpdated) {
if (!isOwnTask && authUserId && !shouldTaskBeVisible(updatedTask, authUserId)) {
setStore("tasks", t => t.filter(x => x.id !== e.record.id));
return;
}
setStore("tasks", t => t.id === e.record.id, updatedTask);
} else {
console.log("Skipping stale realtime update for task:", e.record.id);
}
} else if (authUserId && shouldTaskBeVisible(updatedTask, authUserId)) {
setStore("tasks", t => [updatedTask, ...t]);
}
}
if (e.action === "delete") {
setStore("tasks", t => t.filter(x => x.id !== e.record.id));
setStore("templates", t => (t || []).filter(x => x.id !== e.record.id));
}
});
await pb.collection(NOTES_COLLECTION).unsubscribe("*");
pb.collection(NOTES_COLLECTION).subscribe("*", e => {
const authUserId = pb.authStore.model?.id;
const isVisible = !e.record.isPrivate || unwrapRelationId(e.record.user) === authUserId;
if (e.action === "create" || e.action === "update") {
if (isVisible) {
const shouldSyncContent = loadedNoteDetailIds.has(e.record.id) || activeNoteId() === e.record.id;
const updatedNote = mapRecordToNote(e.record, shouldSyncContent);
setStore("notes", prev => {
const exists = prev.find(n => n.id === updatedNote.id);
if (exists) {
const mergedNote = shouldSyncContent || exists.content === undefined
? updatedNote
: { ...updatedNote, content: exists.content };
return prev.map(n => n.id === updatedNote.id ? mergedNote : n);
}
return [updatedNote, ...prev];
});
} else {
setStore("notes", prev => prev.filter(n => n.id !== e.record.id));
}
}
if (e.action === "delete") {
setStore("notes", prev => prev.filter(n => n.id !== e.record.id));
}
});
await pb.collection(CONTEXTS_COLLECTION).unsubscribe("*");
pb.collection(CONTEXTS_COLLECTION).subscribe("*", e => {
if (e.action === "delete") {
const remaining = store.contexts.filter(context => context.id !== e.record.id);
setContexts(remaining);
setStore("shareRules", remaining.map(mapContextToShareRule));
setStore("tasks", tasks => tasks.filter(task => shouldTaskBeVisible(task, pb.authStore.model?.id || "")));
void syncSupervisedContexts();
return;
}
const context = mapRecordToContext(e.record);
const nextContexts = store.contexts.some(existing => existing.id === context.id)
? store.contexts.map(existing => existing.id === context.id ? context : existing)
: [...store.contexts, context];
setContexts(nextContexts);
setStore("shareRules", nextContexts.map(mapContextToShareRule));
if (context.targetUserId === pb.authStore.model?.id) {
setStore("personalContextSupervisorIds", context.supervisorUserIds || []);
}
void syncSupervisedContexts();
});
if (currentUserId) {
await pb.collection("users").unsubscribe(currentUserId);
pb.collection("users").subscribe(currentUserId, e => {
if (e.action !== "update") return;
const prefs = readScopedPrefs(e.record?.Taskgrid_pref || {});
applyScopedPrefsToStore({
...prefs,
subscribedBuckets: prefs.subscribedBuckets || e.record?.subscribedBuckets || []
});
});
}
};
const runLegacyTagMigration = async (force = false) => {
const currentUserId = pb.authStore.model?.id;
if (!currentUserId) return;
try {
const userRec = await pb.collection("users").getOne(currentUserId, { requestKey: null });
const rawPrefs = userRec.Taskgrid_pref || {};
const prefs = readScopedPrefs(rawPrefs);
if (!force && prefs.migratedStructuredTagsV2) {
return;
}
console.log("[MIGRATION] Starting structured tag migration...");
await syncUserContexts();
const contexts = await loadContextsIntoStore();
const ownerContextByUserId = new Map(
contexts
.filter(context => context.kind === "user" && !!context.targetUserId && !context.deletedAt)
.map(context => [context.targetUserId as string, context])
);
const notes = await pb.collection(NOTES_COLLECTION).getFullList({
requestKey: null
});
const migratedNotes: Note[] = [];
for (const noteRecord of notes) {
const key = normalizeNoteKey(noteRecord.key || noteRecord.title || noteRecord.id);
const existingTags = Array.isArray(noteRecord.tags) ? noteRecord.tags.filter(Boolean) : [];
const nextTags = [...new Set([buildNoteTag(key), ...existingTags])];
const nextTasks = [...new Set((Array.isArray(noteRecord.tasks) ? noteRecord.tasks : []).filter(Boolean))];
await pb.collection(NOTES_COLLECTION).update(noteRecord.id, {
key,
tags: nextTags,
tasks: nextTasks
}, { requestKey: null });
migratedNotes.push({
...mapRecordToNote(noteRecord),
key,
tags: nextTags,
tasks: nextTasks
});
}
setStore("notes", migratedNotes);
const noteLinksByTaskId = buildNoteLinkMap(migratedNotes);
const tasks = await pb.collection(TASGRID_COLLECTION).getFullList({
requestKey: null
});
for (const taskRecord of tasks) {
if (taskRecord.tags?.includes("__template__")) continue;
const task = mapRecordToTask(taskRecord);
const payload = buildTaskMigrationPayload(
task,
ownerContextByUserId,
noteLinksByTaskId.get(task.id) || [],
currentUserId
);
await pb.collection(TASGRID_COLLECTION).update(task.id, payload, { requestKey: null });
}
const nextPrefs = { ...prefs, migratedStructuredTagsV2: true };
await pb.collection("users").update(currentUserId, {
Taskgrid_pref: writeScopedPrefs(rawPrefs, nextPrefs)
}, { requestKey: null });
} catch (err) {
console.error("[MIGRATION] Failed:", err);
}
};
const runContextOwnershipMigration = async (force = false) => {
const currentUserId = pb.authStore.model?.id;
if (!currentUserId) return;
try {
const userRec = await pb.collection("users").getOne(currentUserId, { requestKey: null });
const rawPrefs = userRec.Taskgrid_pref || {};
const prefs = readScopedPrefs(rawPrefs);
if (!force && prefs.migratedContextOwnershipV2) {
return;
}
await syncUserContexts();
const contexts = store.contexts.length > 0 ? store.contexts : await loadContextsIntoStore();
const ownerContextByUserId = new Map(
contexts
.filter(context => context.kind === "user" && !!context.targetUserId && !context.deletedAt)
.map(context => [context.targetUserId as string, context])
);
const noteRecords = await pb.collection(NOTES_COLLECTION).getFullList({
fields: "id,created,updated,title,key,tags,isPrivate,user,tasks,deletedAt",
requestKey: null
}).catch(() => []);
const allNotes = noteRecords.map(record => mapRecordToNote(record));
const noteLinksByTaskId = buildNoteLinkMap(allNotes);
const taskRecords = await pb.collection(TASGRID_COLLECTION).getFullList({
fields: getTaskListFields(),
requestKey: null
});
for (const taskRecord of taskRecords) {
if (taskRecord.tags?.includes("__template__")) continue;
const task = mapRecordToTask(taskRecord);
if (!task.ownerId) continue;
const payload = buildTaskMigrationPayload(
task,
ownerContextByUserId,
noteLinksByTaskId.get(task.id) || [],
currentUserId
);
const sameShareRefs = JSON.stringify(payload.shareRefs) === JSON.stringify(task.shareRefs);
const sameNoteRefs = JSON.stringify(payload.noteRefs) === JSON.stringify(task.noteRefs);
const sameTags = JSON.stringify(payload.tags) === JSON.stringify(task.tags);
const sameOwnerId = payload.ownerId === task.ownerId;
if (sameShareRefs && sameNoteRefs && sameTags && payload.createdBy === task.createdBy && sameOwnerId) continue;
const pbPayload = {
...payload,
user: payload.ownerId || task.ownerId || null
} as any;
delete pbPayload.ownerId;
await pb.collection(TASGRID_COLLECTION).update(task.id, pbPayload, { requestKey: null });
}
await pb.collection("users").update(currentUserId, {
Taskgrid_pref: writeScopedPrefs(rawPrefs, {
...prefs,
migratedPersonalContextRefsV1: true,
migratedContextOwnershipV2: true
})
}, { requestKey: null });
} catch (err) {
console.error("[MIGRATION] Failed context ownership migration:", err);
}
};
export const runDevDataMigration = async () => {
if (!pb.authStore.isValid) return;
if (!TASGRID_IS_DEV_DATA) {
toast.error("Dev migration is only available in dev data mode.");
return;
}
await toast.promise((async () => {
await runLegacyTagMigration(true);
await runContextOwnershipMigration(true);
await loadContextsIntoStore();
await syncSupervisedContexts();
return "Dev data migration completed.";
})(), {
loading: "Running dev data migration...",
success: message => message,
error: "Dev data migration failed."
});
};
export const canRunProdMigration = () => {
if (TASGRID_IS_DEV_DATA || !PROD_MIGRATION_ENABLED) return false;
const email = (pb.authStore.model?.email || "").trim().toLowerCase();
return !!email && PROD_MIGRATION_ADMIN_EMAILS.includes(email);
};
export const runProdDataMigration = async () => {
if (!pb.authStore.isValid) return;
if (!canRunProdMigration()) {
toast.error("Prod migration is not enabled for this account.");
return;
}
await toast.promise((async () => {
await runLegacyTagMigration(true);
await runContextOwnershipMigration(true);
await loadContextsIntoStore();
await syncSupervisedContexts();
return "Production data migration completed.";
})(), {
loading: "Running production data migration...",
success: message => message,
error: "Production data migration failed."
});
};
export const initStore = async () => {
ensureRealtimeRecovery();
if (!pb.authStore.isValid || store.isInitializing) return;
setStore("isInitializing", true);
const currentUserId = pb.authStore.model?.id;
const refreshedTaskIds = new Set<string>();
if (currentUserId) {
try {
const prefs = readScopedPrefs(pb.authStore.model?.Taskgrid_pref || {});
const cachedQuickload = prefs.quickloadTasks || [];
let focusRecords;
if (cachedQuickload.length > 0) {
const idFilter = cachedQuickload.map((id: string) => `id = "${id}"`).join(" || ");
const res = await pb.collection(TASGRID_COLLECTION).getFullList({
filter: `(${idFilter}) && deletedAt = ""`,
requestKey: null
});
res.sort((a, b) => cachedQuickload.indexOf(a.id) - cachedQuickload.indexOf(b.id));
focusRecords = { items: res };
} else {
const personalAccessFilter = buildPersonalAccessFilter([currentUserId]);
focusRecords = await pb.collection(TASGRID_COLLECTION).getList(1, 30, {
filter: `${personalAccessFilter} && completed = false && deletedAt = ""`,
sort: "-priority,dueDate",
requestKey: null
});
}
const focusTasks = focusRecords.items
.map(mapRecordToTask)
.filter(t => !t.tags?.includes("__template__"));
setStore("tasks", focusTasks);
setStore("quickloadFadeTaskIds", focusTasks.map(task => task.id));
focusTasks.forEach(task => refreshedTaskIds.add(task.id));
if (quickloadFadeTimer) clearTimeout(quickloadFadeTimer);
quickloadFadeTimer = window.setTimeout(() => {
setStore("quickloadFadeTaskIds", []);
quickloadFadeTimer = undefined;
}, 200);
} catch (focusErr) {
console.warn("Stage 1 load failed:", focusErr);
}
}
const key = getStorageKey();
if (key) {
const saved = localStorage.getItem(key);
if (saved) {
try {
const parsed = JSON.parse(saved);
const cachedTasks = Array.isArray(parsed?.tasks) ? parsed.tasks : [];
const mergedCachedTasks = [
...store.tasks,
...cachedTasks.filter((task: Task) => !refreshedTaskIds.has(task.id))
];
setStore({
...parsed,
tasks: mergedCachedTasks,
isInitializing: true
});
} catch (e) {
console.error("Failed to parse cached data", e);
}
}
}
if (heartbeatInterval) clearInterval(heartbeatInterval);
heartbeatInterval = setInterval(() => {
setNow(Date.now());
checkRecurringTasks();
}, 60000);
try {
try {
const userId = pb.authStore.model?.id;
if (userId) {
const user = await pb.collection("users").getOne(userId, { requestKey: null });
const prefs = readScopedPrefs(user.Taskgrid_pref || {});
applyScopedPrefsToStore({
...prefs,
subscribedBuckets: prefs.subscribedBuckets || user.subscribedBuckets || []
});
}
} catch (prefErr) {
console.warn("Failed to load preferences:", prefErr);
}
try {
const contextRecords = await pb.collection(CONTEXTS_COLLECTION).getFullList({
sort: "displayName,key",
requestKey: null
});
const contexts = contextRecords.map(mapRecordToContext);
setContexts(contexts);
setStore("shareRules", contexts.map(mapContextToShareRule));
await syncUserContexts();
await syncSupervisedContexts();
const personalContext = getPersonalContext(pb.authStore.model?.id);
if (personalContext) {
setStore("personalContextSupervisorIds", personalContext.supervisorUserIds || []);
}
if (personalContext && currentTaskContext() === "mine") {
setCurrentTaskContext({ bucketId: personalContext.id, name: "My Bucket", isPersonal: true });
}
} catch (contextErr) {
console.warn("Failed to load contexts (collection might not exist yet):", contextErr);
}
await syncSystemTagsAndRules();
const authUserId = pb.authStore.model?.id;
if (!authUserId) return;
const backgroundSync = async () => {
const subscribedBucketTags = getSubscribedBucketContexts()
.map(context => buildShareTag(context.displayName));
const supervisedUserIds = store.supervisedContexts.map(context => context.ownerUserId);
const currentUserAccessFilter = buildPersonalAccessFilter(authUserId ? [authUserId] : []);
const supervisedAccessFilter = buildPersonalAccessFilter(supervisedUserIds);
const incompletePromises = [
pb.collection(TASGRID_COLLECTION).getFullList({
filter: `${currentUserAccessFilter} && completed = false`,
sort: "-created",
fields: getTaskListFields(),
requestKey: null
}),
subscribedBucketTags.length > 0 ? pb.collection(TASGRID_COLLECTION).getFullList({
filter: `(${subscribedBucketTags.map(tag => `tags ~ "${tag}"`).join(" || ")}) && completed = false`,
sort: "-created",
fields: getTaskListFields(),
requestKey: null
}).catch(() => []) : Promise.resolve([]),
supervisedUserIds.length > 0 ? pb.collection(TASGRID_COLLECTION).getFullList({
filter: `${supervisedAccessFilter} && completed = false`,
sort: "-created",
fields: getTaskListFields(),
requestKey: null
}).catch(() => []) : Promise.resolve([])
];
const stage2Results = await Promise.all(incompletePromises);
const allIncomplete = stage2Results.flat();
setStore("tasks", currentTasks => {
const taskMap = new Map(currentTasks.map(t => [t.id, t]));
allIncomplete.forEach((r: any) => {
if (r.tags?.includes("__template__")) return;
if (refreshedTaskIds.has(r.id)) return;
const existing = taskMap.get(r.id);
if (existing && existing.updated === r.updated) {
return;
}
const newTask = mapRecordToTask(r);
if (existing && existing.content) {
newTask.content = existing.content;
}
taskMap.set(r.id, newTask);
refreshedTaskIds.add(r.id);
});
return Array.from(taskMap.values());
});
const tasksNeedingContent = store.tasks
.filter(t => !t.completed && t.content === undefined && !refreshedTaskIds.has(t.id))
.map(t => t.id);
if (tasksNeedingContent.length > 0) {
const chunkSize = 20;
for (let i = 0; i < tasksNeedingContent.length; i += chunkSize) {
const ids = tasksNeedingContent.slice(i, i + chunkSize);
try {
const contentRecords = await pb.collection(TASGRID_COLLECTION).getFullList({
filter: ids.map(id => `id="${id}"`).join(" || "),
fields: "id,content",
requestKey: null
});
setStore("tasks", tasks => tasks.map(t => {
const match = contentRecords.find((r: any) => r.id === t.id);
if (match) return { ...t, content: match.content };
return t;
}));
} catch (e) {
console.warn("Backfill chunk failed", e);
}
}
}
const currentCount = store.tasks.length;
const remainingSlots = 150 - currentCount;
if (remainingSlots > 0) {
try {
const completedAccessFilter = buildPersonalAccessFilter(authUserId ? [authUserId] : []);
const recentCompleted = await pb.collection(TASGRID_COLLECTION).getList(1, remainingSlots, {
filter: `${completedAccessFilter} && completed = true`,
sort: "-updated",
fields: getTaskListFields(),
requestKey: null
});
setStore("tasks", prev => {
const newTasks = [...prev];
recentCompleted.items.forEach((r: any) => {
if (!newTasks.some(t => t.id === r.id)) {
newTasks.push(mapRecordToTask(r));
}
});
return newTasks;
});
} catch (e) {
console.warn("Stage 3 load failed", e);
}
}
checkRecurringTasks();
const foundTagNames = new Set<string>();
store.tasks.forEach(t => t.tags?.forEach(tag => foundTagNames.add(tag)));
for (const tagName of foundTagNames) {
if (tagName === "__template__" || tagName.startsWith("@") || tagName.startsWith("#")) continue;
if (!store.tagDefinitions.find(d => d.name === tagName)) {
await upsertTagDefinition(tagName, 5, undefined, document.documentElement.classList.contains("dark") ? "dark" : "light");
}
}
};
await backgroundSync();
await ensureUpdatedByFieldSupport();
if (TASGRID_IS_DEV_DATA) {
await runLegacyTagMigration();
await runContextOwnershipMigration();
}
try {
const templates = await pb.collection(TASGRID_COLLECTION).getFullList({
filter: `${relationFilter("user", authUserId)} && tags ~ "__template__"`,
requestKey: null
});
const loadedTemplates = templates.map(r => {
let meta: any = {};
try { meta = JSON.parse(r.content || "{}"); } catch { }
return {
id: r.id,
name: r.title,
title: meta.title || "",
priority: r.priority,
urgency: meta.urgency || 5,
tags: meta.tags || [],
content: meta.content || ""
};
});
setStore("templates", loadedTemplates);
} catch { }
await subscribeToRealtime();
await loadFilterTemplates();
scheduleDeferredNotesMetadataLoad();
await purgeExpiredTrashIfNeeded();
await runFinalInitTaskAutoTrash();
} catch (err) {
if (store.tasks.length === 0) {
console.error("Failed to load data:", err);
toast.error("Failed to sync with server.");
}
} finally {
setStore("isInitializing", false);
}
};
export const setupPersistence = () => {
initStore();
};
createRoot(() => {
createEffect(() => {
const key = getStorageKey();
if (key) {
localStorage.setItem(key, JSON.stringify(getPersistedStoreSnapshot()));
}
});
let debouncedSync: number | undefined;
createEffect(() => {
if (store.isInitializing || !pb.authStore.model?.id) return;
const incompleteTasks = store.tasks.filter(t =>
!t.completed &&
taskHasPersonalContextAccess(t, pb.authStore.model?.id || "", true) &&
!t.tags?.includes("__template__") &&
!t.deletedAt
);
const scoredTasks = incompleteTasks.map(t => ({ id: t.id, score: getCombinedScore(t) }));
scoredTasks.sort((a, b) => b.score - a.score);
const top20Ids = scoredTasks.slice(0, 20).map(t => t.id);
const currentIds = store.quickloadTasks || [];
const isDifferent = top20Ids.length !== currentIds.length || top20Ids.some((id, i) => id !== currentIds[i]);
if (isDifferent) {
setStore("quickloadTasks", top20Ids);
if (debouncedSync) window.clearTimeout(debouncedSync);
debouncedSync = window.setTimeout(() => {
syncPreferences();
}, 10000);
}
});
});
+864
View File
@@ -0,0 +1,864 @@
import { reconcile } from "solid-js/store";
import { toast } from "solid-sonner";
import { pb } from "@/lib/pocketbase";
import { CONTEXTS_COLLECTION, NOTES_COLLECTION, TASGRID_COLLECTION } from "@/lib/constants";
import { buildNoteTag, buildShareTag, normalizeContextKey, normalizeNoteKey, parseTags } from "@/lib/tags";
import {
activeNoteId,
currentTaskContext,
type Bucket,
type Note,
type NoteRef,
readScopedPrefs,
type ShareContext,
type ShareRule,
setStore,
store,
type TagDefinition,
type Task,
type TaskContext,
type TaskShareRef,
unwrapRelationId,
unwrapRelationIds,
writeScopedPrefs
} from "./state";
export const relationFilter = (field: string, id: string | undefined | null) =>
id ? `(${field} = "${id}" || ${field} ?= "${id}")` : "false";
export const buildUserContextKey = (userId: string) => `user-${userId}`;
const DEFAULT_LABEL_THEME: "light" | "dark" = "dark";
const mapContextToBucket = (context: ShareContext): Bucket => ({
id: context.id,
name: context.displayName,
color: context.color || "#64748b",
description: context.description,
policy: context.policy,
key: context.key,
deletedAt: context.deletedAt ?? null
});
const deriveBuckets = (contexts: ShareContext[]) =>
contexts
.filter(context => context.kind === "bucket" && !context.deletedAt)
.sort((a, b) => a.displayName.localeCompare(b.displayName))
.map(mapContextToBucket);
const deriveSystemTagDefinitions = (contexts: ShareContext[]): TagDefinition[] =>
contexts
.filter(context => !context.deletedAt)
.map(context => ({
id: context.id,
name: buildShareTag(context.displayName),
value: 5,
color: context.color,
theme: DEFAULT_LABEL_THEME,
isUser: context.kind === "user",
isBucket: context.kind === "bucket"
}));
const findContextForShareTag = (tag: { key: string; raw: string }) =>
store.contexts.find(context => !context.deletedAt && context.key === tag.key) ||
store.contexts.find(context => !context.deletedAt && context.kind === "user" && normalizeContextKey(context.displayName) === tag.key) ||
store.contexts.find(context => !context.deletedAt && normalizeContextKey(context.displayName) === tag.key) ||
store.contexts.find(context => !context.deletedAt && buildShareTag(context.displayName).toLowerCase() === tag.raw.toLowerCase()) ||
null;
const buildNoteContext = (note: Note): ShareContext => ({
id: note.id,
key: note.key,
displayName: note.key || note.title,
kind: "note",
policy: getNoteSharePolicy(note),
targetUserId: note.user,
deletedAt: note.deletedAt ?? null
});
export const getContextByRef = (ref: TaskShareRef) =>
(ref.kind === "note"
? (() => {
const note = store.notes.find(existing => !existing.deletedAt && (existing.id === ref.contextId || existing.key === ref.key));
return note ? buildNoteContext(note) : null;
})()
: null) ||
store.contexts.find(context => !context.deletedAt && context.id === ref.contextId) ||
store.contexts.find(context => !context.deletedAt && context.key === ref.key && context.kind === ref.kind) ||
store.contexts.find(context => !context.deletedAt && context.kind === ref.kind && normalizeContextKey(context.displayName) === ref.key) ||
store.contexts.find(context => !context.deletedAt && normalizeContextKey(context.displayName) === ref.key) ||
null;
export const getNoteByRef = (ref: NoteRef) =>
store.notes.find(note => !note.deletedAt && note.id === ref.noteId) ||
store.notes.find(note => !note.deletedAt && note.key === ref.key);
const DEFAULT_SHARE_POLICY_BY_KIND: Record<TaskShareRef["kind"], "collaborative" | "handoff"> = {
user: "collaborative",
bucket: "handoff",
note: "handoff"
};
const NOTE_HANDOFF_POLICY_TAG = "__note_policy_handoff__";
export const getNoteSharePolicy = (note: Pick<Note, "tags"> | null | undefined): "collaborative" | "handoff" =>
note?.tags?.includes(NOTE_HANDOFF_POLICY_TAG) ? "handoff" : "collaborative";
export const getTaskSharePolicy = (ref: Pick<TaskShareRef, "kind" | "key" | "policy" | "contextId">): "collaborative" | "handoff" => {
if (ref.kind === "note") {
if (ref.policy) return ref.policy;
const note = store.notes.find(existing => !existing.deletedAt && (existing.id === ref.contextId || existing.key === ref.key));
return getNoteSharePolicy(note);
}
const context = getContextByRef(ref as TaskShareRef);
if (ref.policy) return ref.policy;
if (context?.policy) return context.policy;
return DEFAULT_SHARE_POLICY_BY_KIND[ref.kind];
};
export const buildTaskTags = (
labelTags: string[],
shareRefs: TaskShareRef[],
noteRefs: NoteRef[],
favoriteTag?: string
) => {
const tags = [
...labelTags,
...shareRefs.map(ref => {
const context = getContextByRef(ref);
if (ref.kind === "note") {
return buildNoteTag(context?.key || ref.key);
}
if (context?.displayName) {
return buildShareTag(context.displayName);
}
if (ref.kind === "user" && ref.key.startsWith("user-")) {
return null;
}
return buildShareTag(ref.key);
}),
...noteRefs.map(ref => {
const note = getNoteByRef(ref);
return buildNoteTag(note?.key || ref.key);
})
];
if (favoriteTag && !tags.includes(favoriteTag)) {
tags.push(favoriteTag);
}
return [...new Set(tags.filter((tag): tag is string => !!tag))];
};
export const dedupeShareRefs = (refs: TaskShareRef[]) => {
const seen = new Set<string>();
return refs.filter(ref => {
const key = `${ref.kind}:${ref.contextId}:${ref.key}`;
if (seen.has(key)) return false;
seen.add(key);
return true;
});
};
export const buildTaskShareRef = (
parsedTag: Extract<ReturnType<typeof parseTags>[number], { kind: "share" | "note" }>,
existingRefs: TaskShareRef[] = [],
shareModeOverrides: Record<string, "collaborative" | "handoff"> = {}
): TaskShareRef => {
if (parsedTag.kind === "note") {
const note = store.notes.find(existing => !existing.deletedAt && existing.key === parsedTag.key);
const nextRefBase = {
contextId: note?.id || parsedTag.key,
key: parsedTag.key,
kind: "note" as const
};
const existing = existingRefs.find(ref => ref.kind === "note" && (ref.contextId === nextRefBase.contextId || ref.key === nextRefBase.key));
return {
...nextRefBase,
policy: shareModeOverrides[parsedTag.raw.toLowerCase()] || existing?.policy || getTaskSharePolicy(nextRefBase)
};
}
const context = findContextForShareTag(parsedTag);
const nextRefBase = {
contextId: context?.id || parsedTag.key,
key: parsedTag.key,
kind: (context?.kind || "bucket") as "user" | "bucket"
};
const existing = existingRefs.find(ref =>
ref.kind === nextRefBase.kind &&
(ref.contextId === nextRefBase.contextId || ref.key === nextRefBase.key)
);
return {
...nextRefBase,
policy: shareModeOverrides[parsedTag.raw.toLowerCase()] || existing?.policy || getTaskSharePolicy(nextRefBase)
};
};
export const getTaskShareModeForTag = (task: Pick<Task, "shareRefs">, tag: string): "collaborative" | "handoff" | undefined => {
const parsed = parseTags([tag])[0];
if (!parsed || (parsed.kind !== "share" && parsed.kind !== "note")) return undefined;
const ref = task.shareRefs.find(existing =>
existing.key === parsed.key && (
(parsed.kind === "note" && existing.kind === "note") ||
(parsed.kind === "share" && existing.kind !== "note")
)
);
return ref ? getTaskSharePolicy(ref) : undefined;
};
export const deriveTaskRelationsFromTags = (
tags: string[],
existingShareRefs: TaskShareRef[] = [],
shareModeOverrides: Record<string, "collaborative" | "handoff"> = {}
) => {
const parsedTags = parseTags(tags);
const labelTags = parsedTags.filter(tag => tag.kind === "label").map(tag => tag.display);
const shareRefs = parsedTags
.filter((tag): tag is Extract<typeof tag, { kind: "share" | "note" }> => tag.kind === "share" || tag.kind === "note")
.map(tag => buildTaskShareRef(tag, existingShareRefs, shareModeOverrides));
const noteRefs = parsedTags
.filter(tag => tag.kind === "note")
.map(tag => {
const note = store.notes.find(existing => !existing.deletedAt && existing.key === tag.key);
return {
noteId: note?.id || tag.key,
key: tag.key
} satisfies NoteRef;
});
return {
labelTags,
shareRefs: dedupeShareRefs(shareRefs),
noteRefs: dedupeNoteRefs(noteRefs)
};
};
export const dedupeNoteRefs = (refs: NoteRef[]) => {
const seen = new Set<string>();
return refs.filter(ref => {
const key = `${ref.noteId}:${ref.key}`;
if (seen.has(key)) return false;
seen.add(key);
return true;
});
};
export const setContexts = (contexts: ShareContext[]) => {
setStore("contexts", reconcile(contexts));
setStore("buckets", reconcile(deriveBuckets(contexts)));
setStore("tasks", tasks => tasks.map(task => {
const favoriteTag = task.tags.find(tag => tag.endsWith("_favorite__"));
return {
...task,
tags: buildTaskTags(task.labelTags, task.shareRefs, task.noteRefs, favoriteTag)
};
}));
const labelDefinitions = store.tagDefinitions.filter(def => !def.name.startsWith("@"));
const mergedDefs = [...labelDefinitions, ...deriveSystemTagDefinitions(contexts)];
setStore("tagDefinitions", reconcile(mergedDefs));
};
export const mapContextToShareRule = (context: ShareContext): ShareRule => ({
id: context.id,
ownerId: pb.authStore.model?.id || "",
targetUserId: context.targetUserId || pb.authStore.model?.id || "",
type: "tag",
tagName: buildShareTag(context.displayName),
share_mode: context.policy === "handoff" ? "SEND_TASK" : "ADD_USER"
});
export const mapRecordToContext = (r: any): ShareContext => ({
id: r.id,
key: r.key || normalizeContextKey(r.displayName || r.name || ""),
displayName: r.displayName || r.name || r.key,
kind: (r.kind || "bucket") as "user" | "bucket",
policy: (r.policy || (r.kind === "user" ? "collaborative" : "handoff")) as "collaborative" | "handoff",
targetUserId: unwrapRelationId(r.targetUserId),
supervisorUserIds: unwrapRelationIds(r.supervisorUserIds),
color: r.color,
description: r.description,
deletedAt: r.deletedAt ? new Date(r.deletedAt).getTime() : null,
deletedBy: unwrapRelationId(r.deletedBy)
});
export const loadContextsIntoStore = async () => {
const contextRecords = await pb.collection(CONTEXTS_COLLECTION).getFullList({
sort: "displayName,key",
requestKey: null
}).catch(() => []);
const nextContexts = contextRecords.map(mapRecordToContext);
setContexts(nextContexts);
setStore("shareRules", nextContexts.map(mapContextToShareRule));
return nextContexts;
};
export const getPersonalContext = (userId = pb.authStore.model?.id) =>
userId
? store.contexts.find(context =>
!context.deletedAt &&
context.kind === "user" &&
context.targetUserId === userId
) || null
: null;
const getCurrentUserDisplayName = () =>
(pb.authStore.model?.name || pb.authStore.model?.email || "").trim() || null;
const getPersonalContextTag = (userId = pb.authStore.model?.id) => {
const context = getPersonalContext(userId);
if (context?.displayName) {
return buildShareTag(context.displayName);
}
if (userId && userId === pb.authStore.model?.id) {
const displayName = getCurrentUserDisplayName();
if (displayName) {
return buildShareTag(displayName);
}
}
return null;
};
const escapeFilterValue = (value: string) => value.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
export const buildPersonalAccessFilter = (userIds: string[], includeLegacyUser = true) => {
const clauses = userIds.flatMap(userId => {
const nextClauses: string[] = [];
const personalTag = getPersonalContextTag(userId);
if (personalTag) {
nextClauses.push(`tags ~ "${escapeFilterValue(personalTag)}"`);
}
if (includeLegacyUser) {
nextClauses.push(relationFilter("user", userId));
}
return nextClauses;
}).filter(Boolean);
return clauses.length > 0 ? `(${clauses.join(" || ")})` : "false";
};
export const getPersonalContextUserIdFromRef = (ref: TaskShareRef) => {
if (ref.kind !== "user") return null;
const context = getContextByRef(ref);
if (context?.targetUserId) {
return context.targetUserId;
}
if (ref.key.startsWith("user-")) {
return ref.key.replace(/^user-/, "") || null;
}
return null;
};
export const getTaskPersonalRefs = (task: Pick<Task, "shareRefs">) =>
task.shareRefs.filter(ref => ref.kind === "user" && !!getPersonalContextUserIdFromRef(ref));
const getTaskPersonalUserIds = (task: Pick<Task, "shareRefs">) =>
[...new Set(getTaskPersonalRefs(task).map(ref => getPersonalContextUserIdFromRef(ref)).filter((value): value is string => !!value))];
const taskHasLegacyPersonalAccess = (task: Pick<Task, "ownerId" | "shareRefs">, userId: string) =>
task.ownerId === userId &&
getTaskPersonalRefs(task).length === 0 &&
!task.shareRefs.some(ref => ref.kind !== "user" && getTaskSharePolicy(ref) === "handoff");
export const taskHasPersonalContextAccess = (task: Pick<Task, "ownerId" | "shareRefs">, userId: string, allowLegacyFallback = true) =>
getTaskPersonalUserIds(task).includes(userId) ||
(allowLegacyFallback && taskHasLegacyPersonalAccess(task, userId));
export const resolveMirroredOwnerId = (
currentOwnerId: string | null | undefined,
shareRefs: TaskShareRef[],
preferredOwnerId?: string | null
) => {
const personalUserIds = [...new Set(
shareRefs
.filter(ref => ref.kind === "user")
.map(ref => getPersonalContextUserIdFromRef(ref))
.filter((value): value is string => !!value)
)];
if (personalUserIds.length === 1) {
return personalUserIds[0];
}
if (personalUserIds.length > 1 && preferredOwnerId && personalUserIds.includes(preferredOwnerId)) {
return preferredOwnerId;
}
return currentOwnerId || null;
};
export const withMirroredOwnerId = (
currentOwnerId: string | null | undefined,
shareRefs: TaskShareRef[],
updates: Partial<Task>,
preferredOwnerId?: string | null
) => ({
...updates,
ownerId: resolveMirroredOwnerId(currentOwnerId, shareRefs, preferredOwnerId)
});
export const withActiveContextTags = (rawTags: string[]) => {
const nextTags = [...rawTags];
const ctx = currentTaskContext();
if (ctx === "mine") {
const personalContext = getPersonalContext();
if (personalContext) {
const personalTag = buildShareTag(personalContext.displayName);
if (!nextTags.some(tag => tag.toLowerCase() === personalTag.toLowerCase())) {
nextTags.push(personalTag);
}
}
} else if (typeof ctx === "object") {
if ("bucketId" in ctx && ctx.name) {
const bucketContext = store.contexts.find(context => context.id === ctx.bucketId);
const bucketTag = buildShareTag(bucketContext?.displayName || ctx.name);
if (!nextTags.some(tag => tag.toLowerCase() === bucketTag.toLowerCase())) {
nextTags.push(bucketTag);
}
} else if ("userId" in ctx && ctx.name) {
const userTag = buildShareTag(ctx.name);
if (!nextTags.some(tag => tag.toLowerCase() === userTag.toLowerCase())) {
nextTags.push(userTag);
}
}
}
if (store.isNotepadMode) {
const noteId = activeNoteId();
const note = noteId ? store.notes.find(existing => existing.id === noteId) : null;
if (note) {
const noteTag = buildNoteTag(note.key || note.title);
if (!nextTags.some(tag => tag.toLowerCase() === noteTag.toLowerCase())) {
nextTags.push(noteTag);
}
}
}
return [...new Set(nextTags)];
};
export const isCurrentUserContextTag = (tag: string) => {
const normalizedTag = tag.trim().toLowerCase();
if (!normalizedTag.startsWith("@")) return false;
const personalContext = getPersonalContext();
const currentUserDisplayName = (pb.authStore.model?.name || pb.authStore.model?.email || "").trim();
const hiddenTags = new Set<string>();
if (personalContext?.displayName) {
hiddenTags.add(buildShareTag(personalContext.displayName).toLowerCase());
}
if (currentUserDisplayName) {
hiddenTags.add(buildShareTag(currentUserDisplayName).toLowerCase());
}
return hiddenTags.has(normalizedTag);
};
export const isTaskLockedNoteTag = (task: Pick<Task, "tags" | "shareRefs" | "noteRefs">, tag: string) => {
const normalizedTag = tag.trim();
if (!normalizedTag.startsWith("#")) return false;
const noteKey = normalizeNoteKey(normalizedTag);
const matchingNoteRefs = task.noteRefs.filter(ref => ref.key === noteKey || ref.noteId === noteKey);
if (matchingNoteRefs.length === 0) return false;
const nonNoteContexts = task.shareRefs.filter(ref => ref.kind !== "note");
const uniqueNoteRefs = dedupeNoteRefs(task.noteRefs);
return uniqueNoteRefs.length === matchingNoteRefs.length && nonNoteContexts.length === 0;
};
export const getVisibleTaskTags = (task: Pick<Task, "tags" | "shareRefs" | "noteRefs">, context: TaskContext = currentTaskContext()) => {
const tags = task.tags || [];
return tags.filter(tag => {
if (tag.endsWith("_favorite__") || isCurrentUserContextTag(tag)) {
return false;
}
if (isTaskLockedNoteTag(task, tag)) {
return false;
}
if (typeof context === "object" && "bucketId" in context) {
const bucketContext = store.contexts.find(existing => existing.id === context.bucketId);
const bucketTagName = buildShareTag(bucketContext?.displayName || context.name).toLowerCase();
if (tag.toLowerCase() === bucketTagName) {
return false;
}
}
return true;
});
};
export const getSubscribedBucketContexts = () =>
store.subscribedBuckets
.map(id => store.contexts.find(context => context.id === id))
.filter((context): context is ShareContext => !!context && !context.deletedAt && context.kind === "bucket");
export const shouldTaskBeVisible = (task: Task, currentUserId: string): boolean => {
if (taskHasPersonalContextAccess(task, currentUserId)) return true;
if (store.supervisedContexts.some(context => taskHasPersonalContextAccess(task, context.ownerUserId))) return true;
const hasSubscribedBucket = task.shareRefs.some(ref =>
ref.kind === "bucket" && (
store.subscribedBuckets.includes(ref.contextId) ||
store.subscribedBuckets.some(bucketId => store.contexts.find(context => context.id === bucketId)?.key === ref.key)
)
);
if (hasSubscribedBucket) return true;
const hasAccessibleNote = task.noteRefs.some(ref => {
const note = getNoteByRef(ref);
return !!note && (!note.isPrivate || note.user === currentUserId);
});
if (hasAccessibleNote) return true;
return task.shareRefs.some(ref => {
if (ref.kind !== "user" || getTaskSharePolicy(ref) !== "collaborative") return false;
const context = getContextByRef(ref);
return !!context && !context.deletedAt && context.targetUserId === currentUserId;
});
};
export const syncUserContexts = async () => {
const allUsers = await pb.collection("users").getFullList({
filter: "verified = true",
fields: "id,name,email",
requestKey: null
});
const existingContexts = await pb.collection(CONTEXTS_COLLECTION).getFullList({
requestKey: null
}).catch(() => []);
let createdAny = false;
for (const user of allUsers) {
const displayName = user.name || user.email;
const existingUserContext = existingContexts.find((context: any) =>
(context.kind || "bucket") === "user" &&
unwrapRelationId(context.targetUserId) === user.id
);
if (!displayName || existingUserContext) continue;
const created = await pb.collection(CONTEXTS_COLLECTION).create({
key: buildUserContextKey(user.id),
displayName,
kind: "user",
policy: "collaborative",
targetUserId: user.id
}, { requestKey: null }).catch(() => null);
if (created) {
createdAny = true;
}
}
if (createdAny || store.contexts.length === 0) {
await loadContextsIntoStore();
}
};
export const syncSupervisedContexts = async () => {
const currentUserId = pb.authStore.model?.id;
if (!currentUserId) {
setStore("supervisedContexts", []);
return;
}
try {
const contextRecords = await pb.collection(CONTEXTS_COLLECTION).getFullList({
sort: "displayName,key",
requestKey: null
}).catch(() => []);
const contexts = contextRecords.length > 0 ? contextRecords.map(mapRecordToContext) : store.contexts;
if (contextRecords.length > 0) {
setContexts(contexts);
setStore("shareRules", contexts.map(mapContextToShareRule));
}
const nextSupervisedContexts = contexts
.filter(context =>
!context.deletedAt &&
context.kind === "user" &&
!!context.targetUserId &&
context.targetUserId !== currentUserId &&
(context.supervisorUserIds || []).includes(currentUserId)
)
.map(context => ({
ownerUserId: context.targetUserId as string,
ownerName: context.displayName,
contextId: context.id,
contextName: context.displayName
}))
.sort((a, b) => a.ownerName.localeCompare(b.ownerName));
setStore("supervisedContexts", nextSupervisedContexts);
} catch (err) {
console.error("Failed to load supervised contexts:", err);
}
};
export const toggleBucketSubscription = async (bucketId: string) => {
const currentSubscribed = store.subscribedBuckets;
const isSubscribed = currentSubscribed.includes(bucketId);
const newSubscribed = isSubscribed
? currentSubscribed.filter(id => id !== bucketId)
: [...currentSubscribed, bucketId];
setStore("subscribedBuckets", newSubscribed);
const userId = pb.authStore.model?.id;
if (userId) {
try {
const user = await pb.collection("users").getOne(userId, { requestKey: null });
const rawPrefs = user.Taskgrid_pref || {};
const scopedPrefs = readScopedPrefs(rawPrefs);
await pb.collection("users").update(userId, {
Taskgrid_pref: writeScopedPrefs(rawPrefs, {
...scopedPrefs,
subscribedBuckets: newSubscribed
})
});
if (!isSubscribed) {
const bucket = store.buckets.find(b => b.id === bucketId);
if (bucket) {
const { getTaskListFields, mapRecordToTask } = await import("./tasks");
const records = await pb.collection(TASGRID_COLLECTION).getFullList({
filter: `tags ~ "${buildShareTag(bucket.name)}"`,
sort: "-created",
fields: getTaskListFields()
});
const newTasks: Task[] = [];
records.forEach((r: any) => {
const exists = store.tasks.find(t => t.id === r.id);
if (!exists && !r.tags?.includes("__template__")) {
newTasks.push(mapRecordToTask(r));
}
});
if (newTasks.length > 0) {
setStore("tasks", t => [...newTasks, ...t]);
}
}
}
} catch (err) {
console.error("Failed to update subscriptions:", err);
setStore("subscribedBuckets", currentSubscribed);
}
}
};
export const createBucket = async (name: string, color: string, description?: string) => {
try {
const key = normalizeContextKey(name);
await pb.collection(CONTEXTS_COLLECTION).create({
key,
displayName: name,
kind: "bucket",
policy: "handoff",
color,
description
});
toast.success("Bucket created");
} catch (err) {
console.error("Failed to create bucket:", err);
toast.error("Failed to create bucket");
}
};
export const updateBucket = async (id: string, data: { name?: string, color?: string, description?: string, policy?: "collaborative" | "handoff" }) => {
try {
const payload: any = { ...data };
if (data.name) payload.key = normalizeContextKey(data.name);
if (data.name) payload.displayName = data.name;
await pb.collection(CONTEXTS_COLLECTION).update(id, payload);
toast.success("Bucket updated");
} catch (err) {
console.error("Failed to update bucket:", err);
toast.error("Failed to update bucket");
}
};
export const deleteBucket = async (id: string) => {
try {
await pb.collection(CONTEXTS_COLLECTION).update(id, {
deletedAt: new Date().toISOString(),
deletedBy: pb.authStore.model?.id || null
});
toast.success("Bucket moved to trash");
} catch (err) {
console.error("Failed to delete bucket:", err);
toast.error("Failed to delete bucket");
}
};
export const updateUserContextPolicy = async (contextId: string, policy: "collaborative" | "handoff") => {
const success = await setContextPolicy(contextId, policy);
if (success) {
toast.success("User context updated");
}
};
export const setContextPolicy = async (contextId: string, policy: "collaborative" | "handoff") => {
const previousContext = store.contexts.find(context => context.id === contextId);
const previousPolicy = previousContext?.policy;
if (previousContext && previousPolicy !== policy) {
setStore("contexts", context => context.id === contextId, "policy", policy);
setStore("shareRules", store.contexts.map(mapContextToShareRule));
}
try {
await pb.collection(CONTEXTS_COLLECTION).update(contextId, { policy }, { requestKey: null });
return true;
} catch (err) {
console.error("Failed to update user context:", err);
if (previousContext && previousPolicy) {
setStore("contexts", context => context.id === contextId, "policy", previousPolicy);
setStore("shareRules", store.contexts.map(mapContextToShareRule));
}
toast.error("Failed to update user context");
return false;
}
};
export const setNoteContextPolicy = async (noteId: string, policy: "collaborative" | "handoff") => {
if (!pb.authStore.isValid || !noteId) return false;
const note = store.notes.find(existing => existing.id === noteId);
if (!note) return false;
const baseTags = (note.tags || []).filter(tag => tag !== NOTE_HANDOFF_POLICY_TAG);
const nextTags = policy === "handoff" ? [...baseTags, NOTE_HANDOFF_POLICY_TAG] : baseTags;
const previousTags = note.tags || [];
setStore("notes", n => n.id === noteId, "tags", nextTags);
try {
await pb.collection(NOTES_COLLECTION).update(noteId, { tags: nextTags }, { requestKey: null });
return true;
} catch (err) {
console.error("Failed to update note context policy:", err);
setStore("notes", n => n.id === noteId, "tags", previousTags);
toast.error("Failed to update note sharing mode");
return false;
}
};
export const savePersonalContextSupervisors = async (supervisorUserIds: string[]) => {
const userId = pb.authStore.model?.id;
if (!userId) return;
const uniqueSupervisorIds = [...new Set(supervisorUserIds.filter(Boolean))];
setStore("personalContextSupervisorIds", uniqueSupervisorIds);
try {
await syncUserContexts();
const personalContext = getPersonalContext(userId);
if (!personalContext) {
throw new Error("Missing personal context record");
}
await pb.collection(CONTEXTS_COLLECTION).update(personalContext.id, {
supervisorUserIds: uniqueSupervisorIds
}, { requestKey: null });
const user = await pb.collection("users").getOne(userId, { requestKey: null });
const rawPrefs = user.Taskgrid_pref || {};
const scopedPrefs = readScopedPrefs(rawPrefs);
await pb.collection("users").update(userId, {
Taskgrid_pref: writeScopedPrefs(rawPrefs, {
...scopedPrefs,
supervisorUserIds: uniqueSupervisorIds
})
}, { requestKey: null });
await syncSupervisedContexts();
toast.success("Personal supervision updated");
} catch (err) {
console.error("Failed to update personal supervision:", err);
toast.error("Failed to update personal supervision");
}
};
export const loadShareRules = async () => {
if (!pb.authStore.isValid) return;
setStore("shareRules", reconcile(store.contexts.map(mapContextToShareRule)));
};
export const addShareRule = async (type: "tag" | "all", targetUserId: string, tagName?: string, share_mode: "ADD_USER" | "SEND_TASK" = "SEND_TASK") => {
if (!pb.authStore.isValid) return;
if (type !== "tag" || !tagName?.startsWith("@")) {
toast.error("Only @tag contexts are supported now.");
return;
}
const existing = store.contexts.find(context => context.targetUserId === targetUserId && buildShareTag(context.displayName) === tagName);
if (existing) return;
try {
await pb.collection(CONTEXTS_COLLECTION).create({
key: normalizeContextKey(tagName),
displayName: tagName.replace(/^@/, ""),
kind: "user",
policy: share_mode === "SEND_TASK" ? "handoff" : "collaborative",
targetUserId
}, { requestKey: null });
toast.success("Shared context created");
} catch (err) {
console.error("Failed to create shared context:", err);
toast.error("Failed to create shared context.");
}
};
export const updateShareRule = async (ruleId: string, updates: Partial<ShareRule>) => {
if (!pb.authStore.isValid) return;
try {
const context = store.contexts.find(existing => existing.id === ruleId);
if (!context) return;
await pb.collection(CONTEXTS_COLLECTION).update(ruleId, {
policy: updates.share_mode === "SEND_TASK" ? "handoff" : "collaborative"
}, { requestKey: null });
toast.success("Context updated");
} catch (err) {
console.error("Failed to update share rule:", err);
toast.error("Failed to update share rule.");
}
};
export const removeShareRule = async (ruleId: string) => {
if (!pb.authStore.isValid) return;
try {
await pb.collection(CONTEXTS_COLLECTION).delete(ruleId);
toast.success("Context removed");
} catch (err) {
console.error("Failed to delete share rule:", err);
toast.error("Failed to delete share rule.");
}
};
export const syncSystemTagsAndRules = async () => {
if (!pb.authStore.isValid) return;
setStore("shareRules", store.contexts.map(mapContextToShareRule));
setStore("tagDefinitions", defs => {
const plainDefs = defs.filter(def => !def.name.startsWith("@"));
return [...plainDefs, ...deriveSystemTagDefinitions(store.contexts)];
});
};
+6 -2380
View File
File diff suppressed because it is too large Load Diff
+365
View File
@@ -0,0 +1,365 @@
import { toast } from "solid-sonner";
import { pb } from "@/lib/pocketbase";
import { NOTES_COLLECTION, TASGRID_COLLECTION } from "@/lib/constants";
import { buildNoteTag, normalizeNoteKey } from "@/lib/tags";
import { relationFilter } from "./contexts";
import {
activeNoteId,
setActiveNoteId,
type Note,
type Task,
setStore,
store,
unwrapRelationId
} from "./state";
import { getTaskListFields, mapRecordToTask, updateTask } from "./tasks";
let notesLoadPromise: Promise<void> | null = null;
let deferredNotesLoadTimer: number | undefined;
export const loadedNoteDetailIds = new Set<string>();
export const failedNoteDetailIds = new Set<string>();
export const mapRecordToNote = (r: any, includeContent = true): Note => {
return {
id: r.id,
title: r.title,
key: r.key || normalizeNoteKey(r.title),
content: includeContent ? r.content : undefined,
tags: Array.isArray(r.tags) && r.tags.length > 0 ? r.tags : [buildNoteTag(r.key || r.title)],
isPrivate: r.isPrivate || false,
user: unwrapRelationId(r.user) || "",
tasks: r.tasks || [],
created: r.created,
updated: r.updated,
deletedAt: r.deletedAt ? new Date(r.deletedAt).getTime() : undefined
};
};
const reconcileFetchedNotes = (records: any[], includeContent = false) => {
const existingById = new Map(store.notes.map(note => [note.id, note]));
const nextNotes = records.map((record: any) => {
const mapped = mapRecordToNote(record, includeContent);
const existing = existingById.get(mapped.id);
if (!includeContent && existing?.content !== undefined) {
mapped.content = existing.content;
}
return mapped;
});
setStore("notes", nextNotes);
};
export const fetchNoteById = async (id: string): Promise<Note | null> => {
try {
const record = await pb.collection(NOTES_COLLECTION).getOne(id);
if (!record) return null;
return mapRecordToNote(record);
} catch (err) {
console.error("Failed to fetch note by ID:", err);
return null;
}
};
export const updateStoreWithNote = (note: Note) => {
failedNoteDetailIds.delete(note.id);
if (note.content !== undefined) {
loadedNoteDetailIds.add(note.id);
}
setStore("notes", currentNotes => {
const index = currentNotes.findIndex(n => n.id === note.id);
if (index !== -1) {
const newNotes = [...currentNotes];
newNotes[index] = note;
return newNotes;
}
return [...currentNotes, note];
});
};
export const loadNotesMetadata = async () => {
if (!pb.authStore.isValid) return;
if (store.hasLoadedNotesMetadata) return;
if (notesLoadPromise) return notesLoadPromise;
const currentUserId = pb.authStore.model?.id;
setStore("isNotesLoading", true);
notesLoadPromise = (async () => {
try {
const notesRecords = await pb.collection(NOTES_COLLECTION).getFullList({
filter: `isPrivate = false || ${relationFilter("user", currentUserId)}`,
sort: "-created",
fields: "id,created,updated,title,key,tags,isPrivate,user,tasks,deletedAt",
requestKey: null
});
reconcileFetchedNotes(notesRecords, false);
setStore("hasLoadedNotesMetadata", true);
} catch (notesErr) {
console.warn("Failed to load notes (collection might not exist yet):", notesErr);
} finally {
setStore("isNotesLoading", false);
notesLoadPromise = null;
}
})();
return notesLoadPromise;
};
export const scheduleDeferredNotesMetadataLoad = () => {
if (!pb.authStore.isValid || store.hasLoadedNotesMetadata || notesLoadPromise || deferredNotesLoadTimer) return;
deferredNotesLoadTimer = window.setTimeout(() => {
deferredNotesLoadTimer = undefined;
void loadNotesMetadata();
}, 0);
};
export const requestImmediateNotesMetadataLoad = async () => {
if (deferredNotesLoadTimer) {
clearTimeout(deferredNotesLoadTimer);
deferredNotesLoadTimer = undefined;
}
await loadNotesMetadata();
};
export const loadNoteContent = async (id: string) => {
if (!id) return;
if (loadedNoteDetailIds.has(id)) return;
if (failedNoteDetailIds.has(id)) return;
if (store.loadingNoteContentIds.includes(id)) return;
setStore("loadingNoteContentIds", ids => ids.includes(id) ? ids : [...ids, id]);
try {
const currentUserId = pb.authStore.model?.id;
const visibilityFilter = `id = "${id}" && (isPrivate = false || ${relationFilter("user", currentUserId)})`;
const record = await pb.collection(NOTES_COLLECTION).getFirstListItem(visibilityFilter, {
requestKey: `note-content-${id}`
});
const note = mapRecordToNote(record, true);
updateStoreWithNote(note);
loadedNoteDetailIds.add(id);
} catch (err) {
const status = (err as any)?.status;
if (status === 404) {
failedNoteDetailIds.add(id);
return;
}
console.error("Failed to load note content:", err);
} finally {
setStore("loadingNoteContentIds", ids => ids.filter(existingId => existingId !== id));
}
};
export const uploadNoteAttachment = async (noteId: string, file: File): Promise<{ url: string, filename: string }> => {
try {
const formData = new FormData();
formData.append("attachments+", file);
const record = await pb.collection(NOTES_COLLECTION).update(noteId, formData);
if (record.attachments && record.attachments.length > 0) {
const latestFilename = record.attachments[record.attachments.length - 1];
return {
url: pb.files.getURL(record, latestFilename),
filename: latestFilename
};
}
throw new Error("File uploaded but no attachment returned");
} catch (err: any) {
console.error("Error uploading note attachment. Validation details:", JSON.stringify(err.data, null, 2));
toast.error("Failed to upload image to note.");
throw err;
}
};
export const cleanupNoteAttachments = async (noteId: string) => {
if (!pb.authStore.isValid) return;
try {
const record = await pb.collection(NOTES_COLLECTION).getOne(noteId);
if (!record.attachments || record.attachments.length === 0) return;
const content = record.content || "";
const filesToDelete: string[] = [];
const decodedContent = decodeURIComponent(content);
for (const filename of record.attachments) {
if (!content.includes(filename) && !decodedContent.includes(filename)) {
filesToDelete.push(filename);
}
}
if (filesToDelete.length > 0) {
await pb.collection(NOTES_COLLECTION).update(noteId, {
"attachments-": filesToDelete
});
console.log(`[CLEANUP] Deleted ${filesToDelete.length} orphaned attachment(s) for note ${noteId}`);
}
} catch (err) {
if ((err as any)?.status === 404) return;
console.error("[CLEANUP] Failed to clean up orphaned note attachments:", err);
}
};
const removeDeletedNoteFromStore = (id: string) => {
if (activeNoteId() === id) {
setActiveNoteId(null);
}
loadedNoteDetailIds.delete(id);
failedNoteDetailIds.delete(id);
setStore("loadingNoteContentIds", ids => ids.filter(existingId => existingId !== id));
setStore("notes", notes => notes.filter(note => note.id !== id));
};
const removeNoteReferencesFromTask = (task: Task, note: Note): Partial<Task> | null => {
const noteKey = (note.key || normalizeNoteKey(note.title)).toLowerCase();
const noteTag = buildNoteTag(note.key || note.title).toLowerCase();
const nextNoteRefs = task.noteRefs.filter(ref =>
ref.noteId !== note.id &&
ref.key.toLowerCase() !== noteKey
);
const nextShareRefs = task.shareRefs.filter(ref =>
!(ref.kind === "note" && (ref.contextId === note.id || ref.key.toLowerCase() === noteKey))
);
const nextTags = (task.tags || []).filter(tag => tag.toLowerCase() !== noteTag);
const noteRefsChanged = nextNoteRefs.length !== task.noteRefs.length;
const shareRefsChanged = nextShareRefs.length !== task.shareRefs.length;
const tagsChanged = nextTags.length !== (task.tags || []).length;
if (!noteRefsChanged && !shareRefsChanged && !tagsChanged) {
return null;
}
return {
tags: nextTags,
noteRefs: nextNoteRefs,
shareRefs: nextShareRefs
};
};
const cleanupTasksForPermanentlyDeletedNote = async (note: Note) => {
const taskRecords = await pb.collection(TASGRID_COLLECTION).getFullList({
fields: getTaskListFields(),
requestKey: null
});
const affectedTasks = taskRecords
.filter((record: any) => !record.tags?.includes("__template__"))
.map((record: any) => mapRecordToTask(record))
.filter(task => !!removeNoteReferencesFromTask(task, note));
for (const task of affectedTasks) {
const cleanup = removeNoteReferencesFromTask(task, note);
if (!cleanup) continue;
await updateTask(task.id, cleanup);
}
};
export const updateNote = async (id: string, updates: Partial<Note>) => {
if (!pb.authStore.isValid) return;
if (updates.content !== undefined) {
loadedNoteDetailIds.add(id);
}
const optimisticUpdates = {
...updates,
key: updates.key || (updates.title ? normalizeNoteKey(updates.title) : undefined),
updated: new Date().toISOString()
};
setStore("notes", n => n.id === id, optimisticUpdates);
try {
const pbUpdates: any = { ...updates };
if ("deletedAt" in updates) {
if (updates.deletedAt) {
pbUpdates.deletedAt = new Date(updates.deletedAt).toISOString();
} else {
pbUpdates.deletedAt = null;
}
}
if (!pbUpdates.key && updates.title) {
pbUpdates.key = normalizeNoteKey(updates.title);
}
await pb.collection(NOTES_COLLECTION).update(id, pbUpdates, { requestKey: null });
} catch (err) {
console.error("Note update failed", err);
toast.error("Failed to update note.");
}
};
export const removeNote = (id: string) => {
updateNote(id, { deletedAt: Date.now() });
};
export const restoreNote = (id: string) => {
updateNote(id, { deletedAt: null });
};
export const deleteNotePermanently = async (id: string) => {
if (!pb.authStore.isValid) return;
const note = store.notes.find(existing => existing.id === id) || await fetchNoteById(id);
if (note) {
await cleanupTasksForPermanentlyDeletedNote(note);
}
removeDeletedNoteFromStore(id);
try {
await pb.collection(NOTES_COLLECTION).delete(id);
} catch (err) {
console.error("Note delete failed", err);
toast.error("Failed to delete note.");
}
};
export const loadTasksLinkedToNote = async (note: Pick<Note, "id" | "key" | "title" | "tasks">) => {
if (!pb.authStore.isValid || !note?.id) return;
const noteTag = buildNoteTag(note.key || note.title);
const escapedNoteTag = noteTag.replace(/"/g, '\\"');
try {
const listRecords = await pb.collection(TASGRID_COLLECTION).getFullList({
filter: `(tags ~ "${escapedNoteTag}") && tags !~ "__template__"`,
sort: "-updated",
fields: getTaskListFields(),
requestKey: `note-linked-${note.id}`
}).catch(() => []);
const recordMap = new Map<string, any>();
listRecords.forEach(record => {
if (!record?.id) return;
if (record.tags?.includes("__template__")) return;
recordMap.set(record.id, record);
});
if (recordMap.size === 0) return;
setStore("tasks", currentTasks => {
const taskMap = new Map(currentTasks.map(task => [task.id, task]));
recordMap.forEach((record: any) => {
const mapped = mapRecordToTask(record);
const existing = taskMap.get(mapped.id);
if (existing?.content) {
mapped.content = existing.content;
}
taskMap.set(mapped.id, mapped);
});
return Array.from(taskMap.values());
});
} catch (err: any) {
console.error("Failed to load note-linked tasks:", err?.status, err?.response || err);
}
};
+183
View File
@@ -0,0 +1,183 @@
import { pb } from "@/lib/pocketbase";
import { toast } from "solid-sonner";
import {
createDefaultFilter,
type Filter,
type FilterTemplate,
readScopedPrefs,
setStore,
store,
writeScopedPrefs
} from "./state";
export const syncPreferences = async () => {
const userId = pb.authStore.model?.id;
if (!userId) return;
try {
const user = await pb.collection("users").getOne(userId, { requestKey: null });
const rawPrefs = user.Taskgrid_pref || {};
const prefs = {
pWeight: store.pWeight,
uWeight: store.uWeight,
matrixScaleDays: store.matrixScaleDays,
tagDefinitions: store.tagDefinitions.filter(def => !def.name.startsWith("@")),
filterTemplates: store.filterTemplates,
quickloadTasks: store.quickloadTasks,
dismissedTaskUpdateIndicators: store.dismissedTaskUpdateIndicators,
collapsedUntilUpdatedTasks: store.collapsedUntilUpdatedTasks,
subscribedBuckets: store.subscribedBuckets,
supervisorUserIds: store.personalContextSupervisorIds,
aiHelpTipSectionIndex: store.aiHelpTipSectionIndex,
lastCompletedTaskCleanupDate: store.lastCompletedTaskCleanupDate
};
await pb.collection("users").update(userId, {
Taskgrid_pref: writeScopedPrefs(rawPrefs, {
...readScopedPrefs(rawPrefs),
...prefs
})
}, { requestKey: null });
} catch (e: any) {
if (e.isAbort) return;
console.error("Failed to sync preferences", e);
}
};
export const setFilter = (update: Partial<Filter>) => {
setStore("filter", f => ({ ...f, ...update }));
};
export const loadFilterTemplates = async () => {
if (!pb.authStore.isValid) return;
const userId = pb.authStore.model?.id;
if (!userId) return;
const user = await pb.collection("users").getOne(userId, { requestKey: null });
const prefs = readScopedPrefs(user.Taskgrid_pref || {});
setStore("filterTemplates", prefs.filterTemplates || []);
};
export const saveFilterTemplate = async (name: string) => {
if (!pb.authStore.isValid) return;
try {
const newTemplate: FilterTemplate = {
id: `filter-${Date.now()}`,
name,
filter: store.filter
};
setStore("filterTemplates", prev => [...prev, newTemplate]);
await syncPreferences();
toast.success(`Filter template "${name}" saved`);
} catch (err) {
console.error("Failed to save filter template:", err);
toast.error("Failed to save filter template.");
}
};
export const applyFilterTemplate = (id: string) => {
const template = store.filterTemplates.find(t => t.id === id);
if (template) {
setFilter(template.filter);
toast.success(`Applied filter: ${template.name}`);
}
};
export const removeFilterTemplate = async (id: string) => {
if (!pb.authStore.isValid) return;
try {
setStore("filterTemplates", prev => prev.filter(t => t.id !== id));
await syncPreferences();
toast.success("Filter template deleted");
} catch (err) {
console.error("Failed to delete filter template:", err);
toast.error("Failed to delete filter template.");
}
};
export const clearFilter = () => {
setStore("filter", createDefaultFilter());
};
export const setNoteFilter = (update: Partial<Filter>) => {
setStore("noteFilter", f => ({ ...f, ...update }));
};
export const loadNoteFilterTemplates = async () => {
if (!pb.authStore.isValid) return;
const userId = pb.authStore.model?.id;
if (!userId) return;
const user = await pb.collection("users").getOne(userId, { requestKey: null });
const prefs = readScopedPrefs(user.Taskgrid_pref || {});
setStore("filterTemplates", prefs.noteFilterTemplates || []);
};
export const saveNoteFilterTemplate = async (name: string) => {
if (!pb.authStore.isValid) return;
try {
const newTemplate: FilterTemplate = {
id: `note-filter-${Date.now()}`,
name,
filter: store.noteFilter
};
setStore("filterTemplates", prev => [...prev, newTemplate]);
const userId = pb.authStore.model?.id;
if (userId) {
const user = await pb.collection("users").getOne(userId, { requestKey: null });
const rawPrefs = user.Taskgrid_pref || {};
const prefs = readScopedPrefs(rawPrefs);
await pb.collection("users").update(userId, {
Taskgrid_pref: writeScopedPrefs(rawPrefs, {
...prefs,
noteFilterTemplates: [...(prefs.noteFilterTemplates || []), newTemplate]
})
}, { requestKey: null });
}
toast.success(`Note filter template "${name}" saved`);
} catch (err) {
console.error("Failed to save note filter template:", err);
toast.error("Failed to save note filter template.");
}
};
export const applyNoteFilterTemplate = (id: string) => {
const template = store.filterTemplates.find(t => t.id === id);
if (template) {
setNoteFilter(template.filter);
toast.success(`Applied note filter: ${template.name}`);
}
};
export const removeNoteFilterTemplate = async (id: string) => {
if (!pb.authStore.isValid) return;
try {
setStore("filterTemplates", prev => prev.filter(t => t.id !== id));
const userId = pb.authStore.model?.id;
if (userId) {
const user = await pb.collection("users").getOne(userId, { requestKey: null });
const rawPrefs = user.Taskgrid_pref || {};
const prefs = readScopedPrefs(rawPrefs);
await pb.collection("users").update(userId, {
Taskgrid_pref: writeScopedPrefs(rawPrefs, {
...prefs,
noteFilterTemplates: (prefs.noteFilterTemplates || []).filter((template: FilterTemplate) => template.id !== id)
})
}, { requestKey: null });
}
toast.success("Note filter template deleted");
} catch (err) {
console.error("Failed to delete note filter template:", err);
toast.error("Failed to delete note filter template.");
}
};
export const clearNoteFilter = () => {
setStore("noteFilter", createDefaultFilter());
};
export const advanceAIHelpTipSectionIndex = async () => {
setStore("aiHelpTipSectionIndex", prev => prev + 1);
await syncPreferences();
};
+356
View File
@@ -0,0 +1,356 @@
import { createSignal } from "solid-js";
import { createStore } from "solid-js/store";
import { pb } from "@/lib/pocketbase";
import { STORAGE_KEY_PREFIX, TASGRID_DATA_MODE } from "@/lib/app-config";
// Helper to get the current user's personalized favorite tag
export const getFavoriteTag = () => {
const userId = pb.authStore.model?.id;
return userId ? `__${userId}_favorite__` : "__favorite__";
};
export const [now, setNow] = createSignal(Date.now());
// Context: 'mine' is a temporary bootstrap alias for the current user's personal context bucket.
export type TaskContext = "mine" | { userId: string, name: string } | { bucketId: string, name: string, isPersonal?: boolean };
export const [activeTaskId, setActiveTaskId] = createSignal<string | null>(null);
export const [activeNoteId, setActiveNoteId] = createSignal<string | null>(null);
export const [currentTaskContext, setCurrentTaskContext] = createSignal<TaskContext>("mine");
export type UrgencyLevel = number;
export interface TaskShareRef {
contextId: string;
key: string;
kind: "user" | "bucket" | "note";
policy?: "collaborative" | "handoff";
}
export interface NoteRef {
noteId: string;
key: string;
}
export interface Task {
id: string;
title: string;
startDate: string;
dueDate: string;
priority: number;
status: number;
completed: boolean;
tags: string[];
labelTags: string[];
shareRefs: TaskShareRef[];
noteRefs: NoteRef[];
ownerId: string | null;
createdBy: string | null;
updatedBy?: string | null;
content?: string;
deletedAt?: number | null;
created: string;
updated: string;
recurrence?: {
type: "daily" | "weekly" | "monthly";
days?: number[];
dayOfMonth?: number;
lastUncompleted?: string;
};
size?: number;
sharedWith?: Array<{ userId: string; access: "view" | "edit" }>;
attachments?: string[];
}
export interface TaskTemplate {
id: string;
name: string;
title: string;
priority: number;
urgency: number;
tags: string[];
content: string;
}
export interface TagDefinition {
id: string;
name: string;
value: number;
color?: string;
theme?: "light" | "dark";
isUser?: boolean;
isBucket?: boolean;
}
export interface Note {
id: string;
title: string;
key: string;
content?: string;
tags: string[];
isPrivate: boolean;
user: string;
tasks: string[];
created: string;
updated: string;
deletedAt?: number | null;
}
export interface FilterTag {
name: string;
excluded: boolean;
}
export interface Filter {
query: string;
tags: FilterTag[];
priorityMin: number;
priorityMax: number;
urgencyMin: number;
urgencyMax: number;
editedToday: boolean;
ownedByMe: boolean;
starred: boolean;
jobStatus: FilterTag[];
jobDivision: FilterTag[];
}
export interface FilterTemplate {
id: string;
name: string;
filter: Filter;
}
export interface ShareContext {
id: string;
key: string;
displayName: string;
kind: "user" | "bucket" | "note";
policy: "collaborative" | "handoff";
targetUserId?: string | null;
supervisorUserIds?: string[];
color?: string;
description?: string;
deletedAt?: number | null;
deletedBy?: string | null;
}
export interface ShareRule {
id: string;
ownerId: string;
targetUserId: string;
type: "tag" | "all";
tagName?: string;
share_mode?: "ADD_USER" | "SEND_TASK";
}
export interface SupervisedContextAccess {
ownerUserId: string;
ownerName: string;
contextId: string;
contextName: string;
}
export interface Bucket {
id: string;
name: string;
color: string;
description?: string;
policy?: "collaborative" | "handoff";
key?: string;
deletedAt?: number | null;
}
export interface TaskStore {
tasks: Task[];
isInitializing: boolean;
quickloadFadeTaskIds: string[];
dismissedTaskUpdateIndicators: Record<string, string>;
collapsedUntilUpdatedTasks: Record<string, string>;
isNotesLoading: boolean;
hasLoadedNotesMetadata: boolean;
loadingNoteContentIds: string[];
pWeight: number;
uWeight: number;
matrixScaleDays: number;
prefId?: string;
tagDefinitions: TagDefinition[];
templates: TaskTemplate[];
filter: Filter;
shareRules: ShareRule[];
contexts: ShareContext[];
supervisedContexts: SupervisedContextAccess[];
filterTemplates: FilterTemplate[];
buckets: Bucket[];
subscribedBuckets: string[];
personalContextSupervisorIds: string[];
aiHelpTipSectionIndex: number;
notes: Note[];
isNotepadMode: boolean;
quickloadTasks: string[];
noteFilter: Filter;
lastCompletedTaskCleanupDate: string;
}
export interface ScopedTaskgridPrefs {
pWeight?: number;
uWeight?: number;
matrixScaleDays?: number;
tagDefinitions?: TagDefinition[];
filter?: Filter;
filterTemplates?: FilterTemplate[];
noteFilterTemplates?: FilterTemplate[];
quickloadTasks?: string[];
noteFilter?: Filter;
subscribedBuckets?: string[];
supervisorUserIds?: string[];
aiHelpTipSectionIndex?: number;
dismissedTaskUpdateIndicators?: Record<string, string>;
collapsedUntilUpdatedTasks?: Record<string, string>;
migratedStructuredTagsV2?: boolean;
migratedPersonalContextRefsV1?: boolean;
migratedContextOwnershipV2?: boolean;
lastCompletedTaskCleanupDate?: string;
}
export const createDefaultFilter = (): Filter => ({
query: "",
tags: [],
priorityMin: 1,
priorityMax: 10,
urgencyMin: 1,
urgencyMax: 10,
editedToday: false,
ownedByMe: false,
starred: false,
jobStatus: [],
jobDivision: []
});
export const [store, setStore] = createStore<TaskStore>({
tasks: [],
isInitializing: false,
quickloadFadeTaskIds: [],
dismissedTaskUpdateIndicators: {},
collapsedUntilUpdatedTasks: {},
isNotesLoading: false,
hasLoadedNotesMetadata: false,
loadingNoteContentIds: [],
pWeight: 1.0,
uWeight: 1.0,
matrixScaleDays: 30,
tagDefinitions: [],
templates: [],
filter: createDefaultFilter(),
shareRules: [],
contexts: [],
supervisedContexts: [],
filterTemplates: [],
buckets: [],
subscribedBuckets: [],
personalContextSupervisorIds: [],
aiHelpTipSectionIndex: 0,
notes: [],
isNotepadMode: false,
quickloadTasks: [],
lastCompletedTaskCleanupDate: "",
noteFilter: createDefaultFilter()
});
const PREF_ENVIRONMENTS_KEY = "environments";
export const getStorageKey = () => {
const userId = pb.authStore.model?.id;
return userId ? `${STORAGE_KEY_PREFIX}_data_${userId}` : null;
};
export const getPersistedStoreSnapshot = () => {
const { isInitializing, quickloadFadeTaskIds, isNotesLoading, hasLoadedNotesMetadata, loadingNoteContentIds, ...persisted } = store;
return persisted;
};
export const readScopedPrefs = (rawPrefs: any): ScopedTaskgridPrefs => {
const scoped = rawPrefs?.[PREF_ENVIRONMENTS_KEY]?.[TASGRID_DATA_MODE];
return (scoped || rawPrefs || {}) as ScopedTaskgridPrefs;
};
export const writeScopedPrefs = (rawPrefs: any, scopedPrefs: ScopedTaskgridPrefs) => ({
...(rawPrefs || {}),
[PREF_ENVIRONMENTS_KEY]: {
...(rawPrefs?.[PREF_ENVIRONMENTS_KEY] || {}),
[TASGRID_DATA_MODE]: scopedPrefs
}
});
export const applyScopedPrefsToStore = (prefs: ScopedTaskgridPrefs) => {
const currentUserId = pb.authStore.model?.id;
const personalContext = currentUserId
? store.contexts.find(context =>
!context.deletedAt &&
context.kind === "user" &&
context.targetUserId === currentUserId
) || null
: null;
setStore({
pWeight: prefs.pWeight || 1.0,
uWeight: prefs.uWeight || 1.0,
matrixScaleDays: prefs.matrixScaleDays || 30,
prefId: currentUserId,
subscribedBuckets: prefs.subscribedBuckets || [],
personalContextSupervisorIds: personalContext?.supervisorUserIds || prefs.supervisorUserIds || [],
aiHelpTipSectionIndex: typeof prefs.aiHelpTipSectionIndex === "number" ? prefs.aiHelpTipSectionIndex : 0,
tagDefinitions: (prefs.tagDefinitions || []).filter((def: TagDefinition) => !def.name.startsWith("@")),
filterTemplates: prefs.filterTemplates || [],
quickloadTasks: prefs.quickloadTasks || [],
dismissedTaskUpdateIndicators: prefs.dismissedTaskUpdateIndicators || {},
collapsedUntilUpdatedTasks: prefs.collapsedUntilUpdatedTasks || {},
noteFilter: store.noteFilter,
filter: store.filter
});
};
export const unwrapRelationId = (value: unknown): string | null => {
if (typeof value === "string") {
return value || null;
}
if (Array.isArray(value)) {
const first = value[0];
return typeof first === "string" ? first : null;
}
return null;
};
export const unwrapRelationIds = (value: unknown): string[] => {
if (Array.isArray(value)) {
return value
.map(item => {
if (typeof item === "string") return item;
if (item && typeof item === "object" && "id" in item && typeof (item as any).id === "string") {
return (item as any).id;
}
return null;
})
.filter((item): item is string => !!item);
}
if (typeof value === "string") {
const trimmed = value.trim();
if (!trimmed) return [];
try {
const parsed = JSON.parse(trimmed);
return unwrapRelationIds(parsed);
} catch {
return trimmed.includes(",")
? trimmed.split(",").map(item => item.trim()).filter(Boolean)
: [trimmed];
}
}
if (value && typeof value === "object" && "id" in value && typeof (value as any).id === "string") {
return [(value as any).id];
}
return [];
};
+1219
View File
File diff suppressed because it is too large Load Diff
+10
View File
@@ -0,0 +1,10 @@
import { type Component } from "solid-js";
import { AIHelpPanel } from "@/components/AIHelpPanel";
export const AIHelpView: Component = () => {
return (
<div class="h-full min-h-0 w-full max-w-5xl mx-auto md:py-6 md:px-0">
<AIHelpPanel variant="page" class="h-full" />
</div>
);
};
+5 -7
View File
@@ -1,8 +1,8 @@
import { type Component, For, createMemo, onMount } from "solid-js";
import { type Component, For, createMemo } from "solid-js";
import { store, getCombinedScore, matchesFilter } from "@/store";
import { TaskCard } from "@/components/TaskCard";
import { useDelayedSort } from "@/hooks/useDelayedSort";
import autoAnimate from "@formkit/auto-animate";
import { useTaskListAutoAnimate } from "@/hooks/useTaskListAutoAnimate";
export const CriticalView: Component = () => {
const sourceTasks = createMemo(() => store.tasks.filter(t => !t.deletedAt && matchesFilter(t)));
@@ -17,9 +17,7 @@ export const CriticalView: Component = () => {
);
let listRef: HTMLDivElement | undefined;
onMount(() => {
if (listRef) autoAnimate(listRef, { duration: 300, easing: 'ease-out' });
});
useTaskListAutoAnimate(() => listRef);
return (
<div class="space-y-6">
@@ -28,10 +26,10 @@ export const CriticalView: Component = () => {
<p class="text-muted-foreground mt-1 text-lg">Your tasks ranked by highest urgency + priority.</p>
</header>
<div class="grid gap-3" ref={listRef}>
<div class="grid grid-cols-1 gap-3 w-full" ref={listRef}>
<For each={displayedTasks()}>
{(task) => (
<div class="animate-task-enter">
<div class="w-full min-w-0">
<TaskCard task={task} isShaking={shakingTaskIds().includes(task.id)} />
</div>
)}
+5 -7
View File
@@ -1,8 +1,8 @@
import { type Component, For, createMemo, onMount } from "solid-js";
import { type Component, For, createMemo } from "solid-js";
import { store, getCombinedScore, matchesFilter } from "@/store";
import { TaskCard } from "@/components/TaskCard";
import { useDelayedSort } from "@/hooks/useDelayedSort";
import autoAnimate from "@formkit/auto-animate";
import { useTaskListAutoAnimate } from "@/hooks/useTaskListAutoAnimate";
export const DigInView: Component = () => {
const sourceTasks = createMemo(() => store.tasks.filter(t => !t.deletedAt && matchesFilter(t)));
@@ -22,9 +22,7 @@ export const DigInView: Component = () => {
);
let listRef: HTMLDivElement | undefined;
onMount(() => {
if (listRef) autoAnimate(listRef, { duration: 300, easing: 'ease-out' });
});
useTaskListAutoAnimate(() => listRef);
return (
<div class="space-y-6">
@@ -33,10 +31,10 @@ export const DigInView: Component = () => {
<p class="text-muted-foreground mt-1 text-lg">Largest tasks first. Tackle big projects head-on.</p>
</header>
<div class="grid gap-3" ref={listRef}>
<div class="grid grid-cols-1 gap-3 w-full" ref={listRef}>
<For each={displayedTasks()}>
{(task) => (
<div class="animate-task-enter">
<div class="w-full min-w-0">
<TaskCard task={task} isShaking={shakingTaskIds().includes(task.id)} />
</div>
)}
+210 -269
View File
@@ -1,277 +1,218 @@
import { type Component, Show } from "solid-js";
import { ArrowLeft, ExternalLink, HelpCircle, Zap, ShieldCheck, ListTodo, LayoutDashboard, Snowflake, Pickaxe, Clock, ArrowUpCircle, Search, Palette, Trash2, Smartphone, BarChart3 } from "lucide-solid";
import { type Component, For, Show, onCleanup, onMount } from "solid-js";
import { ArrowLeft, BookOpen, Boxes, ExternalLink, HelpCircle, LayoutDashboard, Link2, ListTodo, NotebookText, PencilLine, RefreshCcw, Search, Settings2, Share2, ShieldCheck, Sparkles, WandSparkles, Workflow } from "lucide-solid";
import { Button } from "@/components/ui/button";
interface HelpViewProps {
onBack?: () => void;
}
interface HelpViewProps { onBack?: () => void; }
interface AccessCard { title: string; content: string; detail: string; href?: string; }
interface QuickAction { title: string; steps: string[]; target: string; }
interface RelatedLink { label: string; target: string; }
interface Section { id: string; title: string; icon: any; intro: string; body: string; steps: string[]; reminder: string; warning: string; related: RelatedLink[]; }
interface DeepSection { id: string; title: string; icon: any; intro: string; bullets: string[]; why: string; related: RelatedLink[]; }
const accessCards: AccessCard[] = [
{ title: "Website", content: "tasgrid.ccllc.pro", href: "https://tasgrid.ccllc.pro", detail: "Open TasGrid in your browser or install it as an app on mobile." },
{ title: "Login", content: "Use your Cardoza Construction email", detail: "Your account uses the same email and password you use for Outlook." },
{ title: "What This Guide Is For", content: "Refresh first, then go deeper", detail: "This guide supports the in-person training you already received." }
];
const quickActions: QuickAction[] = [
{ title: "Create a task", target: "create-tasks", steps: ["Open Quick Entry with + or Ctrl/Cmd+K.", "Enter Title, Priority, and Urgency.", "Create the task and let TasGrid keep the active context."] },
{ title: "Edit a task", target: "manage-tasks", steps: ["Click the task to open it.", "Edit status, dates, tags, and notes.", "Use the status circle to update progress or completion."] },
{ title: "Share a task", target: "share-tasks", steps: ["Open the task.", "Add an @person, @bucket, or #note tag.", "Choose Collaborative or Handoff if prompted."] },
{ title: "Move work into a bucket", target: "buckets-supervision", steps: ["Add the bucket's @tag to the task.", "Or create the task while inside that bucket.", "Pin the bucket in Settings if you want it in the switcher."] },
{ title: "Switch to notes", target: "notes", steps: ["Use the Tasks/Notes toggle.", "Open or create a note.", "Work inside it like a project document, not just a comment box."] },
{ title: "Link a task to a note", target: "notes", steps: ["Open the note's Linked Tasks panel.", "Link an existing task or create a new one.", "TasGrid connects it through the note's #tag."] },
{ title: "Find filtered work", target: "finding-work", steps: ["Use search and filters first.", "Switch views if you need a different ranking lens.", "Save the filter if you will reuse it."] },
{ title: "Recover from trash", target: "recovery-trash", steps: ["Open Settings.", "Expand Trash.", "Recover the item before its 7-day window expires."] }
];
const coreSections: Section[] = [
{ id: "create-tasks", title: "Create Tasks", icon: ListTodo, intro: "Use this when you remember the concept but forgot the exact task flow.", body: "Quick Entry is the main capture flow. It keeps scheduling and context consistent for you.", steps: ["Open Quick Entry with the floating plus button or Ctrl/Cmd+K.", "Enter Title, Priority, and Urgency. Add Size, Due Date, tags, and rich details if needed.", "Create the task. TasGrid automatically applies the current context, like a bucket, supervised user, or active note."], reminder: "Urgency and Due Date stay linked, and new tasks inherit the place you are currently working from.", warning: "If you create work inside a bucket, supervised context, or note, TasGrid usually adds that context automatically.", related: [{ label: "How contexts and sharing work", target: "contexts-sharing" }, { label: "How to find work later", target: "finding-work" }] },
{ id: "manage-tasks", title: "Edit and Manage Tasks", icon: PencilLine, intro: "Use this when you know the task exists but forgot where a control lives.", body: "The task detail sheet is the main editing surface for title, status, dates, tags, favorites, and task notes.", steps: ["Click a task to open the detail sheet.", "Edit title, status, priority, urgency, size, due date, tags, favorite state, and the rich text body.", "Use the status circle to move through progress states or mark the task complete."], reminder: "Task cards also support quick actions like status updates, duplicate, and favorite.", warning: "Deleting a task sends it to trash first, so check trash before assuming something is gone forever.", related: [{ label: "Recover from trash", target: "recovery-trash" }, { label: "Templates and recurrence", target: "time-savers" }] },
{ id: "notes", title: "Notes", icon: NotebookText, intro: "Use this when you need to refresh how notes behave as workspaces.", body: "Notes are a full mode inside the app. They can hold rich content, linked tasks, favorites, and public or private visibility.", steps: ["Switch to Notes using the Tasks/Notes toggle.", "Create or open a note from the notes sidebar.", "Use Linked Tasks to connect existing work or create a task directly from the note context."], reminder: "Creating a task from a note usually links it automatically through the note's #tag.", warning: "If you rename a note, TasGrid also updates the matching #note tag definition so linked tasks stay aligned.", related: [{ label: "How notes become project workspaces", target: "notes-project-workspaces" }, { label: "How rich editing works", target: "editor-rich-content" }] },
{ id: "share-tasks", title: "Share Tasks", icon: Share2, intro: "Use this if you remember that sharing exists, but not the current model.", body: "Task sharing is tag-driven. You share by attaching a user, bucket, or note context to the task.", steps: ["Open the task and edit its tags.", "Add an @person, @bucket, or #note context through the tag picker.", "Choose Collaborative or Handoff when TasGrid asks for a sharing mode."], reminder: "The old mental model of a separate share dialog is no longer the main workflow.", warning: "Handoff and Collaborative do very different things. Handoff moves responsibility; Collaborative keeps the task visible in both places.", related: [{ label: "How contexts and sharing work", target: "contexts-sharing" }, { label: "Buckets and supervision", target: "buckets-supervision" }] },
{ id: "buckets-supervision", title: "Buckets and Supervision", icon: Boxes, intro: "Use this if you forgot how to move between your work, team buckets, and supervised contexts.", body: "Contexts control where work lives and who can see it. The same switcher handles My Bucket, pinned shared buckets, and supervised personal contexts.", steps: ["Use the context switcher to move between My Bucket, pinned buckets, and supervised contexts.", "Pin buckets in Settings if you want them in the switcher.", "Create tasks from the context you are already inside so TasGrid applies the right tag automatically."], reminder: "Your own personal context tag, the active bucket tag, and some note tags may stay hidden while still remaining active.", warning: "If a task seems to belong to a bucket or person even though you cannot see the tag, hidden context behavior is often the reason.", related: [{ label: "Share tasks", target: "share-tasks" }, { label: "Deep dive on context behavior", target: "contexts-sharing" }] },
{ id: "finding-work", title: "Views and Finding Work", icon: Search, intro: "Use this when the task exists but you cannot remember how to surface it quickly.", body: "TasGrid gives you multiple sort views plus search and filters so you can find the right work without rebuilding lists by hand.", steps: ["Use search for text and the filter controls for tags, ranges, edited today, mine, or starred.", "Switch between Focus, Matrix, Snowball, Dig In, Urgency, Priority, or Progress depending on the lens you need.", "Save filters you reuse often so you do not have to rebuild them every time."], reminder: "Changing views is often faster than changing the task itself.", warning: "If a task seems missing, first check your current context and filters before assuming it was deleted or moved.", related: [{ label: "How task views think", target: "task-views-think" }, { label: "Templates and saved filters", target: "time-savers" }] },
{ id: "recovery-trash", title: "Recovery and Trash", icon: RefreshCcw, intro: "Use this when you are trying to undo something or confirm whether an item is really gone.", body: "Settings contains separate trash sections for tasks and notes. Items stay there for 7 days before permanent deletion.", steps: ["Open Settings and expand Trash.", "Look in the Tasks or Notes list depending on what you lost.", "Recover the item or permanently delete it if you are sure."], reminder: "Deletion is usually reversible during the trash window, and the app also offers an immediate undo action.", warning: "Expired trash is permanently removed when the app loads, so do not rely on trash as long-term storage.", related: [{ label: "Manage tasks", target: "manage-tasks" }, { label: "How settings change behavior", target: "settings-behavior" }] }
];
const deepSections: DeepSection[] = [
{ id: "task-views-think", title: "How Task Views Think", icon: LayoutDashboard, intro: "Use this when you want to understand why the same task appears differently across views.", bullets: ["Focus, Urgency, Priority, Progress, Snowball, and Dig In all sort the same active task pool by different rules.", "Matrix places incomplete tasks on an urgency-versus-priority grid instead of a list.", "Matrix dots are clickable, size-aware, and show U, S, and P values.", "The Matrix time scale can be adjusted to 1, 7, 30, 60, or 90 days."], why: "If users understand the view's mental model, they stop fighting the sort order and start choosing the right view for the moment.", related: [{ label: "Find work refresher", target: "finding-work" }, { label: "Templates and saved filters", target: "time-savers" }] },
{ id: "contexts-sharing", title: "How Contexts and Sharing Work", icon: Share2, intro: "Use this when the app's access behavior feels smarter or stranger than a normal tag list.", bullets: ["TasGrid turns @user, @bucket, and #note tags into structured relationships, not just labels.", "Each context can behave as Collaborative or Handoff.", "Per-user policies, bucket policies, and supervision access all change how work moves and who can see it.", "Some context tags stay hidden in the current view while still remaining active."], why: "Most sharing confusion comes from treating contexts like plain tags when they actually control access and ownership behavior.", related: [{ label: "Share tasks refresher", target: "share-tasks" }, { label: "Buckets and supervision", target: "buckets-supervision" }] },
{ id: "notes-project-workspaces", title: "How Notes Become Project Workspaces", icon: NotebookText, intro: "Use this when you want to go beyond simple note editing and understand note structure.", bullets: ["Root notes can have child tabs that inherit the parent note's privacy.", "Desktop supports drag-and-drop tab ordering.", "Linked tasks live at the note workspace level, and root notes manage those relationships directly.", "Share links open note content through the embed route."], why: "Once users treat notes like a workspace with tabs and linked tasks, notes become much more useful than static text pages.", related: [{ label: "Notes refresher", target: "notes" }, { label: "How the editor handles rich content", target: "editor-rich-content" }] },
{ id: "editor-rich-content", title: "How the Editor Handles Rich Content", icon: WandSparkles, intro: "Use this when someone remembers that the editor is powerful, but forgot what it can do.", bullets: ["Tasks, notes, and templates all use the same editor.", "Typing / opens slash commands for blocks, lists, checklists, uploads, job files, dividers, and tables.", "The editor supports underline, links, highlight, alignment, resizable tables, @user mentions, and #note mentions.", "Images, videos, and files are handled differently and can be pasted or dragged directly into the editor."], why: "Users trust the note and task body more when they know it can hold real working content, not just plain text.", related: [{ label: "Notes refresher", target: "notes" }, { label: "How notes become project workspaces", target: "notes-project-workspaces" }] },
{ id: "time-savers", title: "How Templates, Filters, and Recurrence Save Time", icon: Workflow, intro: "Use this when the user understands the basics and wants to stop repeating setup work.", bullets: ["Task templates can store reusable titles, priority, urgency, tags, and rich descriptions.", "Task filters and note filters can be saved as reusable templates.", "Load All is useful when older history is not currently loaded into the visible list.", "Recurrence supports daily, weekly, and monthly schedules, and completed recurring tasks reopen automatically."], why: "These features remove repeated setup and reduce the amount of remembering users have to do from scratch.", related: [{ label: "Create tasks refresher", target: "create-tasks" }, { label: "Find work refresher", target: "finding-work" }] },
{ id: "settings-behavior", title: "How Settings Change Behavior", icon: Settings2, intro: "Use this when a user needs to understand how the app can be configured, not just used.", bullets: ["Settings controls theme, tag values and colors, bucket creation and pinning, supervision access, import tools, trash recovery, app updates, and PWA reset.", "Shared Buckets defines which team spaces exist and whether they behave as Collaborative or Handoff.", "Tag Sharing defines how user contexts behave and who can supervise your personal context.", "Import Tasks helps convert pasted lists into structured work with bulk defaults."], why: "Most team-level confusion comes from not realizing that sharing, buckets, and maintenance behavior are defined in Settings.", related: [{ label: "Buckets and supervision", target: "buckets-supervision" }, { label: "How contexts and sharing work", target: "contexts-sharing" }] }
];
const jumpLinks = [
{ label: "Get Unstuck Fast", target: "quick-start" },
{ label: "Create Tasks", target: "create-tasks" },
{ label: "Share Tasks", target: "share-tasks" },
{ label: "Notes", target: "notes" },
{ label: "Find Work", target: "finding-work" },
{ label: "Deep Dive", target: "task-views-think" }
];
const RelatedTopics: Component<{ items: RelatedLink[]; onJump: (id: string) => void }> = (props) => (
<div class="pt-3 border-t border-border/40 space-y-2">
<p class="text-[0.625rem] font-black uppercase tracking-widest text-muted-foreground">Related topics</p>
<div class="flex flex-wrap gap-2">
<For each={props.items}>{(item) => <button class="rounded-full border border-border/60 bg-background px-3 py-1.5 text-[0.6875rem] font-medium text-muted-foreground hover:text-foreground hover:border-primary/40 hover:bg-primary/5 transition-colors" onClick={() => props.onJump(item.target)}>{item.label}</button>}</For>
</div>
</div>
);
export const HelpView: Component<HelpViewProps> = (props) => {
return (
<div class="w-full max-w-3xl mx-auto py-4 sm:py-10 px-4 sm:px-0 animate-in fade-in slide-in-from-bottom-2 duration-500">
<header class="flex items-center gap-4 mb-8">
<Show when={props.onBack}>
<Button variant="ghost" size="icon" onClick={() => props.onBack?.()} class="h-10 w-10 rounded-xl">
<ArrowLeft size={20} />
</Button>
</Show>
<div>
<h1 class="text-2xl sm:text-4xl font-black tracking-tighter flex items-center gap-3">
<HelpCircle size={32} class="text-primary hidden sm:block" />
User Guide
</h1>
<p class="text-[0.625rem] sm:text-xs text-muted-foreground font-mono uppercase tracking-widest opacity-70">
Everything you need to know about TaskGrid
</p>
</div>
</header>
const scrollToSection = (id?: string, behavior: ScrollBehavior = "smooth") => {
if (!id) return;
document.getElementById(`help-${id}`)?.scrollIntoView({ behavior, block: "start" });
};
const getSectionFromHash = () => {
if (window.location.hash.startsWith("#/help/")) {
return decodeURIComponent(window.location.hash.slice("#/help/".length));
}
if (window.location.hash.startsWith("#help/")) {
return decodeURIComponent(window.location.hash.slice("#help/".length));
}
return undefined;
};
const jumpTo = (id: string) => {
const nextHash = `#/help/${encodeURIComponent(id)}`;
if (window.location.hash !== nextHash) window.location.hash = nextHash;
scrollToSection(id);
};
<div class="space-y-12 pb-20">
{/* Welcome & Access */}
<section class="space-y-4">
<h2 class="text-xl font-bold border-b border-border pb-2 flex items-center gap-2">
<ShieldCheck size={20} class="text-primary" />
Welcome & Access
</h2>
<div class="grid gap-4 sm:grid-cols-2">
<div class="p-4 rounded-2xl bg-muted/30 border border-border/50">
<p class="text-[0.625rem] font-black uppercase tracking-widest text-muted-foreground mb-1">Website</p>
<a href="https://tasgrid.ccllc.pro" target="_blank" class="text-sm font-bold text-primary flex items-center gap-1 hover:underline">
tasgrid.ccllc.pro
<ExternalLink size={12} />
</a>
</div>
<div class="p-4 rounded-2xl bg-muted/30 border border-border/50">
<p class="text-[0.625rem] font-black uppercase tracking-widest text-muted-foreground mb-1">Login</p>
<p class="text-sm font-medium">Use your <strong>Cardoza Construction email</strong> (the same one you use for Outlook). Your password should match your email password as well.</p>
</div>
</div>
</section>
onMount(() => {
const syncToHash = () => window.setTimeout(() => scrollToSection(getSectionFromHash(), "auto"), 50);
const handleHelpTarget = (event: Event) => {
const detail = (event as CustomEvent).detail as { sectionId?: string } | undefined;
window.setTimeout(() => scrollToSection(detail?.sectionId || getSectionFromHash()), 50);
};
syncToHash();
window.addEventListener("hashchange", syncToHash);
window.addEventListener("taskgrid:help-target", handleHelpTarget);
onCleanup(() => {
window.removeEventListener("hashchange", syncToHash);
window.removeEventListener("taskgrid:help-target", handleHelpTarget);
});
});
{/* Basic Features */}
<section class="space-y-6">
<h2 class="text-xl font-bold border-b border-border pb-2 flex items-center gap-2">
<Zap size={20} class="text-yellow-500" />
Basic Features (Getting Started)
</h2>
<div class="space-y-4">
<h3 class="font-bold text-lg">Creating Your First Task</h3>
<ol class="space-y-3 pl-1">
{[
<>Click the <strong>large plus (+) sign</strong> in the bottom right corner (or type <code>control + k</code>).</>,
<><strong>Title</strong>: Enter a clear name for what needs to be done.</>,
<><strong>Priority</strong>: Choose from 1 (trivial) to 10 (absolutely necessary).</>,
<><strong>Urgency & Due Date</strong>: These are linked!
<ul class="mt-2 space-y-1 ml-4 list-disc text-xs text-muted-foreground">
<li>Pick an <strong>Urgency level</strong> (10 is today, 1 is 6 months from now), and the due date will be calculated for you.</li>
<li>Alternatively, pick a <strong>Due Date</strong> using the calendar, and the urgency will adjust automatically.</li>
</ul>
</>,
<><strong>Size</strong>: This defaults to <strong>5</strong>. Adjust it if the task is very small (15 mins) or very large (multiple days).</>,
<><strong>Description</strong>: Add notes or details. Use the <strong>forward slash (/)</strong> to reveal formatting options like bold text, checklists, and even adding images.</>,
<>Click <strong>Create Task</strong>.</>
].map((item, i) => (
<li class="flex gap-4 items-start">
<span class="flex-shrink-0 w-6 h-6 rounded-full bg-primary/10 text-primary flex items-center justify-center text-xs font-bold">{i + 1}</span>
<div class="text-sm leading-relaxed">{item}</div>
</li>
))}
</ol>
</div>
<div class="space-y-4 pt-4">
<h3 class="font-bold text-lg">Viewing Your Tasks</h3>
<p class="text-sm text-muted-foreground mb-4">TaskGrid sorts your tasks so you don't have to. The primary views are:</p>
<div class="grid gap-4">
{[
{ icon: ListTodo, label: "Focus (Default)", desc: "Your most important tasks, ranked by a combination of Urgency and Priority." },
{
icon: LayoutDashboard,
label: "Strategy Matrix",
desc: "A 4-quadrant grid mapping Urgency vs. Priority.",
sub: [
{ l: "Do First", d: "Urgent & Important" },
{ l: "Schedule", d: "Important but not urgent yet" },
{ l: "Delegate", d: "Urgent but not important" },
{ l: "Eliminate", d: "Neither urgent nor important" }
]
},
{ icon: Snowflake, label: "Snowball", desc: "Shows your smallest tasks first to help you build momentum." },
{ icon: Pickaxe, label: "Dig In", desc: "Shows your largest tasks first for when you have time to focus." },
{ icon: Clock, label: "Urgency", desc: "Sorts tasks purely by when they are due." },
{ icon: ArrowUpCircle, label: "Priority", desc: "Sorts tasks purely by how important they are." }
].map(view => (
<div class="p-4 rounded-xl border border-border/50 bg-card/50 flex gap-4 items-start">
<div class="mt-1 text-primary p-2 rounded-lg bg-primary/5">
<view.icon size={18} />
</div>
<div class="space-y-2">
<p class="text-sm font-bold">{view.label}</p>
<p class="text-sm text-muted-foreground leading-relaxed">{view.desc}</p>
{view.sub && (
<div class="grid grid-cols-2 gap-2 pt-2 border-t border-border/20">
{view.sub.map(s => (
<div>
<p class="text-[0.625rem] font-black uppercase text-primary/70">{s.l}</p>
<p class="text-[0.625rem] text-muted-foreground">{s.d}</p>
</div>
))}
</div>
)}
</div>
</div>
))}
</div>
</div>
<div class="space-y-4 pt-4">
<h3 class="font-bold text-lg">Other Info</h3>
<div class="grid gap-3 sm:grid-cols-3">
{[
{ icon: Search, label: "Search & Filters", desc: "Use the bar at the top right to find specific tasks or filter by priority, urgency, or tasks edited today." },
{ icon: Palette, label: "Themes", desc: "Switch between Light Mode and Dark Mode in the Settings." },
{ icon: Trash2, label: "Trash", desc: "Deleted tasks are kept for 7 days before being permanently removed. You can recover them in Settings." }
].map(info => (
<div class="p-3 rounded-xl border border-border/50 bg-card/10">
<div class="flex items-center gap-2 mb-2">
<info.icon size={14} class="text-primary" />
<p class="text-xs font-bold">{info.label}</p>
</div>
<p class="text-xs text-muted-foreground leading-relaxed">{info.desc}</p>
</div>
))}
</div>
</div>
<div class="space-y-4 pt-4">
<h3 class="font-bold text-lg">Mobile Installation</h3>
<p class="text-sm text-muted-foreground mb-3">TaskGrid works like a dedicated app on your phone:</p>
<div class="grid gap-4 sm:grid-cols-2">
<div class="p-4 rounded-xl bg-primary/5 border border-primary/10">
<p class="text-xs font-bold flex items-center gap-2 mb-2"><Smartphone size={14} /> Android</p>
<p class="text-xs text-muted-foreground">Tap the three dots in Chrome, select "Add to Home Screen," then "Install."</p>
</div>
<div class="p-4 rounded-xl bg-primary/5 border border-primary/10">
<p class="text-xs font-bold flex items-center gap-2 mb-2"><Smartphone size={14} /> iPhone</p>
<p class="text-xs text-muted-foreground">Tap the Share icon in Safari and select "Add to Home Screen."</p>
</div>
</div>
</div>
</section>
{/* Advanced Features */}
<section class="space-y-6">
<h2 class="text-xl font-bold border-b border-border pb-2 flex items-center gap-2">
<Zap size={20} class="text-primary" />
Advanced Features (Teams & Automation)
</h2>
<div class="space-y-4">
<h3 class="font-bold text-lg">Sharing & Collaboration</h3>
<p class="text-sm text-muted-foreground mb-4">Collaboration in TaskGrid is flexible and transparent:</p>
<ol class="space-y-4 text-sm leading-relaxed">
<li>
<strong>1. Manual Sharing</strong>: Open a task and use the <strong>Share</strong> menu to add a specific person.
<ul class="ml-4 mt-1 list-disc text-muted-foreground">
<li>You can also <strong>unshare and split</strong> tasks here. Unshare just removes it from their list and split gives each of you a unique copy of the task.</li>
</ul>
</li>
<li>
<strong>2. Tag-Based User Sharing</strong>: Add a tag that matches a coworker's name (e.g., "John Smith").
<ul class="ml-4 mt-1 list-disc text-muted-foreground">
<li>This automatically shares the task with them.</li>
<li>Both of you can edit the task together (Collaborative mode).</li>
</ul>
</li>
<li>
<strong>3. Collaboration vs. Handoff</strong>:
<ul class="ml-4 mt-1 list-disc text-muted-foreground">
<li><strong>Collaborate</strong>: Both users can see and edit.</li>
<li><strong>Handoff</strong>: The task is transferred to the other person, and they become the new owner.</li>
</ul>
</li>
</ol>
</div>
<div class="space-y-4 pt-4">
<h3 class="font-bold text-lg">Team Spaces (Buckets)</h3>
<p class="text-sm text-muted-foreground leading-relaxed italic mb-2">"Buckets" are shared areas for teams (like "Shop" or "Maintenance").</p>
<ul class="space-y-2 text-sm leading-relaxed">
<li><strong>Move to Bucket</strong>: Simply add a tag that matches the bucket name (e.g., tagging a task with "Shop").</li>
<li><strong>Visibility</strong>: By default, bucketed tasks are hidden from your main view to keep your list clean. You can view them by selecting the bucket in the sidebar but they will only appear if you have pinned them.</li>
<li><strong>Pinning</strong>: In order to work in a bucket, you must "Pin" it in Settings so it always shows up in your sidebar.</li>
</ul>
</div>
<div class="space-y-4 pt-4">
<h3 class="font-bold text-lg flex items-center gap-2"><BarChart3 size={20} class="text-primary" /> Oversight (Share All)</h3>
<p class="text-sm text-muted-foreground leading-relaxed">
Managers and team leads can ask their teams to create a "share all" sharing rule in setting. This will add that user's bucket as an option in the sidebar just like shared buckets. Clicking a user's name in your sidebar allows you to view their entire task list, making it easy to track progress across the team.
</p>
</div>
<div class="space-y-4 pt-4">
<h3 class="font-bold text-lg">Automatic Tasks (Recurrence)</h3>
<p class="text-sm text-muted-foreground leading-relaxed">
For recurring work, you can set a task to repeat <strong>Daily</strong>, <strong>Weekly</strong> (on any specified days of the week), or <strong>Monthly</strong> (on a specified day of the month).
</p>
<ul class="space-y-1 ml-4 list-disc text-sm text-muted-foreground">
<li>When you mark it complete, TaskGrid automatically "un-completes" it for you when the next cycle begins.</li>
<li>You'll see status labels like "Complete until Monday" to let you know exactly when it will reappear.</li>
</ul>
</div>
<div class="space-y-4 pt-4">
<h3 class="font-bold text-lg">Bulk Import (Filling a Bucket)</h3>
<p class="text-sm text-muted-foreground leading-relaxed italic mb-2">Need to add a long list of tasks at once?</p>
<ol class="space-y-2 text-sm leading-relaxed list-decimal ml-4">
<li>Go to <strong>Settings &gt; Import Tasks</strong>.</li>
<li>Paste your list (one task per line).</li>
<li>Click "Parse and Configure."</li>
<li>Use the <strong>Bulk Update</strong> tool at the top to set the Priority, Urgency, and Tag (e.g., "Shop") for all tasks at once.</li>
<li>Update tasks that need individual urgency and priority. (Usually all tasks need individual urgency and priority because if everything is set the same TasGrid can't sort them)</li>
<li>Click <strong>Import Tasks</strong>.</li>
</ol>
</div>
</section>
{/* Power User Tips */}
<section class="p-6 rounded-2xl bg-primary/5 border border-primary/20 space-y-4">
<h2 class="text-xl font-bold flex items-center gap-2">
<Zap size={20} class="text-primary" />
Power User Tips
</h2>
<ul class="space-y-4">
<li class="text-sm flex gap-3">
<span class="text-primary font-bold">/</span>
<span><strong>Quick Entry Shorthands</strong>: Type <code>/p5</code> (priority), <code>/u8</code> (urgency), or <code>/t Wood</code> (tags) directly into the title box when creating a task to set properties instantly without touching your mouse!</span>
</li>
<li class="text-sm flex gap-3">
<span class="text-primary font-bold">/</span>
<span><strong>Task Templates</strong>: If you have recurring projects with the same notes and tags, create a <strong>Template</strong> in Settings. You can apply it with a single click in the creation menu.</span>
</li>
<li class="text-sm flex gap-3">
<span class="text-primary font-bold">/</span>
<span><strong>Matrix Color Pro-tip</strong>: The dots on your Strategy Matrix change color based on <strong>Size</strong>**Blue** dots are small wins (easy tasks), and **Red** dots are major projects (time-consuming tasks).</span>
</li>
<li class="text-sm flex gap-3">
<span class="text-primary font-bold">/</span>
<span><strong>Smart Tagging</strong>: If you are currently viewing a Bucket (e.g., "Shop"), when you click the Plus button to add a task, TaskGrid will automatically add the "Shop" tag for you.</span>
</li>
</ul>
</section>
{/* Help/Contact */}
<footer class="pt-10 border-t border-border flex flex-col items-center text-center space-y-4">
<p class="text-sm text-muted-foreground">
If the app feels out of sync, a quick <button onClick={() => location.reload()} class="text-primary font-bold hover:underline cursor-pointer bg-transparent border-none p-0 inline">refresh</button> usually fixes it. Most updates happen automatically in real-time!
</p>
<p class="text-sm font-medium">
If you have any other questions contact Timothy Cardoza or IT.
</p>
</footer>
</div>
return (
<div class="w-full max-w-5xl mx-auto py-4 sm:py-10 px-4 sm:px-0 animate-in fade-in slide-in-from-bottom-2 duration-500">
<header class="flex items-center gap-4 mb-8 sm:mb-10">
<Show when={props.onBack}><Button variant="ghost" size="icon" onClick={() => props.onBack?.()} class="h-10 w-10 rounded-xl"><ArrowLeft size={20} /></Button></Show>
<div>
<h1 class="text-2xl sm:text-4xl font-black tracking-tighter flex items-center gap-3"><HelpCircle size={32} class="text-primary hidden sm:block" />Help</h1>
<p class="text-[0.625rem] sm:text-xs text-muted-foreground font-mono uppercase tracking-widest opacity-70">Refresh what you forgot, then learn the deeper system</p>
</div>
);
</header>
<div class="space-y-12 pb-20">
<section class="rounded-3xl border border-primary/20 bg-primary/[0.05] p-5 sm:p-6 space-y-4">
<div class="space-y-2">
<p class="text-[0.625rem] font-black uppercase tracking-widest text-primary/80">Jump to</p>
<h2 class="text-xl font-black tracking-tight">Need the fast version?</h2>
<p class="text-sm text-muted-foreground leading-relaxed">Use these shortcuts when you remember being shown something, but do not remember the exact steps anymore.</p>
</div>
<div class="flex flex-wrap gap-2">
<For each={jumpLinks}>{(item) => <button class="rounded-full border border-primary/20 bg-background/80 px-3 py-1.5 text-[0.6875rem] font-semibold text-foreground hover:bg-primary/10 hover:border-primary/40 transition-colors" onClick={() => jumpTo(item.target)}>{item.label}</button>}</For>
</div>
</section>
<section id="help-quick-start" class="space-y-5">
<div class="space-y-2">
<h2 class="text-xl font-bold border-b border-border pb-2 flex items-center gap-2"><Sparkles size={20} class="text-primary" />Get Unstuck Fast</h2>
<p class="text-sm text-muted-foreground leading-relaxed">These cards answer the questions users most often have after in-person training.</p>
</div>
<div class="grid gap-4 md:grid-cols-2">
<For each={quickActions}>{(action) => (
<div class="rounded-2xl border border-border/50 bg-card/70 shadow-sm p-4 space-y-3">
<div class="space-y-1"><p class="text-[0.625rem] font-black uppercase tracking-widest text-primary/80">I need to...</p><h3 class="text-sm font-bold tracking-tight">{action.title}</h3></div>
<ol class="space-y-2">
<For each={action.steps}>{(step, index) => <li class="flex gap-3 text-sm text-muted-foreground leading-relaxed"><span class="mt-0.5 flex h-5 w-5 shrink-0 items-center justify-center rounded-full bg-primary/10 text-primary text-[0.625rem] font-bold">{index() + 1}</span><span>{step}</span></li>}</For>
</ol>
<Button variant="ghost" size="sm" class="h-8 px-2 text-[0.6875rem] font-bold text-primary hover:bg-primary/10" onClick={() => jumpTo(action.target)}>Open full guide</Button>
</div>
)}</For>
</div>
</section>
<section class="space-y-6 rounded-[2rem] border border-sky-200/40 bg-sky-500/[0.04] p-5 sm:p-6">
<div class="space-y-2">
<h2 class="text-xl font-bold border-b border-border/60 pb-2 flex items-center gap-2"><BookOpen size={20} class="text-sky-600" />Core Workflows</h2>
<p class="text-sm text-muted-foreground leading-relaxed">This layer is for refreshing the workflows you already learned in person.</p>
</div>
<div class="space-y-5">
<For each={coreSections}>{(section) => {
const Icon = section.icon;
return <article id={`help-${section.id}`} class="rounded-3xl border border-border/50 bg-card/80 shadow-sm overflow-hidden">
<div class="p-5 sm:p-6 border-b border-border/40 bg-gradient-to-r from-sky-500/[0.08] via-sky-500/[0.03] to-transparent">
<div class="flex items-start gap-4">
<div class="hidden sm:flex h-11 w-11 shrink-0 items-center justify-center rounded-2xl bg-sky-500/10 text-sky-700"><Icon size={20} /></div>
<div class="space-y-2 min-w-0"><h3 class="text-lg sm:text-xl font-black tracking-tight flex items-center gap-2"><Icon size={18} class="sm:hidden text-sky-700" />{section.title}</h3><p class="text-sm text-muted-foreground leading-relaxed">{section.intro}</p></div>
</div>
</div>
<div class="p-5 sm:p-6 space-y-5">
<div class="space-y-2"><p class="text-[0.625rem] font-black uppercase tracking-widest text-muted-foreground">What it is</p><p class="text-sm leading-relaxed text-foreground">{section.body}</p></div>
<div class="space-y-3"><p class="text-[0.625rem] font-black uppercase tracking-widest text-muted-foreground">How to do it</p><ol class="space-y-2"><For each={section.steps}>{(step, index) => <li class="flex gap-3 text-sm text-muted-foreground leading-relaxed"><span class="mt-0.5 flex h-5 w-5 shrink-0 items-center justify-center rounded-full bg-sky-500/10 text-sky-700 text-[0.625rem] font-bold">{index() + 1}</span><span>{step}</span></li>}</For></ol></div>
<div class="grid gap-4 md:grid-cols-2">
<div class="rounded-2xl border border-amber-300/40 bg-amber-500/[0.06] p-4 space-y-2"><p class="text-[0.625rem] font-black uppercase tracking-widest text-amber-700">What people usually forget</p><p class="text-sm text-muted-foreground leading-relaxed">{section.reminder}</p></div>
<div class="rounded-2xl border border-rose-300/40 bg-rose-500/[0.06] p-4 space-y-2"><p class="text-[0.625rem] font-black uppercase tracking-widest text-rose-700">Watch for this</p><p class="text-sm text-muted-foreground leading-relaxed">{section.warning}</p></div>
</div>
<RelatedTopics items={section.related} onJump={jumpTo} />
</div>
</article>;
}}</For>
</div>
</section>
<section class="space-y-6 rounded-[2rem] border border-violet-200/40 bg-violet-500/[0.04] p-5 sm:p-6">
<div class="space-y-2">
<h2 class="text-xl font-bold border-b border-border/60 pb-2 flex items-center gap-2"><Link2 size={20} class="text-violet-700" />Deep Dive</h2>
<p class="text-sm text-muted-foreground leading-relaxed">This layer is optional enrichment for users who want the mental model behind the app.</p>
</div>
<div class="space-y-5">
<For each={deepSections}>{(section) => {
const Icon = section.icon;
return <article id={`help-${section.id}`} class="rounded-3xl border border-border/50 bg-card/80 shadow-sm overflow-hidden">
<div class="p-5 sm:p-6 border-b border-border/40 bg-gradient-to-r from-violet-500/[0.08] via-violet-500/[0.03] to-transparent">
<div class="flex items-start gap-4">
<div class="hidden sm:flex h-11 w-11 shrink-0 items-center justify-center rounded-2xl bg-violet-500/10 text-violet-700"><Icon size={20} /></div>
<div class="space-y-2 min-w-0"><h3 class="text-lg sm:text-xl font-black tracking-tight flex items-center gap-2"><Icon size={18} class="sm:hidden text-violet-700" />{section.title}</h3><p class="text-sm text-muted-foreground leading-relaxed">{section.intro}</p></div>
</div>
</div>
<div class="p-5 sm:p-6 space-y-5">
<div class="space-y-3"><p class="text-[0.625rem] font-black uppercase tracking-widest text-muted-foreground">Learn more</p><ul class="space-y-2"><For each={section.bullets}>{(item) => <li class="flex gap-3 text-sm text-muted-foreground leading-relaxed"><span class="mt-[0.45rem] h-1.5 w-1.5 shrink-0 rounded-full bg-violet-600/70" /><span>{item}</span></li>}</For></ul></div>
<div class="rounded-2xl border border-violet-300/40 bg-violet-500/[0.06] p-4 space-y-2"><p class="text-[0.625rem] font-black uppercase tracking-widest text-violet-700">Why it matters</p><p class="text-sm text-muted-foreground leading-relaxed">{section.why}</p></div>
<RelatedTopics items={section.related} onJump={jumpTo} />
</div>
</article>;
}}</For>
</div>
</section>
<section class="space-y-5">
<h2 class="text-xl font-bold border-b border-border pb-2 flex items-center gap-2"><ShieldCheck size={20} class="text-primary" />Welcome & Access</h2>
<p class="text-sm text-muted-foreground leading-relaxed">This section is mostly here for helping someone else get into TasGrid or for occasional reference later. It is intentionally placed near the end because most users opening Help already know how to get in.</p>
<div class="grid gap-4 md:grid-cols-3">
<For each={accessCards}>{(card) => (
<div class="rounded-2xl border border-border/50 bg-card/50 p-4 space-y-2 shadow-sm">
<p class="text-[0.625rem] font-black uppercase tracking-widest text-muted-foreground">{card.title}</p>
<Show when={card.href} fallback={<p class="text-sm font-bold tracking-tight">{card.content}</p>}>
<a href={card.href} target="_blank" class="text-sm font-bold tracking-tight text-primary flex items-center gap-1 hover:underline">{card.content}<ExternalLink size={12} /></a>
</Show>
<p class="text-sm text-muted-foreground leading-relaxed">{card.detail}</p>
</div>
)}</For>
</div>
</section>
<footer class="pt-10 border-t border-border flex flex-col items-center text-center space-y-4">
<p class="text-sm text-muted-foreground max-w-2xl leading-relaxed">If the app feels out of sync, a quick <button onClick={() => location.reload()} class="text-primary font-bold hover:underline cursor-pointer bg-transparent border-none p-0 inline-flex items-center gap-1">refresh<RefreshCcw size={12} /></button> is still the best first step. Most changes sync automatically in real time.</p>
<p class="text-sm font-medium">If you still need help after that, contact Timothy Cardoza or IT.</p>
</footer>
</div>
</div>
);
};
+871 -220
View File
File diff suppressed because it is too large Load Diff
+5 -7
View File
@@ -1,8 +1,8 @@
import { type Component, For, createMemo, onMount } from "solid-js";
import { type Component, For, createMemo } from "solid-js";
import { store, getCombinedScore, matchesFilter } from "@/store";
import { TaskCard } from "@/components/TaskCard";
import { useDelayedSort } from "@/hooks/useDelayedSort";
import autoAnimate from "@formkit/auto-animate";
import { useTaskListAutoAnimate } from "@/hooks/useTaskListAutoAnimate";
export const PriorityView: Component = () => {
const sourceTasks = createMemo(() => store.tasks.filter(t => !t.deletedAt && matchesFilter(t)));
@@ -17,9 +17,7 @@ export const PriorityView: Component = () => {
);
let listRef: HTMLDivElement | undefined;
onMount(() => {
if (listRef) autoAnimate(listRef, { duration: 300, easing: 'ease-out' });
});
useTaskListAutoAnimate(() => listRef);
return (
<div class="space-y-6">
@@ -28,10 +26,10 @@ export const PriorityView: Component = () => {
<p class="text-muted-foreground mt-1 text-lg">Your tasks ordered by priority.</p>
</header>
<div class="grid gap-3" ref={listRef}>
<div class="grid grid-cols-1 gap-3 w-full" ref={listRef}>
<For each={displayedTasks()}>
{(task) => (
<div class="animate-task-enter">
<div class="w-full min-w-0">
<TaskCard task={task} isShaking={shakingTaskIds().includes(task.id)} />
</div>
)}
+5 -7
View File
@@ -1,8 +1,8 @@
import { type Component, For, createMemo, onMount } from "solid-js";
import { type Component, For, createMemo } from "solid-js";
import { store, getCombinedScore, matchesFilter } from "@/store";
import { TaskCard } from "@/components/TaskCard";
import { useDelayedSort } from "@/hooks/useDelayedSort";
import autoAnimate from "@formkit/auto-animate";
import { useTaskListAutoAnimate } from "@/hooks/useTaskListAutoAnimate";
export const ProgressView: Component = () => {
const sourceTasks = createMemo(() => store.tasks.filter(t => !t.deletedAt && matchesFilter(t)));
@@ -24,9 +24,7 @@ export const ProgressView: Component = () => {
);
let listRef: HTMLDivElement | undefined;
onMount(() => {
if (listRef) autoAnimate(listRef, { duration: 300, easing: 'ease-out' });
});
useTaskListAutoAnimate(() => listRef);
return (
<div class="space-y-6">
@@ -35,10 +33,10 @@ export const ProgressView: Component = () => {
<p class="text-muted-foreground mt-1 text-lg">Tasks sorted by progress status, then by focus score.</p>
</header>
<div class="grid gap-3" ref={listRef}>
<div class="grid grid-cols-1 gap-3 w-full" ref={listRef}>
<For each={displayedTasks()}>
{(task) => (
<div class="animate-task-enter">
<div class="w-full min-w-0">
<TaskCard task={task} isShaking={shakingTaskIds().includes(task.id)} />
</div>
)}
+655 -289
View File
File diff suppressed because it is too large Load Diff
+5 -7
View File
@@ -1,8 +1,8 @@
import { type Component, For, createMemo, onMount } from "solid-js";
import { type Component, For, createMemo } from "solid-js";
import { store, getCombinedScore, matchesFilter } from "@/store";
import { TaskCard } from "@/components/TaskCard";
import { useDelayedSort } from "@/hooks/useDelayedSort";
import autoAnimate from "@formkit/auto-animate";
import { useTaskListAutoAnimate } from "@/hooks/useTaskListAutoAnimate";
export const SnowballView: Component = () => {
const sourceTasks = createMemo(() => store.tasks.filter(t => !t.deletedAt && matchesFilter(t)));
@@ -22,9 +22,7 @@ export const SnowballView: Component = () => {
);
let listRef: HTMLDivElement | undefined;
onMount(() => {
if (listRef) autoAnimate(listRef, { duration: 300, easing: 'ease-out' });
});
useTaskListAutoAnimate(() => listRef);
return (
<div class="space-y-6">
@@ -33,10 +31,10 @@ export const SnowballView: Component = () => {
<p class="text-muted-foreground mt-1 text-lg">Smallest tasks first. Build momentum with quick wins.</p>
</header>
<div class="grid gap-3" ref={listRef}>
<div class="grid grid-cols-1 gap-3 w-full" ref={listRef}>
<For each={displayedTasks()}>
{(task) => (
<div class="animate-task-enter">
<div class="w-full min-w-0">
<TaskCard task={task} isShaking={shakingTaskIds().includes(task.id)} />
</div>
)}
+5 -7
View File
@@ -1,8 +1,8 @@
import { type Component, For, createMemo, onMount } from "solid-js";
import { type Component, For, createMemo } from "solid-js";
import { store, calculateUrgencyScore, getCombinedScore, matchesFilter } from "@/store";
import { TaskCard } from "@/components/TaskCard";
import { useDelayedSort } from "@/hooks/useDelayedSort";
import autoAnimate from "@formkit/auto-animate";
import { useTaskListAutoAnimate } from "@/hooks/useTaskListAutoAnimate";
export const UrgencyView: Component = () => {
const sourceTasks = createMemo(() => store.tasks.filter(t => !t.deletedAt && matchesFilter(t)));
@@ -21,9 +21,7 @@ export const UrgencyView: Component = () => {
);
let listRef: HTMLDivElement | undefined;
onMount(() => {
if (listRef) autoAnimate(listRef, { duration: 300, easing: 'ease-out' });
});
useTaskListAutoAnimate(() => listRef);
return (
<div class="space-y-6">
@@ -32,10 +30,10 @@ export const UrgencyView: Component = () => {
<p class="text-muted-foreground mt-1 text-lg">Your tasks ordered by deadline.</p>
</header>
<div class="grid gap-3" ref={listRef}>
<div class="grid grid-cols-1 gap-3 w-full" ref={listRef}>
<For each={displayedTasks()}>
{(task) => (
<div class="animate-task-enter">
<div class="w-full min-w-0">
<TaskCard task={task} isShaking={shakingTaskIds().includes(task.id)} />
</div>
)}
+3
View File
@@ -0,0 +1,3 @@
/// <reference types="vite/client" />
declare const __APP_VERSION__: string;
+477 -81
View File
@@ -105,6 +105,80 @@
h1 {
margin-top: 0;
}
.sdk-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
gap: 14px;
margin-bottom: 10px;
}
.sdk-card {
border: 1px solid #e5e7eb;
border-radius: 10px;
padding: 14px;
background: #fafafa;
}
.sdk-card h4 {
margin: 0 0 10px;
font-size: 14px;
}
.sdk-card p {
margin: 0 0 10px;
font-size: 12px;
color: #666;
line-height: 1.5;
}
.sdk-stack {
display: grid;
gap: 8px;
}
.sdk-stack input,
.sdk-stack textarea,
.sdk-stack select {
width: 100%;
padding: 8px 10px;
border: 1px solid #d1d5db;
border-radius: 6px;
font: inherit;
box-sizing: border-box;
background: white;
}
.sdk-stack textarea {
min-height: 74px;
resize: vertical;
}
.sdk-inline {
display: flex;
gap: 8px;
align-items: center;
flex-wrap: wrap;
}
.sdk-inline label {
font-size: 12px;
color: #444;
}
.sdk-meta {
margin-top: 10px;
font-size: 12px;
color: #555;
line-height: 1.5;
}
.sdk-actions {
display: flex;
gap: 8px;
flex-wrap: wrap;
margin-top: 8px;
}
</style>
<script src="https://cdn.jsdelivr.net/npm/pocketbase/dist/pocketbase.umd.js"></script>
</head>
@@ -113,6 +187,18 @@
<h1>TasGrid Parent App Simulator</h1>
<div class="controls">
<div id="env-settings" style="margin-bottom: 20px; padding-bottom: 20px; border-bottom: 1px solid #eee;">
<p style="font-size: 13px; color: #666; margin-top: 0;"><strong>Target Environment:</strong></p>
<label style="margin-right: 15px; cursor: pointer;">
<input type="radio" name="envToggle" value="https://tasgrid.ccllc.pro" onchange="updateEnv()">
Production (tasgrid.ccllc.pro)
</label>
<label style="cursor: pointer;">
<input type="radio" name="envToggle" value="http://localhost:4001" checked onchange="updateEnv()">
Localhost:4001
</label>
</div>
<div id="login-section">
<h3>Login to Parent Application</h3>
<p>This simulates the parent application authenticating with the shared backend.</p>
@@ -132,12 +218,94 @@
<button onclick="loadSpecificNote()" style="padding: 8px 16px; cursor: pointer;">Load Note</button>
</div>
<p style="font-size: 13px; color: #666; margin-top: 20px;"><strong>Test API Creation:</strong></p>
<div style="display: flex; gap: 10px; margin-bottom: 10px;">
<input type="text" id="new-note-title" style="flex: 1; padding: 8px;" placeholder="New Note Title...">
<button onclick="createNoteViaAPI()"
style="padding: 8px 16px; cursor: pointer; background: #28a745; color: white; border: none; border-radius: 4px; font-weight: bold;">Create
& Open Note</button>
<p style="font-size: 13px; color: #666; margin-top: 20px;"><strong>Test Headless SDK (Current
Creation Model):</strong></p>
<div class="sdk-grid">
<div class="sdk-card">
<h4>Task Creation</h4>
<p>Creates a task with normalized tags, structured sharing refs, and optional `#note` linkage, then verifies the task is anchored by personal-context refs instead of the legacy direct user link.</p>
<div class="sdk-stack">
<input type="text" id="sdk-task-title" placeholder="Task title">
<input type="text" id="sdk-task-tags"
placeholder="Tags CSV, e.g. sdk_test,#sdk-workspace,@Shared Bucket">
<textarea id="sdk-task-share-modes"
placeholder="Optional share mode overrides, one per line. Example:&#10;#sdk-workspace=collaborative&#10;@shared bucket=handoff"></textarea>
<div class="sdk-inline">
<input type="number" id="sdk-task-priority" min="1" max="10" value="8"
style="max-width: 90px;" title="Priority">
<input type="number" id="sdk-task-urgency" min="1" max="10" value="4"
style="max-width: 90px;" title="Urgency">
<input type="number" id="sdk-task-size" min="0" max="10" value="3"
style="max-width: 90px;" title="Size">
</div>
<textarea id="sdk-task-content"
placeholder="Task content HTML or plain text">This task was created by a sister app using the current TasGrid SDK flow.</textarea>
</div>
<div class="sdk-actions">
<button onclick="createTaskViaSDK()"
style="padding: 8px 16px; cursor: pointer; background: #6f42c1; color: white; border: none; border-radius: 4px; font-weight: bold;">Create
Task via SDK</button>
</div>
<div class="sdk-meta" id="sdk-task-meta">Last created task: none</div>
</div>
<div class="sdk-card">
<h4>Note Creation</h4>
<p>Creates a canonical-key note and can fully link tasks into the note workspace the new way.</p>
<div class="sdk-stack">
<input type="text" id="sdk-note-title" placeholder="Note title">
<input type="text" id="sdk-note-key" placeholder="Optional note key override">
<input type="text" id="sdk-note-tags" placeholder="Tags CSV, e.g. sdk_test">
<textarea id="sdk-note-content"
placeholder="Note HTML content">&lt;p&gt;This note was created by the updated TasGrid SDK.&lt;/p&gt;</textarea>
<input type="text" id="sdk-note-task-ids"
placeholder="Task IDs CSV to fully link into this note workspace">
<div class="sdk-inline">
<label>Share mode
<select id="sdk-note-share-mode">
<option value="collaborative" selected>collaborative</option>
<option value="handoff">handoff</option>
</select>
</label>
<label>Visibility
<select id="sdk-note-private">
<option value="false" selected>public</option>
<option value="true">private</option>
</select>
</label>
<label style="display: flex; align-items: center; gap: 6px;">
<input type="checkbox" id="sdk-note-link-tasks" checked>
Link tasks via SDK
</label>
</div>
</div>
<div class="sdk-actions">
<button onclick="createNoteViaSDK()"
style="padding: 8px 16px; cursor: pointer; background: #fd7e14; color: white; border: none; border-radius: 4px; font-weight: bold;">Create
Note via SDK</button>
<button onclick="createLinkedWorkspaceScenario()"
style="padding: 8px 16px; cursor: pointer; background: #0f766e; color: white; border: none; border-radius: 4px; font-weight: bold;">Create
Task + Linked Note</button>
</div>
<div class="sdk-meta" id="sdk-note-meta">Last created note: none</div>
</div>
<div class="sdk-card">
<h4>Link Existing Tasks</h4>
<p>Calls the SDK's direct note-link helper so you can test current workspace linking without creating a new note.</p>
<div class="sdk-stack">
<input type="text" id="sdk-link-note-id" placeholder="Target note ID">
<input type="text" id="sdk-link-task-ids" placeholder="Task IDs CSV to link">
</div>
<div class="sdk-actions">
<button onclick="linkExistingTasksViaSDK()"
style="padding: 8px 16px; cursor: pointer; background: #2563eb; color: white; border: none; border-radius: 4px; font-weight: bold;">Link
Tasks to Note</button>
<button onclick="loadLastCreatedNote()" style="padding: 8px 16px; cursor: pointer;">Load Last
Note Embed</button>
</div>
<div class="sdk-meta" id="sdk-link-meta">Ready to link existing records.</div>
</div>
</div>
<p style="font-size: 13px; color: #666; margin-top: 20px;"><strong>Manual Auth:</strong></p>
@@ -157,44 +325,53 @@
<li><code>/embed/quick-add</code>: Renders a standalone task creation form.</li>
</ul>
<p><strong>2. Creating Notes Programmatically (API):</strong></p>
<p>Before embedding, a sister app can create a Note via PocketBase API to retrieve a new
<code>noteId</code>. By default, notes should have an array of <code>tags</code> and a
<code>title</code>.
<p><strong>2. Programmatic Creation (TasGrid SDK):</strong></p>
<p>The recommended way to create entries is using the <code>tasgrid-sdk.js</code> module. This
now applies TasGrid's current normalized creation rules, including canonical note keys,
structured <code>labelTags</code>/<code>shareRefs</code>/<code>noteRefs</code>, owner-context
tagging, and note-linked task metadata.
</p>
<p><em>Using the PocketBase JS SDK:</em></p>
<p><em>Setup:</em></p>
<pre
style="background: #f8f9fa; padding: 15px; border-radius: 4px; border: 1px solid #e9ecef; overflow-x: auto; font-size: 12px;">
const record = await pb.collection('TasGrid_Notes').create({
title: "Sister App Payload",
content: "&lt;p&gt;Initial content here...&lt;/p&gt;",
tags: ["sister_app_export"],
isPrivate: false,
user: pb.authStore.model.id, // Must be authenticated user ID
tasks: [] // Optional array of linked task IDs
});
const newNoteId = record.id;
// Then iframe to /embed/notes?noteId=${newNoteId}
</pre>
<p><em>Using standard REST API (fetch):</em></p>
<pre
style="background: #f8f9fa; padding: 15px; border-radius: 4px; border: 1px solid #e9ecef; overflow-x: auto; font-size: 12px;">
const res = await fetch('https://pocketbase.ccllc.pro/api/collections/TasGrid_Notes/records', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': 'YOUR_PB_AUTH_TOKEN'
import { createTasgridTask, createTasgridNote } from 'https://tasgrid.ccllc.pro/tasgrid-sdk.js';</pre>
<div style="border-left: 4px solid #6f42c1; padding-left: 15px; margin: 20px 0;">
<p><strong>A. Creating a TASK:</strong></p>
<p style="font-size: 12px; color: #666; margin-top: -10px;">Use this for items that need status,
priority, and automated due date calculation.</p>
<pre
style="background: #f8f9fa; padding: 15px; border-radius: 4px; border: 1px solid #e9ecef; overflow-x: auto; font-size: 12px;">
const task = await createTasgridTask(pb, {
title: "Urgent Task",
urgency: 10, // Automated: 10=Today, 5=Next Week, 1=Six Months
priority: 8, // 1-10
size: 3, // Default: 3 (1 hour). Range 0-10.
tags: ["external", "@Shared Bucket", "#project-brief"],
shareModeByTag: {
"@shared bucket": "handoff",
"#project-brief": "collaborative"
},
body: JSON.stringify({
title: "REST Custom Note",
user: "YOUR_USER_ID",
tags: [],
tasks: [],
isPrivate: false
})
});
const data = await res.json();
const newNoteId = data.id;</pre>
owner: "USER_ID" // Optional: target personal-context owner, not a direct task.user link
});</pre>
</div>
<div style="border-left: 4px solid #fd7e14; padding-left: 15px; margin: 20px 0;">
<p><strong>B. Creating a NOTE:</strong></p>
<p style="font-size: 12px; color: #666; margin-top: -10px;">Use this for long-form content or
documentation without a deadline.</p>
<pre
style="background: #f8f9fa; padding: 15px; border-radius: 4px; border: 1px solid #e9ecef; overflow-x: auto; font-size: 12px;">
const note = await createTasgridNote(pb, {
title: "Project Brief",
content: "&lt;p&gt;HTML content here...&lt;/p&gt;",
tags: ["documentation"],
noteShareMode: "collaborative",
isPrivate: false,
tasks: [] // Optional: Task IDs to store and fully link into the note workspace
});</pre>
</div>
<p><strong>3. Authentication Protocol:</strong></p>
<p>The parent application must send a <code>postMessage</code> to the iframe immediately after the
@@ -232,7 +409,7 @@ function syncAuth(iframe) {
<span>Notes Embed</span>
<code style="font-size: 10px;">/embed/notes</code>
</div>
<iframe id="notes-iframe" src="http://localhost:4000/embed/notes" onload="syncAuthOnLoad(this)"></iframe>
<iframe id="notes-iframe" onload="syncAuthOnLoad(this)"></iframe>
</div>
<div class="iframe-container">
@@ -241,20 +418,132 @@ function syncAuth(iframe) {
<span>Quick Add Embed</span>
<code style="font-size: 10px;">/embed/quick-add</code>
</div>
<iframe id="quick-add-iframe" src="http://localhost:4000/embed/quick-add"
onload="syncAuthOnLoad(this)"></iframe>
<iframe id="quick-add-iframe" onload="syncAuthOnLoad(this)"></iframe>
</div>
</div>
<script>
const pb = new PocketBase('https://pocketbase.ccllc.pro');
const statusEl = document.getElementById('status');
let ENV_URL = 'http://localhost:4001';
let lastCreatedTaskId = '';
let lastCreatedNoteId = '';
function setStatus(kind, message) {
statusEl.className = kind ? `status ${kind}` : 'status';
statusEl.textContent = message;
statusEl.style.display = 'block';
}
function parseCsvInput(id) {
return (document.getElementById(id)?.value || '')
.split(',')
.map(value => value.trim())
.filter(Boolean);
}
function parseShareModeOverrides(id) {
const lines = (document.getElementById(id)?.value || '')
.split(/\r?\n/)
.map(line => line.trim())
.filter(Boolean);
const result = {};
for (const line of lines) {
const separatorIndex = line.indexOf('=');
if (separatorIndex === -1) continue;
const key = line.slice(0, separatorIndex).trim().toLowerCase();
const value = line.slice(separatorIndex + 1).trim().toLowerCase();
if (!key || (value !== 'collaborative' && value !== 'handoff')) continue;
result[key] = value;
}
return result;
}
function refreshSdkMeta() {
document.getElementById('sdk-task-meta').textContent = lastCreatedTaskId
? `Last created task: ${lastCreatedTaskId}`
: 'Last created task: none';
document.getElementById('sdk-note-meta').textContent = lastCreatedNoteId
? `Last created note: ${lastCreatedNoteId}`
: 'Last created note: none';
document.getElementById('sdk-link-meta').textContent = [
lastCreatedTaskId ? `task ${lastCreatedTaskId}` : null,
lastCreatedNoteId ? `note ${lastCreatedNoteId}` : null
].filter(Boolean).join(' | ') || 'Ready to link existing records.';
}
async function loadSdkModule() {
return await import(`${ENV_URL}/tasgrid-sdk.js`);
}
function fillLinkingFields() {
if (lastCreatedTaskId && !document.getElementById('sdk-note-task-ids').value.trim()) {
document.getElementById('sdk-note-task-ids').value = lastCreatedTaskId;
}
if (lastCreatedTaskId && !document.getElementById('sdk-link-task-ids').value.trim()) {
document.getElementById('sdk-link-task-ids').value = lastCreatedTaskId;
}
if (lastCreatedNoteId && !document.getElementById('sdk-link-note-id').value.trim()) {
document.getElementById('sdk-link-note-id').value = lastCreatedNoteId;
}
}
function loadLastCreatedNote() {
if (!lastCreatedNoteId) {
alert('Create a note first.');
return;
}
document.getElementById('note-id-input').value = lastCreatedNoteId;
loadSpecificNote();
}
async function inspectTaskContextState(taskId) {
const record = await pb.collection('TasGrid').getOne(taskId, {
fields: 'id,user,shareRefs,noteRefs,tags,labelTags',
requestKey: null
});
const legacyUser = record.user && (typeof record.user === 'string' ? record.user : record.user.id);
const shareRefCount = Array.isArray(record.shareRefs) ? record.shareRefs.length : 0;
const noteRefCount = Array.isArray(record.noteRefs) ? record.noteRefs.length : 0;
return {
legacyUser: legacyUser || null,
shareRefCount,
noteRefCount,
tags: Array.isArray(record.tags) ? record.tags : []
};
}
function updateEnv() {
const selected = document.querySelector('input[name="envToggle"]:checked').value;
ENV_URL = selected;
// Reload iframes with new base URL
const notesIframe = document.getElementById('notes-iframe');
const noteId = document.getElementById('note-id-input')?.value.trim();
if (noteId) {
notesIframe.src = `${ENV_URL}/embed/notes?noteId=${noteId}`;
} else {
notesIframe.src = `${ENV_URL}/embed/notes`;
}
document.getElementById('quick-add-iframe').src = `${ENV_URL}/embed/quick-add`;
console.log(`Switched target environment to: ${ENV_URL}`);
}
// Initialize iframes on load
window.addEventListener('DOMContentLoaded', () => {
updateEnv();
});
function syncAuthOnLoad(iframe) {
console.log(`Iframe ${iframe.id} loaded, checking auth sync...`);
if (pb.authStore.isValid) {
console.log(`Syncing existing auth to ${iframe.id}`);
sendAuthToIframes();
sendAuth(pb.authStore.token, pb.authStore.model);
}
}
@@ -262,46 +551,158 @@ function syncAuth(iframe) {
const id = document.getElementById('note-id-input').value.trim();
const iframe = document.getElementById('notes-iframe');
if (id) {
iframe.src = `http://localhost:4000/embed/notes?noteId=${id}`;
iframe.src = `${ENV_URL}/embed/notes?noteId=${id}`;
} else {
iframe.src = `http://localhost:4000/embed/notes`;
iframe.src = `${ENV_URL}/embed/notes`;
}
}
async function createNoteViaAPI() {
const title = document.getElementById('new-note-title').value.trim() || 'API Generated Note';
async function createTaskViaSDK() {
const title = document.getElementById('sdk-task-title').value.trim() || 'SDK Generated Task';
if (!pb.authStore.isValid) {
alert("Please log in first before creating a note via API.");
alert("Please log in first before creating a task via SDK.");
return;
}
statusEl.className = 'status';
statusEl.textContent = 'Creating note via API...';
statusEl.style.display = 'block';
setStatus('', 'Importing SDK and creating task...');
try {
// Using PocketBase SDK to mimic a REST API call from sister app
const record = await pb.collection('TasGrid_Notes').create({
const { createTasgridTask, TASGRID_SDK_SIGNATURE } = await loadSdkModule();
const priority = parseInt(document.getElementById('sdk-task-priority').value || '8', 10);
const urgency = parseInt(document.getElementById('sdk-task-urgency').value || '4', 10);
const size = parseInt(document.getElementById('sdk-task-size').value || '3', 10);
const tags = parseCsvInput('sdk-task-tags');
const shareModeByTag = parseShareModeOverrides('sdk-task-share-modes');
const record = await createTasgridTask(pb, {
title: title,
content: "<p>This note was created automatically via the API script!</p>",
tags: ["api_generated"],
isPrivate: false,
user: pb.authStore.model.id,
tasks: []
priority: Number.isFinite(priority) ? priority : 8,
urgency: Number.isFinite(urgency) ? urgency : 4,
size: Number.isFinite(size) ? size : 3,
tags: tags,
shareModeByTag,
content: document.getElementById('sdk-task-content').value || ""
});
statusEl.className = 'status success';
statusEl.textContent = `Note created successfully (ID: ${record.id})! Loading iframe...`;
// Automatically set the new ID in the deep link input
document.getElementById('note-id-input').value = record.id;
// Automatically load it
loadSpecificNote();
lastCreatedTaskId = record.id;
fillLinkingFields();
refreshSdkMeta();
const inspection = await inspectTaskContextState(record.id);
const legacyText = inspection.legacyUser ? `legacy user mirror still present: ${inspection.legacyUser}` : 'no legacy task.user link';
setStatus('success', `Task created successfully via SDK ${TASGRID_SDK_SIGNATURE || '(unknown signature)'} (ID: ${record.id}). shareRefs=${inspection.shareRefCount}, noteRefs=${inspection.noteRefCount}, ${legacyText}.`);
} catch (err) {
statusEl.className = 'status error';
statusEl.textContent = 'Failed to create note: ' + err.message;
setStatus('error', 'Failed to create task via SDK: ' + err.message);
}
}
async function createNoteViaSDK() {
const title = document.getElementById('sdk-note-title').value.trim() || 'SDK Generated Note';
if (!pb.authStore.isValid) {
alert("Please log in first before creating a note via SDK.");
return;
}
setStatus('', 'Importing SDK and creating note...');
try {
const { createTasgridNote, TASGRID_SDK_SIGNATURE } = await loadSdkModule();
const taskIds = parseCsvInput('sdk-note-task-ids');
const record = await createTasgridNote(pb, {
title: title,
key: document.getElementById('sdk-note-key').value.trim() || undefined,
content: document.getElementById('sdk-note-content').value || "",
tags: parseCsvInput('sdk-note-tags'),
noteShareMode: document.getElementById('sdk-note-share-mode').value,
isPrivate: document.getElementById('sdk-note-private').value === 'true',
tasks: taskIds,
linkTasks: document.getElementById('sdk-note-link-tasks').checked
});
lastCreatedNoteId = record.id;
document.getElementById('note-id-input').value = record.id;
fillLinkingFields();
refreshSdkMeta();
loadSpecificNote();
setStatus('success', `Note created successfully via SDK ${TASGRID_SDK_SIGNATURE || '(unknown signature)'} (ID: ${record.id}). The notes iframe was pointed at the new note.`);
} catch (err) {
setStatus('error', 'Failed to create note via SDK: ' + err.message);
}
}
async function linkExistingTasksViaSDK() {
const noteId = document.getElementById('sdk-link-note-id').value.trim();
const taskIds = parseCsvInput('sdk-link-task-ids');
if (!pb.authStore.isValid) {
alert("Please log in first before linking tasks via SDK.");
return;
}
if (!noteId || taskIds.length === 0) {
alert("Provide both a target note ID and at least one task ID.");
return;
}
setStatus('', 'Importing SDK and linking tasks to note...');
try {
const { linkTasgridTasksToNote, TASGRID_SDK_SIGNATURE } = await loadSdkModule();
const records = await linkTasgridTasksToNote(pb, noteId, taskIds);
lastCreatedNoteId = noteId;
fillLinkingFields();
refreshSdkMeta();
const inspection = await inspectTaskContextState(taskIds[0]);
setStatus('success', `Linked ${records.length} task(s) to note ${noteId} via SDK ${TASGRID_SDK_SIGNATURE || '(unknown signature)'}. First task now has shareRefs=${inspection.shareRefCount} and noteRefs=${inspection.noteRefCount}.`);
} catch (err) {
setStatus('error', 'Failed to link tasks via SDK: ' + err.message);
}
}
async function createLinkedWorkspaceScenario() {
if (!pb.authStore.isValid) {
alert("Please log in first before creating linked workspace data.");
return;
}
setStatus('', 'Creating a task and linked note scenario via the SDK...');
try {
const { createTasgridTask, createTasgridNote, TASGRID_SDK_SIGNATURE } = await loadSdkModule();
const timestamp = new Date().toISOString().replace(/[^\d]/g, '').slice(0, 12);
const workspaceTag = `#sdk-workspace-${timestamp}`;
const taskRecord = await createTasgridTask(pb, {
title: `SDK Workspace Task ${timestamp}`,
priority: 8,
urgency: 4,
size: 3,
tags: ['sdk_test', workspaceTag],
shareModeByTag: {
[workspaceTag.toLowerCase()]: 'collaborative'
},
content: 'Workspace task created by the test simulator.'
});
const noteRecord = await createTasgridNote(pb, {
title: `SDK Workspace Note ${timestamp}`,
tags: ['sdk_test'],
content: `<p>Workspace note for task <code>${taskRecord.id}</code>.</p>`,
noteShareMode: 'collaborative',
isPrivate: false,
tasks: [taskRecord.id],
linkTasks: true
});
lastCreatedTaskId = taskRecord.id;
lastCreatedNoteId = noteRecord.id;
document.getElementById('note-id-input').value = noteRecord.id;
document.getElementById('sdk-note-task-ids').value = taskRecord.id;
fillLinkingFields();
refreshSdkMeta();
loadSpecificNote();
const inspection = await inspectTaskContextState(taskRecord.id);
const legacyText = inspection.legacyUser ? `legacy user mirror still present: ${inspection.legacyUser}` : 'no legacy task.user link';
setStatus('success', `Created linked workspace scenario via SDK ${TASGRID_SDK_SIGNATURE || '(unknown signature)'}. Task ${taskRecord.id} and note ${noteRecord.id} were created, loaded into the embed, and verified with shareRefs=${inspection.shareRefCount}, noteRefs=${inspection.noteRefCount}, ${legacyText}.`);
} catch (err) {
setStatus('error', 'Failed to create linked workspace scenario: ' + err.message);
}
}
@@ -309,21 +710,17 @@ function syncAuth(iframe) {
const email = document.getElementById('email').value;
const password = document.getElementById('password').value;
statusEl.className = 'status';
statusEl.textContent = 'Logging in...';
statusEl.style.display = 'block';
setStatus('', 'Logging in...');
try {
const authData = await pb.collection('users').authWithPassword(email, password);
statusEl.className = 'status success';
statusEl.textContent = `Logged in as ${authData.record.email}! Syncing iframes...`;
setStatus('success', `Logged in as ${authData.record.email}! Syncing iframes...`);
// Automatically send auth to iframes
sendAuth(pb.authStore.token, authData.record);
} catch (err) {
statusEl.className = 'status error';
statusEl.textContent = 'Login failed: ' + err.message;
setStatus('error', 'Login failed: ' + err.message);
}
}
@@ -355,12 +752,11 @@ function syncAuth(iframe) {
window.addEventListener('load', () => {
if (pb.authStore.isValid && pb.authStore.model) {
document.getElementById('email').value = pb.authStore.model.email || "";
statusEl.className = 'status success';
statusEl.textContent = 'Session restored. Click login or send manual token to sync.';
statusEl.style.display = 'block';
setStatus('success', 'Session restored. Click login or send manual token to sync.');
}
refreshSdkMeta();
});
</script>
</body>
</html>
</html>
+3 -1
View File
@@ -10,7 +10,9 @@
"DOM.Iterable"
],
"types": [
"vite/client"
"vite/client",
"vite-plugin-pwa/client",
"vite-plugin-pwa/solid"
],
"skipLibCheck": true,
/* Bundler mode */
+70 -66
View File
@@ -1,75 +1,79 @@
import { defineConfig } from 'vite'
import { defineConfig, loadEnv } from 'vite'
import solid from 'vite-plugin-solid'
import { VitePWA } from 'vite-plugin-pwa'
import tailwindcss from '@tailwindcss/vite'
import path from 'path'
import { readFileSync } from 'fs'
export default defineConfig({
plugins: [
solid(),
tailwindcss(),
VitePWA({
registerType: 'autoUpdate',
includeAssets: ['icon.webp', 'vite.svg'],
manifest: {
name: 'Tasgrid',
short_name: 'Tasgrid',
description: 'Precision Productivity Task Manager',
theme_color: '#0a0a0a',
background_color: '#0a0a0a',
display: 'standalone',
icons: [
{
src: 'icon.webp',
sizes: '1024x1024',
type: 'image/webp',
purpose: 'any'
},
{
src: 'icon.webp',
sizes: '192x192',
type: 'image/webp',
purpose: 'maskable'
},
{
src: 'icon.webp',
sizes: '512x512',
type: 'image/webp',
purpose: 'any'
}
]
},
workbox: {
globPatterns: ['**/*.{js,css,html,ico,png,svg,woff2}'],
runtimeCaching: [
{
urlPattern: /^https:\/\/pocketbase\.ccllc\.pro\/api\/.*/i,
handler: 'NetworkFirst',
options: {
cacheName: 'api-cache',
expiration: {
maxEntries: 100,
maxAgeSeconds: 60 * 60 * 24 * 7 // 1 week
},
cacheableResponse: {
statuses: [0, 200]
}
const packageJson = JSON.parse(
readFileSync(new URL('./package.json', import.meta.url), 'utf-8')
);
export default defineConfig(({ mode }) => {
const env = loadEnv(mode, process.cwd(), '')
const dataMode = (env.VITE_TASGRID_DATA_MODE || 'prod').toLowerCase()
const isDevData = dataMode === 'dev'
return {
define: {
__APP_VERSION__: JSON.stringify(packageJson.version),
},
plugins: [
solid(),
tailwindcss(),
VitePWA({
registerType: 'prompt',
includeAssets: ['icon.webp', 'vite.svg'],
manifest: {
name: isDevData ? 'Tasgrid Dev' : 'Tasgrid',
short_name: isDevData ? 'TasgridDev' : 'Tasgrid',
description: isDevData ? 'Tasgrid development data lane' : 'Precision Productivity Task Manager',
theme_color: '#0a0a0a',
background_color: '#0a0a0a',
display: 'standalone',
icons: [
{
src: 'icon.webp',
sizes: '1024x1024',
type: 'image/webp',
purpose: 'any'
},
{
src: 'icon.webp',
sizes: '192x192',
type: 'image/webp',
purpose: 'maskable'
},
{
src: 'icon.webp',
sizes: '512x512',
type: 'image/webp',
purpose: 'any'
}
}
]
]
},
workbox: {
cleanupOutdatedCaches: true,
globPatterns: ['**/*.{js,css,html,ico,png,svg,woff2}'],
runtimeCaching: []
}
})
],
server: {
host: '0.0.0.0',
port: 4000,
cors: { origin: '*' },
headers: {
'Access-Control-Allow-Origin': '*'
},
allowedHosts: [
'tasgrid.ccllc.pro'
]
},
resolve: {
alias: {
"@": path.resolve(__dirname, "./src")
}
})
],
server: {
host: '0.0.0.0',
port: 4000,
allowedHosts: [
'tasgrid.ccllc.pro'
]
},
resolve: {
alias: {
"@": path.resolve(__dirname, "./src")
}
}
})
})