Compare commits

...

3 Commits

Author SHA1 Message Date
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
18 changed files with 5471 additions and 4417 deletions
+3 -1
View File
@@ -1,2 +1,4 @@
VITE_TASGRID_ENABLE_PROD_MIGRATION=false
VITE_TASGRID_MIGRATION_ADMIN_EMAILS=tcardoza@cardoza.construction
VITE_TASGRID_MIGRATION_ADMIN_EMAILS=tcardoza@cardoza.construction
VITE_FIREWORKS_API_KEY=fw_2QZReEeWXyJsuYn3JWffTh
VITE_FIREWORKS_HELP_MODEL=accounts/fireworks/models/gpt-oss-20b
+822 -77
View File
@@ -1,114 +1,859 @@
# 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.
## Purpose and Scope
---
- 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.
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 bottom of the sidebar includes `Help`, `Settings`, and the context switcher.
- 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`
- `Matrix`
- grouped `Value` views for `Priority` and `Urgency`
- grouped `Flow` views for `Snowball`, `Dig In`, and `Progress`
- `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:
### Help 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."
- 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>`.
---
## Tasks
## Advanced Features (Teams & Automation)
### Task Creation
### Sharing & Collaboration
- 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
Collaboration in TaskGrid is flexible and transparent:
### Default Task Values and Interpretation
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.
- 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
### Team Spaces (Buckets)
### Quick Entry Shorthand
"Buckets" are shared areas for teams (like "Shop" or "Maintenance").
- 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.
- **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.
### Task Detail Editing
### Oversight (Share All)
- 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.
- 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.
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 Card Actions
### Automatic Tasks (Recurrence)
- 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.
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 Status and Progress
- 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 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.
### Bulk Import (Filling a Bucket)
### Due Date, Urgency, Priority, and Size
Need to add a long list of tasks at once?
- `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.
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**.
### 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.
## Power User Tips
### Task Deletion, Trash, and Auto-Maintenance
- **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.
- 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.
---
TasGrid also performs maintenance-related auto-trash behavior:
### Need help?
- 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
If the app feels out of sync, a quick [**refresh**](#) usually fixes it. Most updates happen automatically in real-time\!
These behaviors are important because a user may feel a task has disappeared when it has actually moved to Trash automatically.
If you have any other questions contact Timothy Cardoza or IT.
### 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, Sharing, Buckets, and Supervision
### 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 at the bottom of the sidebar.
- Mobile shows it through the floating control.
### 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.
- Documentation should not describe a separate manual share dialog as the main expected workflow.
### 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.
- 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
### 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`
### 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.
### 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
- 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
### 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
### 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.
## Important Constraints and User-Facing Caveats
- Sharing is primarily tag-driven, not driven by a separate share dialog.
- 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.
## 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
## 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`.
## 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
+74 -13
View File
@@ -1,11 +1,11 @@
import { type Component, createSignal, onMount, onCleanup, createMemo, lazy, Suspense, Show } from 'solid-js';
import { type Component, createSignal, onMount, onCleanup, createMemo, createEffect, lazy, Suspense, Show } 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 } from './store';
// const CriticalView = lazy(() => import('./views/CriticalView').then(m => ({ default: m.CriticalView })));
const UrgencyView = lazy(() => import('./views/UrgencyView').then(m => ({ default: m.UrgencyView })));
@@ -16,19 +16,66 @@ 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 App: Component = () => {
// Basic routing state
const [currentView, setCurrentView] = createSignal("critical");
const [lastNonAIView, setLastNonAIView] = createSignal("critical");
const [isAuthenticated, setIsAuthenticated] = createSignal(pb.authStore.isValid);
const [location, setLocation] = createSignal(window.location.href);
const [isDesktop, setIsDesktop] = createSignal(window.matchMedia("(min-width: 768px)").matches);
createEffect(() => {
const view = currentView();
if (view !== "help_ai") {
setLastNonAIView(view);
}
});
const effectiveView = createMemo(() =>
currentView() === "help_ai" && isDesktop() ? lastNonAIView() : currentView()
);
onMount(() => {
const handleLocChange = () => setLocation(window.location.href);
const mediaQuery = window.matchMedia("(min-width: 768px)");
const openHelpGuide = (sectionId?: string) => {
const nextHash = sectionId ? `#help/${sectionId}` : "#help";
if (window.location.hash !== nextHash) {
window.location.hash = nextHash;
}
setStore('isNotepadMode', false);
setCurrentView("help");
window.dispatchEvent(new CustomEvent("taskgrid:help-target", { detail: { sectionId } }));
};
const openAIHelp = () => {
setStore('isNotepadMode', false);
setCurrentView("help_ai");
};
const handleMediaChange = (event: MediaQueryListEvent) => {
setIsDesktop(event.matches);
};
const handleLocChange = () => {
setLocation(window.location.href);
const hash = window.location.hash;
if (hash === "#help" || hash.startsWith("#help/")) {
setCurrentView("help");
}
};
(window as any).setCurrentView = setCurrentView;
(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) => {
@@ -43,8 +90,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
@@ -59,6 +110,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');
@@ -67,6 +119,8 @@ const App: Component = () => {
setTimeout(() => splash.remove(), 500);
}
});
handleLocChange();
});
// Determine if we are in an embed route (check both path and hash for compatibility)
@@ -96,17 +150,24 @@ const App: Component = () => {
when={isEmbed()}
fallback={
<Show when={isAuthenticated()} fallback={<AuthCallback onSuccess={() => { }} />}>
<Layout currentView={currentView()} setView={setCurrentView}>
<Layout
currentView={effectiveView()}
setView={setCurrentView}
isAIHelpOpen={currentView() === "help_ai" && isDesktop()}
onToggleAIHelp={() => setCurrentView(currentView() === "help_ai" ? lastNonAIView() : "help_ai")}
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={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>
<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={setCurrentView} /></Show>
<Show when={effectiveView() === "help_ai"}><AIHelpView /></Show>
<Show when={effectiveView() === "help"}><HelpView onBack={() => setCurrentView("settings")} /></Show>
</Suspense>
</Layout>
</Show>
+305
View File
@@ -0,0 +1,305 @@
import { type Component, For, Show, createEffect, createMemo, createSignal } from "solid-js";
import { Bot, LoaderCircle, RefreshCw, Send } from "lucide-solid";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
import { FIREWORKS_API_KEY, FIREWORKS_API_URL, FIREWORKS_HELP_MODEL } from "@/lib/app-config";
import helpGuide from "../../docs/HELP_GUIDE.md?raw";
type ChatMessage = {
role: "user" | "assistant";
content: string;
};
const SYSTEM_PROMPT = `You are the TasGrid AI Help assistant.
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.
Prefer concise, practical answers.
When helpful, mention the relevant section name from the guide.
TASGRID REFERENCE GUIDE
${helpGuide}`;
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 about TasGrid features, workflows, or edge cases. I only answer from the reference guide, so if the guide does not mention something, I will tell you.";
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(() => FIREWORKS_API_KEY.trim().length > 0);
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: "AI Help is not configured yet. Add `VITE_FIREWORKS_API_KEY` to the app environment to enable Fireworks responses."
}
]);
setIsLoading(false);
return;
}
try {
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: "low",
temperature: 0.2,
max_tokens: 700,
messages: [
{ role: "system", content: SYSTEM_PROMPT },
...nextMessages.map(message => ({
role: message.role,
content: message.content
}))
]
})
});
if (!response.ok) {
const bodyText = await response.text();
throw new Error(bodyText || `Request failed with status ${response.status}`);
}
const data = await response.json();
const assistantText = data?.choices?.[0]?.message?.content?.trim();
if (!assistantText) {
throw new Error("The help assistant did not return any text.");
}
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">
<Bot size={16} class="text-primary shrink-0" />
<h1 class={cn("font-semibold tracking-tight", isSidebar() ? "text-sm" : "text-lg")}>AI Help</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()}
fallback={
<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 AI Help. The view is wired to Fireworks model `{FIREWORKS_HELP_MODEL}`.
</div>
}
>
<div class="flex flex-wrap gap-2">
<For each={SUGGESTED_PROMPTS}>
{(prompt) => (
<button
class={cn(
"rounded-full border border-border bg-background 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
ref={messagesViewport}
class={cn(
"min-h-0 flex-1 overflow-y-auto space-y-3 rounded-xl border border-border bg-background",
isSidebar() ? "p-2.5" : "p-3"
)}
>
<For each={messages()}>
{(message) => (
<div class={cn("flex", message.role === "user" ? "justify-end" : "justify-start")}>
<div
class={cn(
"rounded-2xl px-4 py-3 leading-relaxed whitespace-pre-wrap shadow-sm",
isSidebar() ? "max-w-[92%] text-[0.75rem]" : "max-w-[90%] sm:max-w-[80%] text-sm",
message.role === "user"
? "bg-primary text-primary-foreground"
: "bg-card border border-border text-foreground"
)}
>
<div class="mb-1 flex items-center gap-2 text-[0.625rem] font-black uppercase tracking-[0.18em] opacity-70">
<Show when={message.role === "assistant"} fallback={<span>You</span>}>
<Bot size={12} />
<span>Guide Assistant</span>
</Show>
</div>
<div>{message.content}</div>
</div>
</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-[0.75rem]" : "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">
<Bot size={12} />
<span>Guide Assistant</span>
</div>
<div class="flex items-center gap-2 text-muted-foreground">
<LoaderCircle size={14} class="animate-spin" />
Looking through the guide...
</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();
}}
>
<textarea
value={input()}
onInput={(e) => setInput(e.currentTarget.value)}
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 text-[0.75rem]" : "min-h-[96px] px-3 py-2.5 text-sm"
)}
/>
<div class={cn(
"flex gap-2",
isSidebar() ? "flex-col" : "justify-end"
)}>
<Button
type="submit"
class={cn("gap-2 rounded-xl", isSidebar() && "w-full")}
disabled={isLoading() || !input().trim()}
>
<Send size={14} />
Ask AI Help
</Button>
</div>
</form>
</div>
</div>
);
};
+38 -10
View File
@@ -15,6 +15,9 @@ interface LayoutProps {
children: JSX.Element;
currentView: string;
setView: (v: string) => void;
isAIHelpOpen?: boolean;
onToggleAIHelp?: () => void;
hideQuickEntry?: boolean;
}
export const Layout: Component<LayoutProps> = (props) => {
@@ -70,13 +73,27 @@ export const Layout: Component<LayoutProps> = (props) => {
});
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() ? `${sidebarWidthClass()} translate-x-0` : `${sidebarWidthClass()} -translate-x-full`,
!isSidebarLocked() && (isSidebarPeeking() || (store.isNotepadMode && !selectedNoteId())) && "translate-x-0 shadow-2xl ring-1 ring-border"
)}
onMouseEnter={() => !isSidebarLocked() && setIsSidebarPeeking(true)}
@@ -97,6 +114,8 @@ export const Layout: Component<LayoutProps> = (props) => {
<Sidebar
currentView={props.currentView}
setView={props.setView}
isAIHelpOpen={!!props.isAIHelpOpen}
onToggleAIHelp={() => props.onToggleAIHelp?.()}
isLocked={isSidebarLocked()}
setIsLocked={setIsSidebarLocked}
isPeeking={isSidebarPeeking()}
@@ -149,7 +168,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() && (
@@ -212,15 +231,20 @@ export const Layout: Component<LayoutProps> = (props) => {
{/* Main Content Area */}
<main class={cn(
"flex-1 min-w-0 overflow-y-auto overflow-x-hidden relative scroll-smooth",
"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 min-h-full mx-auto",
!store.isNotepadMode ? "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"
"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"}>
<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}
@@ -238,9 +262,11 @@ export const Layout: Component<LayoutProps> = (props) => {
</div>
</div>
<Suspense>
<QuickEntry />
</Suspense>
<Show when={!props.hideQuickEntry}>
<Suspense>
<QuickEntry />
</Suspense>
</Show>
<Show when={activeTask()}>
<Suspense>
@@ -252,5 +278,7 @@ export const Layout: Component<LayoutProps> = (props) => {
</Suspense>
</Show>
</div >
);
})()
);
};
+47 -20
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, Bot } 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, loadTasksForOwner } from "@/store";
import { pb } from "@/lib/pocketbase";
import { createSignal, createEffect } from "solid-js";
import { AIHelpPanel } from "./AIHelpPanel";
interface NavItem {
icon: any;
@@ -34,13 +35,14 @@ 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" },
]
},
{ icon: Bot, label: "AI Help", view: "help_ai" },
{
icon: Gauge, label: "Flow", items: [
{ icon: Snowflake, label: "Snowball", view: "snowball" },
@@ -302,6 +304,8 @@ export const ContextSwitcher: Component<{
export const Sidebar: Component<{
currentView: string;
setView: (v: string) => void;
isAIHelpOpen: boolean;
onToggleAIHelp: () => void;
isLocked: boolean;
setIsLocked: (v: boolean) => void;
isPeeking: boolean;
@@ -354,7 +358,7 @@ export const Sidebar: Component<{
<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
@@ -373,23 +377,45 @@ 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-[34rem] opacity-100" : "max-h-0 opacity-0 pointer-events-none"
)}
>
<AIHelpPanel variant="sidebar" class="h-[32rem]" />
</div>
<div class="px-3 border-t border-border mt-auto pt-3 space-y-1 pb-3">
{/* Context Switcher - Only visible if there are oversight contexts */}
<div class={cn(
"transition-all 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="border-t border-border pt-3 space-y-1">
<button
onClick={props.onToggleAIHelp}
class={cn(
"flex items-center space-x-3 w-full px-3 py-2.5 rounded-lg transition-all duration-200 group text-sm",
props.isAIHelpOpen
? "bg-primary text-primary-foreground shadow-sm"
: "text-muted-foreground hover:bg-muted hover:text-foreground"
)}
>
<Bot size={16} class={cn("transition-transform group-hover:scale-110")} />
<span class="font-medium">AI Help</span>
</button>
<button
onClick={() => props.setView("help")}
class={cn(
@@ -400,7 +426,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")}
@@ -414,6 +440,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>
);
+3
View File
@@ -6,6 +6,9 @@ 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";
+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 -4025
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);
}
};
+176
View File
@@ -0,0 +1,176 @@
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,
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());
};
+352
View File
@@ -0,0 +1,352 @@
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[];
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[];
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: [],
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 || [],
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>
);
};
+198 -269
View File
@@ -1,277 +1,206 @@
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 = () => window.location.hash.startsWith("#help/") ? decodeURIComponent(window.location.hash.slice("#help/".length)) : undefined;
const jumpTo = (id: string) => { if (window.location.hash !== `#help/${id}`) window.location.hash = `#help/${id}`; 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>
);
};
+16
View File
@@ -292,6 +292,10 @@ export const NotepadView: Component<{
window.dispatchEvent(new KeyboardEvent('keydown', { metaKey: true, key: 'k' }));
};
const openHelp = (sectionId?: string) => {
(window as any).openTasGridHelp?.(sectionId);
};
const handleDragStart = () => {
setIsDragging(true);
};
@@ -696,6 +700,18 @@ export const NotepadView: Component<{
</div>
<Show when={isLinkedTasksOpen()}>
<div class="flex items-center gap-1">
<Button
size="sm"
variant="ghost"
class="h-7 px-2 text-[0.625rem] font-bold uppercase tracking-wider text-primary hover:bg-primary/10 rounded-lg"
onClick={(e) => {
e.stopPropagation();
openHelp("notes");
}}
title="Open note linking help"
>
Help
</Button>
<Button
size="icon"
variant="ghost"
+44 -2
View File
@@ -62,6 +62,21 @@ export const SettingsView: Component<{ setView?: (v: string) => void }> = (props
.filter(context => !context.deletedAt && context.kind === "user")
.sort((a, b) => a.displayName.localeCompare(b.displayName));
const openHelp = (sectionId?: string) => {
if ((window as any).openTasGridHelp) {
(window as any).openTasGridHelp(sectionId);
return;
}
if (sectionId) {
window.location.hash = `#help/${sectionId}`;
}
if (props.setView) {
props.setView("help");
} else {
(window as any).setCurrentView?.("help");
}
};
return (
<div class="w-full max-w-2xl mx-auto py-4 sm:py-10 px-4 sm:px-0 space-y-8 sm:space-y-12 animate-in fade-in slide-in-from-bottom-2 duration-500 min-w-0 overflow-hidden text-balance">
<header class="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
@@ -101,8 +116,8 @@ export const SettingsView: Component<{ setView?: (v: string) => void }> = (props
<HelpCircle size={20} />
</div>
<div class="text-left">
<h3 class="text-sm font-bold">User Help Guide</h3>
<p class="text-[0.625rem] text-muted-foreground">Learn how to use TaskGrid effectively.</p>
<h3 class="text-sm font-bold">Help</h3>
<p class="text-[0.625rem] text-muted-foreground">Learn how to use TasGrid effectively.</p>
</div>
</div>
<ChevronRight size={16} class="text-muted-foreground group-hover:text-primary transition-colors" />
@@ -125,6 +140,15 @@ export const SettingsView: Component<{ setView?: (v: string) => void }> = (props
Tag Sharing
</h3>
<p class="text-sm text-muted-foreground">Share through tags: `@people` to collaborate, `@buckets` to move tasks to buckets, and `#notes` to link tasks to notes.</p>
<button
class="text-[0.6875rem] font-semibold text-primary hover:underline"
onClick={(e) => {
e.stopPropagation();
openHelp("contexts-sharing");
}}
>
Need help with sharing and contexts?
</button>
</div>
<div class="w-8 h-8 rounded-full flex items-center justify-center bg-muted/50 group-hover:bg-muted transition-colors">
{isSharingOpen() ? <ChevronDown size={16} /> : <ChevronRight size={16} />}
@@ -512,6 +536,15 @@ export const SettingsView: Component<{ setView?: (v: string) => void }> = (props
Shared Buckets
</h3>
<p class="text-sm text-muted-foreground">Buckets are shared spaces where tasks can be moved and collaborated on.</p>
<button
class="text-[0.6875rem] font-semibold text-primary hover:underline"
onClick={(e) => {
e.stopPropagation();
openHelp("buckets-supervision");
}}
>
Need help with buckets and the context switcher?
</button>
</div>
<div class="w-8 h-8 rounded-full flex items-center justify-center bg-muted/50 group-hover:bg-muted transition-colors">
{isBucketsOpen() ? <ChevronDown size={16} /> : <ChevronRight size={16} />}
@@ -947,6 +980,15 @@ export const SettingsView: Component<{ setView?: (v: string) => void }> = (props
Trash
</h3>
<p class="text-sm text-muted-foreground truncate">Expired trash is permanently deleted when the app loads.</p>
<button
class="text-[0.6875rem] font-semibold text-primary hover:underline"
onClick={(e) => {
e.stopPropagation();
openHelp("recovery-trash");
}}
>
Need help recovering something?
</button>
</div>
<div class="w-8 h-8 rounded-full flex items-center justify-center bg-muted/50 group-hover:bg-muted transition-colors">
{isTrashOpen() ? <ChevronDown size={16} /> : <ChevronRight size={16} />}