Compare commits
11 Commits
446d599ed2
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| ec67fb89ca | |||
| 6900a7cee1 | |||
| e0e8f4e7e8 | |||
| 613a2c72f5 | |||
| e45bc9e2b6 | |||
| 7653c6bf0e | |||
| f30b160fa5 | |||
| 82c5f07f7a | |||
| 47257aa007 | |||
| 20cfeaccd3 | |||
| 5fc1318c1d |
+3
-1
@@ -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
|
||||
+868
-77
@@ -1,114 +1,905 @@
|
||||
# TaskGrid User Guide
|
||||
# TasGrid Reference Guide
|
||||
|
||||
## Welcome & Access
|
||||
This document is a thorough reference for how TasGrid works today. It is intended to support future help tooling, deeper troubleshooting, and advanced feature explanations. It is not meant to match the shorter in-app Help screen one-to-one.
|
||||
|
||||
- **Website**: [tasgrid.ccllc.pro](https://tasgrid.ccllc.pro)
|
||||
- **Login**: Use your **Cardoza Construction email** (the same one you use for Outlook). Your password should match your email password as well.
|
||||
## Overview
|
||||
|
||||
---
|
||||
- This guide focuses on end-user and team-lead features inside TasGrid.
|
||||
- It explains both normal workflows and important behavior that may confuse users if it is not documented.
|
||||
- It excludes SDK, API, and integration-developer documentation.
|
||||
- It also excludes developer-only migration tools, admin repair flows, and hidden legacy UI that is not active in normal use.
|
||||
|
||||
## Basic Features (Getting Started)
|
||||
## Core Mental Model
|
||||
|
||||
### Creating Your First Task
|
||||
- TasGrid is built around tasks, notes, and contexts.
|
||||
- Tasks are the primary work items.
|
||||
- Notes are rich project workspaces that can hold content, tabs, linked tasks, and public share links.
|
||||
- Contexts decide where work lives and who can see or act on it.
|
||||
- Contexts are usually represented through special tags:
|
||||
- `@person` for a user's personal context
|
||||
- `@bucket` for a shared bucket context
|
||||
- `#note` for note-linked workspaces
|
||||
- These special tags are not just labels. TasGrid converts them into structured relationships that affect visibility, ownership, and workflow behavior.
|
||||
- For tasks, the important persisted relationship fields are `labelTags`, `shareRefs`, and `noteRefs`.
|
||||
- The user-facing tag list is effectively a derived view built back from those structured relationships, plus the user's hidden favorite tag when applicable.
|
||||
- This means editing tags is not a purely decorative string change. TasGrid reinterprets the tag list and normalizes it back into structured refs.
|
||||
|
||||
1. Click the **large plus (+) sign** in the bottom right corner (or type control \+ k).
|
||||
2. **Title**: Enter a clear name for what needs to be done.
|
||||
3. **Priority**: Choose from 1 (trivial) to 10 (absolutely necessary).
|
||||
4. **Urgency & Due Date**: These are linked\!
|
||||
- Pick an **Urgency level** (10 is today, 1 is 6 months from now), and the due date will be calculated for you.
|
||||
- Alternatively, pick a **Due Date** using the calendar, and the urgency will adjust automatically.
|
||||
5. **Size**: This defaults to **5**. Adjust it if the task is very small (15 mins) or very large (multiple days).
|
||||
6. **Description**: Add notes or details. Use the **forward slash (/)** to reveal formatting options like bold text, checklists, and even adding images.
|
||||
7. Click **Create Task**.
|
||||
## Navigation and Access
|
||||
|
||||
### Viewing Your Tasks
|
||||
### Main App Modes
|
||||
|
||||
TaskGrid sorts your tasks so you don't have to. The primary views are:
|
||||
- TasGrid has two main modes: `Tasks` and `Notes`.
|
||||
- The top-center mode switcher toggles between them.
|
||||
- On desktop, switching modes changes the main sidebar content.
|
||||
- On mobile, Notes opens into a note-list-first flow and hides the bottom navigation while you are inside the note workspace.
|
||||
|
||||
- **✨ Focus (Default)**: Your most important tasks, ranked by a combination of Urgency and Priority.
|
||||
- **📊 Strategy Matrix**: A 4-quadrant grid mapping Urgency vs. Priority.
|
||||
- **Do First**: Urgent & Important.
|
||||
- **Schedule**: Important but not urgent yet.
|
||||
- **Delegate**: Urgent but not important.
|
||||
- **Eliminate**: Neither urgent nor important.
|
||||
- **❄️ Snowball**: Shows your **smallest** tasks first to help you build momentum.
|
||||
- **⛏️ Dig In**: Shows your **largest** tasks first for when you have time to focus.
|
||||
- **🕒 Urgency**: Sorts tasks purely by when they are due.
|
||||
- **⭐ Priority**: Sorts tasks purely by how important they are.
|
||||
### Desktop Navigation
|
||||
|
||||
### Other Info
|
||||
- The left sidebar provides task views:
|
||||
- `Focus`
|
||||
- `Urgency`
|
||||
- `Priority`
|
||||
- `Matrix`
|
||||
- `Snowball`
|
||||
- `Dig In`
|
||||
- `Progress`
|
||||
- The context switcher sits near the top of the sidebar above the task views.
|
||||
- The lower utility area includes:
|
||||
- `Ask Questions`
|
||||
- `Help`
|
||||
- `Settings`
|
||||
- The sidebar supports locked, collapsed, and peek states.
|
||||
|
||||
- **Search & Filters**: Use the bar at the top right to find specific tasks or filter by priority, urgency, or tasks edited today.
|
||||
- **Themes**: Switch between **Light Mode** and **Dark Mode** in the Settings.
|
||||
- **Trash**: Deleted tasks are kept for **7 days** before being permanently removed. You can recover them in Settings.
|
||||
### Mobile Navigation
|
||||
|
||||
### Mobile Installation
|
||||
- The mobile bottom nav exposes:
|
||||
- `Focus`
|
||||
- grouped `Value` views for `Priority` and `Urgency`
|
||||
- `Matrix` inside the `Value` group
|
||||
- grouped `Flow` views for `Snowball`, `Dig In`, and `Progress`
|
||||
- `Ask`
|
||||
- `Settings`
|
||||
- Mobile does not have a dedicated Help tab in bottom navigation.
|
||||
- Help is reached through `Settings > Resources > Help`.
|
||||
- The mobile context switcher is a floating control near the top-left area of the screen.
|
||||
|
||||
TaskGrid works like a dedicated app on your phone:
|
||||
### Ask Questions Access
|
||||
|
||||
- **Android**: Tap the three dots in Chrome, select "Add to Home Screen," then "Install."
|
||||
- **iPhone**: Tap the Share icon in Safari and select "Add to Home Screen."
|
||||
- `Ask Questions` is a built-in question panel for looking up how TasGrid works.
|
||||
- On desktop it opens inline inside the sidebar so it can be used alongside the rest of the app.
|
||||
- On mobile it is a dedicated bottom-nav destination labeled `Ask`.
|
||||
- The current implementation is guide-grounded rather than general-purpose: answers are intended to come from the TasGrid reference material rather than from outside product knowledge.
|
||||
|
||||
---
|
||||
### Help Access
|
||||
|
||||
## Advanced Features (Teams & Automation)
|
||||
- Desktop Help is available from the left sidebar.
|
||||
- Mobile Help is available through `Settings > Resources > Help`.
|
||||
- Some higher-confusion areas include direct Help links that open the relevant help section.
|
||||
- Help supports deep-link anchors in the form `#help/<section-id>`.
|
||||
|
||||
### Sharing & Collaboration
|
||||
## Task Creation and Editing
|
||||
|
||||
Collaboration in TaskGrid is flexible and transparent:
|
||||
### Task Creation
|
||||
|
||||
1. **Manual Sharing**: Open a task and use the **Share** menu to add a specific person.
|
||||
- You can also **unshare** **and split** tasks here. Unshare just removes it from their list and split gives each of you a unique copy of the task.
|
||||
2. **Tag-Based User Sharing**: Add a tag that matches a coworker's name (e.g., "John Smith").
|
||||
- This automatically shares the task with them.
|
||||
- Both of you can edit the task together (Collaborative mode).
|
||||
3. **Collaboration vs. Handoff**:
|
||||
- **Collaborate**: Both users can see and edit.
|
||||
- **Handoff**: The task is transferred to the other person, and they become the new owner.
|
||||
- The main task creation flow is Quick Entry.
|
||||
- Users can open Quick Entry with:
|
||||
- the floating `+` button
|
||||
- `Ctrl+K` or `Cmd+K`
|
||||
- Quick Entry requires:
|
||||
- `Title`
|
||||
- `Priority`
|
||||
- `Urgency`
|
||||
- Optional fields include:
|
||||
- `Size`
|
||||
- `Due Date`
|
||||
- tags
|
||||
- rich description/content
|
||||
- template selection
|
||||
|
||||
### Team Spaces (Buckets)
|
||||
### Default Task Values and Interpretation
|
||||
|
||||
"Buckets" are shared areas for teams (like "Shop" or "Maintenance").
|
||||
- Size defaults to `3`, which represents about one hour of work.
|
||||
- If a task is created with urgency but no due date, TasGrid derives the due date from the urgency setting.
|
||||
- Due date and urgency are linked concepts in the task model.
|
||||
- Creating a task inside an active context often causes TasGrid to inherit that context automatically.
|
||||
- Common examples:
|
||||
- creating a task while switched into a bucket adds that bucket context
|
||||
- creating a task while in a supervised user context applies that personal context
|
||||
- creating a task from a note applies the note's `#note` tag
|
||||
|
||||
- **Move to Bucket**: Simply add a tag that matches the bucket name (e.g., tagging a task with "Shop").
|
||||
- **Visibility**: By default, bucketed tasks are hidden from your main view to keep your list clean. You can view them by selecting the bucket in the sidebar but they will only appear if you have pinned them.
|
||||
- **Pinning**: In order to work in a bucket, you must "Pin" it in Settings so it always shows up in your sidebar.
|
||||
### Quick Entry Shorthand
|
||||
|
||||
### Oversight (Share All)
|
||||
- Quick Entry supports shorthand text commands:
|
||||
- `/pN` to set priority
|
||||
- `/uN` to set urgency
|
||||
- `/t tag` to add a tag
|
||||
- `/t tag:5` to add a tag with a score/value
|
||||
- This lets users create and classify tasks quickly without manually touching every field.
|
||||
|
||||
Managers and team leads can ask their teams to create a "share all" sharing rule in setting. This will add that user's bucket as an option in the sidebar just like shared buckets. Clicking a user's name in your sidebar allows you to view their entire task list, making it easy to track progress across the team.
|
||||
### Task Detail Editing
|
||||
|
||||
### Automatic Tasks (Recurrence)
|
||||
- Opening a task shows the main task detail editing sheet.
|
||||
- Users can edit:
|
||||
- title
|
||||
- status
|
||||
- priority
|
||||
- urgency
|
||||
- size
|
||||
- due date
|
||||
- tags
|
||||
- favorite state
|
||||
- rich description/content
|
||||
- Task detail auto-saves changes.
|
||||
- Some saves are debounced rather than written instantly.
|
||||
- Tag edits are interpreted into `labelTags`, `shareRefs`, and `noteRefs`, then written back as a normalized task state.
|
||||
- If a user inserts an `@person` mention into task content, TasGrid also adds that matching `@person` context tag so the task participates in sharing correctly.
|
||||
|
||||
For recurring work, you can set a task to repeat **Daily**, **Weekly** (on any specified days of the week), or **Monthly** (on a specified day of the month).
|
||||
### Task Card Actions
|
||||
|
||||
- When you mark it complete, TaskGrid automatically "un-completes" it for you when the next cycle begins.
|
||||
- You'll see status labels like "Complete until Monday" to let you know exactly when it will reappear.
|
||||
- Task cards support quick actions without opening the full editor.
|
||||
- Common actions include:
|
||||
- open the task
|
||||
- change status
|
||||
- complete the task
|
||||
- duplicate the task
|
||||
- toggle favorite
|
||||
- Double-clicking a task can complete it quickly.
|
||||
- Some collaboratively shared tasks show update indicators.
|
||||
- There is also a `Collapse until updated` behavior for reducing clutter on ongoing shared work.
|
||||
|
||||
### Bulk Import (Filling a Bucket)
|
||||
## Task Lifecycle and Scheduling
|
||||
|
||||
Need to add a long list of tasks at once?
|
||||
### Task Status and Progress
|
||||
|
||||
1. Go to **Settings** \> **Import Tasks**.
|
||||
2. Paste your list (one task per line).
|
||||
3. Click "Parse and Configure."
|
||||
4. Use the **Bulk Update** tool at the top to set the Priority, Urgency, and Tag (e.g., "Shop") for all tasks at once.
|
||||
5. Update tasks that need individual urgency and priority. (Usually all tasks need individual urgency and priority because if everything is set the same TasGrid can't sort them)
|
||||
6. Click **Import Tasks**.
|
||||
- Task status is numeric from `0` to `10`.
|
||||
- `0` is the starting state.
|
||||
- `10` is complete.
|
||||
- Progress-oriented views sort tasks using status plus other ranking signals.
|
||||
- Recurring tasks that are completed may show a progress ring while waiting to reopen.
|
||||
|
||||
---
|
||||
### Due Date, Urgency, Priority, and Size
|
||||
|
||||
## Power User Tips
|
||||
- `Priority` and `Urgency` both influence ranking, but they are not the same thing.
|
||||
- `Urgency` is closely tied to due date behavior.
|
||||
- `Size` affects how some views rank or group work.
|
||||
- The app calculates combined scores from these factors for several list views.
|
||||
- Users often expect these values to be fully independent, but due date and urgency especially are coupled.
|
||||
|
||||
- **Quick Entry Shorthands**: Type `/p5` (priority), `/u8` (urgency), or `/t Wood` (tags) directly into the title box when creating a task to set properties instantly without touching your mouse!
|
||||
- **Task Templates**: If you have recurring projects with the same notes and tags, create a **Template** in Settings. You can apply it with a single click in the creation menu.
|
||||
- **Matrix Color Pro-tip**: The dots on your Strategy Matrix change color based on **Size**—**Blue** dots are small wins (easy tasks), and **Red** dots are major projects (time-consuming tasks).
|
||||
- **Smart Tagging**: If you are currently viewing a Bucket (e.g., "Shop"), when you click the Plus button to add a task, TaskGrid will automatically add the "Shop" tag for you.
|
||||
### Favorites
|
||||
|
||||
---
|
||||
- Favoriting a task is personal to the current user.
|
||||
- Internally, favorites are handled with hidden per-user tagging behavior.
|
||||
- A task being starred by one person does not automatically mean it is starred for everyone else.
|
||||
|
||||
### Need help?
|
||||
### Task Deletion, Trash, and Auto-Maintenance
|
||||
|
||||
If the app feels out of sync, a quick [**refresh**](#) usually fixes it. Most updates happen automatically in real-time\!
|
||||
- Deleting a task is a soft delete, not an immediate permanent delete.
|
||||
- Soft-deleted tasks go into Trash for 7 days.
|
||||
- The app usually offers an immediate undo prompt after deletion.
|
||||
- Permanent recovery and permanent delete are available in `Settings > Trash`.
|
||||
- Trash is automatically purged on app load when items have exceeded the 7-day retention window.
|
||||
|
||||
If you have any other questions contact Timothy Cardoza or IT.
|
||||
TasGrid also performs maintenance-related auto-trash behavior:
|
||||
|
||||
- completed tasks older than 90 days are automatically moved to trash
|
||||
- tasks with no active personal, shared, or note-linked context can be auto-trashed during initialization
|
||||
|
||||
These behaviors are important because a user may feel a task has disappeared when it has actually moved to Trash automatically.
|
||||
|
||||
### Recurrence
|
||||
|
||||
- Tasks support recurring schedules.
|
||||
- Supported recurrence types are:
|
||||
- daily
|
||||
- weekly
|
||||
- monthly
|
||||
- Weekly recurrence requires selected days.
|
||||
- Monthly recurrence depends on a valid day-of-month configuration.
|
||||
- Completed recurring tasks do not stay permanently complete. TasGrid reopens them when their next recurrence window arrives.
|
||||
- Reopen checks happen on startup and continue periodically while the app is running.
|
||||
|
||||
### Task Templates
|
||||
|
||||
- Task templates let users store reusable task setups.
|
||||
- Template fields include:
|
||||
- template name
|
||||
- title placeholder
|
||||
- priority
|
||||
- urgency
|
||||
- tags
|
||||
- rich description/content
|
||||
- Notably, task templates do not include:
|
||||
- size
|
||||
- explicit due date
|
||||
- When a template is applied, due date is recalculated from urgency rather than copied as a fixed old date.
|
||||
|
||||
### Task Import
|
||||
|
||||
- Settings includes an `Import Tasks` tool for pasting in task lists.
|
||||
- The import flow is:
|
||||
1. paste a task list
|
||||
2. review parsed tasks
|
||||
3. apply bulk defaults
|
||||
4. import
|
||||
- The parser strips common list formatting such as:
|
||||
- bullets
|
||||
- checkboxes
|
||||
- numbered-list prefixes
|
||||
- Each non-empty line becomes one task.
|
||||
- Users can bulk-apply:
|
||||
- priority
|
||||
- urgency
|
||||
- size
|
||||
- tags
|
||||
- Users can still edit or remove individual rows before import.
|
||||
- If no tags are provided, imported tasks default to `work`.
|
||||
- Due date is derived from urgency on import just like normal task creation.
|
||||
|
||||
## Task Views
|
||||
|
||||
### Focus
|
||||
|
||||
- Focus is the main ranking-oriented list view.
|
||||
- It sorts by a combined score using the task's importance and urgency characteristics.
|
||||
- This is often the default working view for deciding what deserves attention next.
|
||||
|
||||
### Urgency
|
||||
|
||||
- Urgency emphasizes time pressure.
|
||||
- It sorts by a continuous urgency score and then falls back to other ranking signals.
|
||||
- It is helpful when due dates and near-term deadlines matter more than task size or stage.
|
||||
|
||||
### Priority
|
||||
|
||||
- Priority is intended as an importance-oriented lens.
|
||||
- In the current implementation, it is not meaningfully different from Focus in the way tasks are sorted.
|
||||
- This is an important documentation caveat because users may expect a completely different ranking.
|
||||
|
||||
### Progress
|
||||
|
||||
- Progress groups and ranks work based on completion state first, then other score factors.
|
||||
- It is useful when users want to move partially completed work forward rather than start new tasks.
|
||||
|
||||
### Snowball
|
||||
|
||||
- Snowball sorts by smaller tasks first.
|
||||
- It is the "quick wins" view.
|
||||
- This is useful when users want momentum, cleanup, or rapid visible progress.
|
||||
|
||||
### Dig In
|
||||
|
||||
- Dig In sorts by larger tasks first.
|
||||
- It is the "deep work" view.
|
||||
- This is useful when users want to tackle heavier tasks rather than clear short items.
|
||||
|
||||
### Matrix
|
||||
|
||||
- Matrix shows tasks on an urgency-versus-priority grid instead of a simple ranked list.
|
||||
- Matrix points are clickable and expose more detail on hover or selection.
|
||||
- Matrix communicates multiple dimensions:
|
||||
- placement reflects urgency and priority
|
||||
- dot size reflects task size
|
||||
- dot color communicates ranking/meaning in the current matrix logic
|
||||
- Tooltips surface key values such as urgency, size, and priority.
|
||||
- Matrix includes time-scale controls:
|
||||
- `1`
|
||||
- `7`
|
||||
- `30`
|
||||
- `60`
|
||||
- `90` days
|
||||
- Changing the time scale changes how the urgency side of the visualization is interpreted.
|
||||
|
||||
## Search, Filters, and Saved Filters
|
||||
|
||||
### Task Search and Filters
|
||||
|
||||
- Task filtering supports:
|
||||
- text query
|
||||
- included tags
|
||||
- priority ranges
|
||||
- urgency ranges
|
||||
- edited today
|
||||
- mine
|
||||
- starred
|
||||
- Included tags use OR-style logic, not strict AND-only matching.
|
||||
- Hidden system tags may not appear in the filter picker unless searched explicitly.
|
||||
- `Mine` is based on personal-context access rules rather than just simplistic creator matching.
|
||||
|
||||
### Saved Task Filters
|
||||
|
||||
- Users can save the current task filter setup as a reusable filter template.
|
||||
- Saved filters are managed from the filter/search area rather than from Settings.
|
||||
- These are useful for repeat operational views like:
|
||||
- follow-up work
|
||||
- specific team contexts
|
||||
- daily supervision checks
|
||||
|
||||
### Note Filters
|
||||
|
||||
- Notes also have filter templates, separate from task filter templates.
|
||||
- They are managed in Notes mode through the note filter/search UI, not through Settings.
|
||||
|
||||
### Load All
|
||||
|
||||
- `Load All` is used when older completed or historical items are not currently loaded into the active store.
|
||||
- This is especially relevant for search and filters because a task cannot be found if it has not been loaded into memory yet.
|
||||
- Users may interpret missing search results as deletion when the actual issue is partial history loading.
|
||||
|
||||
## Contexts and Sharing
|
||||
|
||||
### Context Types
|
||||
|
||||
- TasGrid has three important context types:
|
||||
- personal user contexts through `@person`
|
||||
- shared bucket contexts through `@bucket`
|
||||
- note contexts through `#note`
|
||||
- These are system-backed contexts, not ordinary decorative tags.
|
||||
|
||||
### My Bucket and Personal Contexts
|
||||
|
||||
- Every user effectively has a personal working context.
|
||||
- This is often represented in UI as `My Bucket`.
|
||||
- Personal context affects what a user considers their own active work.
|
||||
- Tasks can move into or out of a user's personal working space through context policies.
|
||||
|
||||
### Context Switcher
|
||||
|
||||
- The context switcher is how users move between available working spaces.
|
||||
- It can show:
|
||||
- `My Bucket`
|
||||
- pinned shared buckets
|
||||
- supervised personal contexts shared with the current user
|
||||
- Desktop places this near the top of the sidebar above the task views.
|
||||
- Mobile shows it through the floating control.
|
||||
|
||||
## Buckets and Supervision
|
||||
|
||||
### Shared Buckets
|
||||
|
||||
- Shared buckets are team contexts created in Settings.
|
||||
- A bucket includes:
|
||||
- required name
|
||||
- color
|
||||
- optional description
|
||||
- New buckets default to `handoff` policy.
|
||||
- Bucket rows support:
|
||||
- inline rename
|
||||
- policy toggle
|
||||
- pin/unpin
|
||||
- archive
|
||||
- Archiving a bucket sets a deleted timestamp on the bucket context.
|
||||
- There is no confirmed user-facing bucket restore flow at this time.
|
||||
|
||||
### Pinned Buckets
|
||||
|
||||
- Pinning a bucket adds it to the context switcher.
|
||||
- Pinning is stored in user preferences.
|
||||
- When a bucket is pinned, TasGrid loads tasks already tagged with that bucket into the local task store.
|
||||
- This matters because unpinned bucket work may not appear in the same way during task search or cross-linking flows.
|
||||
|
||||
### Sharing Model
|
||||
|
||||
- TasGrid's main sharing workflow is tag-driven.
|
||||
- Users share tasks by applying:
|
||||
- an `@person` tag
|
||||
- an `@bucket` tag
|
||||
- a `#note` tag
|
||||
- This is the primary sharing model.
|
||||
- TasGrid parses those tags into structured `shareRefs` and `noteRefs` rather than relying on the display strings alone.
|
||||
- Documentation should not describe a separate manual share dialog as the main expected workflow.
|
||||
|
||||
### `#note` Sharing Semantics
|
||||
|
||||
- Applying a `#note` tag does more than add a label:
|
||||
- it creates a `noteRef`
|
||||
- it also creates a note-kind `shareRef`
|
||||
- A `#note` tag links the task into that note workspace. It does **not** mean "all teammates can now see this task."
|
||||
- Visibility from `#note` linkage is governed by note accessibility:
|
||||
- if the note is private, only the note owner can gain visibility from that note link alone
|
||||
- if the note is public, other authenticated users can gain visibility from the note link because the note is accessible to them
|
||||
- For explicit person-to-person or team sharing, use `@person` and `@bucket`.
|
||||
- If the goal is "make sure another specific teammate can see this task," `#note` alone is not the clearest or safest mechanism unless that teammate already has access to the note.
|
||||
- In implementation terms, note-linked visibility is checked through accessible `noteRefs`, while user and bucket sharing are checked through personal-context and bucket-context rules.
|
||||
|
||||
### Collaborative vs Handoff
|
||||
|
||||
- TasGrid uses two important context policies:
|
||||
- `Collaborative`
|
||||
- `Handoff`
|
||||
- Collaborative means a task stays visible in both places as shared work.
|
||||
- Handoff means responsibility shifts between contexts in a more directional way.
|
||||
- Users can define personal user-tag policies in Settings.
|
||||
- Buckets also carry a policy.
|
||||
- These policies strongly affect whether a task feels shared, moved, or reassigned.
|
||||
|
||||
### Supervision
|
||||
|
||||
- Supervision lets one user enter another user's personal context.
|
||||
- This is broader than sharing a single task.
|
||||
- Personal Context Supervision settings let a user grant specific people permission to switch into that full personal context.
|
||||
- `Personal Contexts Shared With You` lists supervised contexts the current user can access.
|
||||
- Supervised contexts appear in the context switcher alongside buckets.
|
||||
|
||||
### Hidden System Tags
|
||||
|
||||
- Some system-backed context tags are intentionally hidden in normal UI.
|
||||
- Hidden does not mean inactive.
|
||||
- A task may still belong to a person, bucket, or note even when the user does not obviously see the matching context tag in normal task chips.
|
||||
- In particular, the current user's own personal-context tag can be suppressed from visible task chips even though the underlying ref is still active.
|
||||
- This is one of the biggest sources of user confusion and should be treated as normal behavior rather than as data loss.
|
||||
|
||||
### Context Effects on Task Creation and Visibility
|
||||
|
||||
- Creating work from the current context usually applies that context automatically.
|
||||
- Context affects what tasks a user can see in a given working view.
|
||||
- If work appears to vanish after sharing, switching context, or linking to notes, the cause is often context visibility rather than deletion.
|
||||
- Users should check:
|
||||
- current context
|
||||
- active filters
|
||||
- trash
|
||||
- whether the task belongs to an unpinned bucket
|
||||
|
||||
## Notes Basics
|
||||
|
||||
### Notes Mode
|
||||
|
||||
- Notes is a full working mode, not just an attached notes panel.
|
||||
- Switching into Notes triggers note metadata loading.
|
||||
- Desktop replaces the normal task sidebar with a notes sidebar.
|
||||
- Mobile shows the notes list first, then opens the editor as a layered workspace.
|
||||
|
||||
### Notes Sidebar
|
||||
|
||||
- The main notes sidebar lists root notes only.
|
||||
- Child tabs do not appear in the main sidebar list.
|
||||
- The sidebar supports:
|
||||
- title-and-tag search
|
||||
- saved filters
|
||||
- starred filter
|
||||
- edited today filter
|
||||
- owned by me filter
|
||||
- Prism job note filters where applicable
|
||||
- Notes are sorted by title descending, not by most recently updated.
|
||||
|
||||
### Note Loading Model
|
||||
|
||||
- Notes load in two stages:
|
||||
- metadata first
|
||||
- full content when the note is opened
|
||||
- This allows the notes list to appear before every note body has loaded.
|
||||
- If a note is visible in metadata but restricted by visibility rules, its content may fail to hydrate for the current user.
|
||||
|
||||
### Creating Notes
|
||||
|
||||
- Creating a note from the sidebar produces a root note with:
|
||||
- title `New Note`
|
||||
- empty content
|
||||
- empty tags
|
||||
- `isPrivate: false`
|
||||
- a generated normalized key with a timestamp suffix
|
||||
|
||||
### Editing Notes
|
||||
|
||||
- Root note titles are editable inline in the note header.
|
||||
- Selected child tabs can also be renamed inline.
|
||||
- Note content auto-saves on a 500ms debounce.
|
||||
- Leaving a note flushes pending saves immediately.
|
||||
|
||||
### Deleting and Restoring Notes
|
||||
|
||||
- Deleting a note is a soft delete.
|
||||
- Soft delete:
|
||||
- sets `deletedAt`
|
||||
- removes current selection
|
||||
- shows an Undo toast
|
||||
- Restore is available from:
|
||||
- the immediate Undo action
|
||||
- `Settings > Trash`
|
||||
- Permanent delete is exposed from `Settings > Trash`.
|
||||
- Permanent deletion also removes note references from tasks by clearing:
|
||||
- matching `#note` tag
|
||||
- matching `noteRefs`
|
||||
- matching note-based `shareRefs`
|
||||
|
||||
## Note Sharing and Linked Work
|
||||
|
||||
### Public, Private, and Share Links
|
||||
|
||||
- Notes can be public or private.
|
||||
- Metadata loading exposes:
|
||||
- public notes
|
||||
- private notes owned by the current user
|
||||
- Privacy, share-link, and delete controls are owner-only.
|
||||
- Favorites can still be toggled from the note header.
|
||||
- Share Link copies an embed URL like `#/embed/notes?noteId=...`.
|
||||
- If a note is private when the share link is copied, TasGrid prompts the owner to make it public first.
|
||||
- Public notes can be viewed through the embed route without normal auth when the note ID is present.
|
||||
- Public notes are editable in-app by non-owners, but owner-only controls remain restricted.
|
||||
- A public note can also make note-linked tasks visible inside the authenticated app because those tasks pass the note-access visibility check.
|
||||
- A public embed link should be treated as note-content access, not as a guaranteed full unauthenticated task workspace. Linked-task loading still depends on the in-app task-loading path.
|
||||
|
||||
### Child Tabs
|
||||
|
||||
- Root notes can have child tabs.
|
||||
- Child tabs are separate note records tagged with `child-of-{rootId}`.
|
||||
- The root note appears in the tab UI as `General`.
|
||||
- Desktop shows tabs as draggable side ribbons.
|
||||
- Mobile shows tabs in a dropdown.
|
||||
- Tab order is stored through `taborder:{index}` tags.
|
||||
- Dragging tabs updates those tags.
|
||||
- Creating a tab makes a note with:
|
||||
- title `New Tab`
|
||||
- empty content
|
||||
- empty linked tasks array
|
||||
- `child-of-{rootId}` tag
|
||||
- privacy copied from the root note at creation time
|
||||
|
||||
Important privacy caveat:
|
||||
|
||||
- child tabs inherit privacy when they are created
|
||||
- later privacy changes on the root note do not automatically cascade back down to existing child tabs
|
||||
|
||||
### Linked Tasks
|
||||
|
||||
- Notes can act as project workspaces through a linked tasks panel.
|
||||
- Linked tasks belong to the note workspace.
|
||||
- If the user is inside a child tab, linked tasks resolve through the parent/root note workspace.
|
||||
- A task counts as linked if either is true:
|
||||
- it has a matching `noteRef`
|
||||
- it has the matching `#note` tag
|
||||
- In practice, TasGrid treats the root note as the linking owner for that workspace, even when the user is currently inside a child tab.
|
||||
- Linked tasks are shown with incomplete items first, then ranked by combined score.
|
||||
|
||||
Linked task actions include:
|
||||
|
||||
- create a new task from the note
|
||||
- search and link an existing task
|
||||
- open a task from the linked task panel
|
||||
|
||||
Linking constraints:
|
||||
|
||||
- existing-task search returns nothing until the user types
|
||||
- search matches task title only
|
||||
- results are capped at 10
|
||||
- deleted tasks are excluded
|
||||
- tasks from only unpinned buckets are excluded
|
||||
|
||||
Current behavior caveats:
|
||||
|
||||
- child tabs cannot directly unlink tasks
|
||||
- unlink behavior is not perfectly consistent across every tag-linked path
|
||||
|
||||
### Note Rename Propagation
|
||||
|
||||
- Renaming a note updates both:
|
||||
- the note title
|
||||
- the normalized note key
|
||||
- TasGrid also renames the corresponding `#note` tag definition after the note rename.
|
||||
- This helps linked tasks continue to point to the correct note workspace after the name changes.
|
||||
|
||||
## Rich Editor Basics
|
||||
|
||||
### Shared Editor Model
|
||||
|
||||
- Tasks, notes, and task templates all use the same rich editor system.
|
||||
- The editor supports both simple writing and structured embedded content.
|
||||
|
||||
### Basic Content Types
|
||||
|
||||
- Supported content includes:
|
||||
- paragraphs
|
||||
- headings 1 to 3
|
||||
- bullet lists
|
||||
- checklists
|
||||
- multi-checklists
|
||||
- code blocks
|
||||
- blockquotes
|
||||
- horizontal rules
|
||||
- links
|
||||
- highlight
|
||||
- underline
|
||||
- text alignment
|
||||
- tables
|
||||
- images
|
||||
- videos
|
||||
- file attachments
|
||||
- job file embeds
|
||||
- `@user` mentions
|
||||
- `#note` mentions
|
||||
|
||||
### Toolbar and Slash Commands
|
||||
|
||||
- The editor provides a persistent text formatting toolbar.
|
||||
- When the cursor is inside a table, a table toolbar appears.
|
||||
- The placeholder text is `Type '/' for commands or start writing...`.
|
||||
- Typing `/` opens the slash command menu.
|
||||
|
||||
Available slash commands include:
|
||||
|
||||
- Text
|
||||
- Heading 1
|
||||
- Heading 2
|
||||
- Heading 3
|
||||
- Bullet List
|
||||
- Checklist
|
||||
- Multi Checklist
|
||||
- Code Block
|
||||
- Blockquote
|
||||
- Upload
|
||||
- Job File
|
||||
- Divider
|
||||
- Table
|
||||
- Delete Table
|
||||
|
||||
- Slash command filtering is prefix-based against titles and aliases.
|
||||
- The note header also provides shortcut buttons for `Commands` and `Upload`.
|
||||
|
||||
### Mentions
|
||||
|
||||
#### `@user` Mentions
|
||||
|
||||
- `@user` suggestions come from user share contexts.
|
||||
- The current user's own context is excluded from suggestion results.
|
||||
- Matching is based on the start of the display name.
|
||||
- In task detail, inserting an `@user` mention also applies the corresponding `@user` tag for sharing behavior.
|
||||
|
||||
#### `#note` Mentions
|
||||
|
||||
- `#note` suggestions include root notes only, not child tabs.
|
||||
- Matching uses note key or title substring behavior.
|
||||
- If note content is already loaded, the suggestion list can show a short preview.
|
||||
- Clicking a `#note` mention opens a preview popover with:
|
||||
- note title
|
||||
- short content preview
|
||||
- `Open Note`
|
||||
|
||||
Current caveat:
|
||||
|
||||
- in task detail, `Open Note` is wired to actual note navigation
|
||||
- in the notes editor, the preview appears but `Open Note` is not fully wired through the Notes view
|
||||
|
||||
## Rich Editor Uploads and Embeds
|
||||
|
||||
### Uploads and Embedded Media
|
||||
|
||||
- Upload flows are available through:
|
||||
- the Upload button
|
||||
- the slash menu
|
||||
- paste
|
||||
- drag and drop
|
||||
- Uploading requires an active task or active note.
|
||||
|
||||
Upload result behavior:
|
||||
|
||||
- images become inline image blocks
|
||||
- videos become inline video blocks
|
||||
- other files become attachment blocks
|
||||
|
||||
- Most image uploads are converted before upload.
|
||||
- Explicit Upload command flows insert a blank paragraph afterward so the user can continue typing.
|
||||
- Removing an embedded file or media block deletes it from the editor HTML.
|
||||
- When leaving a note, TasGrid checks for orphaned uploads and removes no-longer-referenced files from storage.
|
||||
|
||||
### Images
|
||||
|
||||
- Images render as inline block nodes.
|
||||
- They support:
|
||||
- resize handle
|
||||
- delete overlay
|
||||
- fullscreen preview
|
||||
- Fullscreen preview supports:
|
||||
- zoom by click
|
||||
- wheel zoom
|
||||
- panning while zoomed
|
||||
|
||||
### Videos
|
||||
|
||||
- Videos render inline with:
|
||||
- native playback controls
|
||||
- resize handle
|
||||
- delete overlay
|
||||
|
||||
### File Attachments
|
||||
|
||||
- PDFs and `.txt` files can render inline in an embedded viewer.
|
||||
- Other file types show as attachment cards with filename and open-in-new-tab behavior.
|
||||
- If the first content block in a note is a PDF attachment or a job file viewer, the note uses a top-viewer layout with a large embedded viewer area.
|
||||
|
||||
### Tables
|
||||
|
||||
- Tables are inserted as 3x3 by default with a header row.
|
||||
- They are resizable and selectable as editor nodes.
|
||||
- The table toolbar supports:
|
||||
- insert row above
|
||||
- insert row below
|
||||
- delete row
|
||||
- insert column left
|
||||
- insert column right
|
||||
- delete column
|
||||
- merge cells
|
||||
- split cell
|
||||
- toggle header row
|
||||
- delete table
|
||||
|
||||
### Job File Embeds
|
||||
|
||||
- The editor supports Prism job file embeds through the `/job file` command.
|
||||
- If a job file block has not been configured yet, it shows a picker UI.
|
||||
- The picker tries to infer a job from the active note or one of its ancestor notes using a `job_id:` tag.
|
||||
- If no job is detected, the user can search `Job_Info_Prod` by:
|
||||
- job number
|
||||
- job name
|
||||
- Exposed file categories include:
|
||||
- `plans`
|
||||
- `EST_and_CO`
|
||||
- `Addendums`
|
||||
- `files`
|
||||
- Selecting a file stores:
|
||||
- `jobId`
|
||||
- `collectionId`
|
||||
- `filename`
|
||||
- The final embedded viewer uses the PocketBase file URL inside an iframe-style viewer.
|
||||
|
||||
## Settings
|
||||
|
||||
### Account Header
|
||||
|
||||
- Settings shows the signed-in email at the top.
|
||||
- It also includes:
|
||||
- theme toggle
|
||||
- `Sign Out`
|
||||
|
||||
### Theme
|
||||
|
||||
- The theme system supports:
|
||||
- `light`
|
||||
- `dark`
|
||||
- `system`
|
||||
- Theme preference is stored in local storage under `tasgrid-theme`.
|
||||
- `system` is resolved from the browser or device `prefers-color-scheme`.
|
||||
- The current Settings UI exposes only a visible light/dark toggle.
|
||||
- Documentation should not promise a visible full theme selector with a dedicated system option unless that control is added later.
|
||||
|
||||
### Sharing Settings
|
||||
|
||||
- `Tag Sharing` explains the main context types:
|
||||
- `@person`
|
||||
- `@bucket`
|
||||
- `#note`
|
||||
- `User Tag Policies` lets users decide whether each personal user context behaves as:
|
||||
- `Collaborative`
|
||||
- `Handoff`
|
||||
- This section reflects the current tag/context-based sharing model.
|
||||
|
||||
### Supervision Settings
|
||||
|
||||
- `Personal Context Supervision` lets a user grant specific people access to switch into that user's personal context.
|
||||
- `Personal Contexts Shared With You` shows the contexts the current user can supervise.
|
||||
- These settings are separate from normal task-by-task sharing because they apply to the whole context.
|
||||
|
||||
### Shared Buckets Manager
|
||||
|
||||
- `Shared Buckets` is the bucket management area.
|
||||
- Users can:
|
||||
- create buckets
|
||||
- rename buckets
|
||||
- pin buckets
|
||||
- toggle bucket policy
|
||||
- archive buckets
|
||||
- Bucket creation requires a name and supports color plus optional description.
|
||||
|
||||
### Tag Manager
|
||||
|
||||
- `Tag List` is where users manage normal custom tag definitions.
|
||||
- Users can:
|
||||
- rename a custom tag
|
||||
- recolor it
|
||||
- rescore it from `0` to `10`
|
||||
- delete it
|
||||
- Renaming a tag propagates to tasks that use it.
|
||||
- Deleting a tag removes it from tasks that use it.
|
||||
- `System Tags (Users, Buckets & Notes)` is a separate expandable area.
|
||||
- System tags are primarily reference/configuration artifacts tied to contexts and notes rather than standard freeform tags.
|
||||
|
||||
### Templates Manager
|
||||
|
||||
- `Task Templates` in Settings is the template management surface.
|
||||
- Users can create, rename, edit, and delete templates there.
|
||||
- Saved task and note filters are not managed in this section.
|
||||
|
||||
### Trash
|
||||
|
||||
- Trash contains separate lists for deleted tasks and deleted notes.
|
||||
- Entries show the number of days remaining in the 7-day retention period.
|
||||
- Each entry supports:
|
||||
- recover
|
||||
- permanent delete
|
||||
- The Trash view is the main long-term recovery surface for deleted content.
|
||||
|
||||
### Update App
|
||||
|
||||
- `Update App` checks the service worker for updates.
|
||||
- If an update is available, TasGrid reloads into the new version.
|
||||
- If no update is found quickly, the app shows `No update found`.
|
||||
|
||||
Background update behavior:
|
||||
|
||||
- checks roughly hourly
|
||||
- checks again when the window regains focus or visibility
|
||||
- if an update is ready, it can apply automatically when the app is hidden or after about 10 minutes of idle time
|
||||
|
||||
### Reset PWA
|
||||
|
||||
- `Reset PWA` unregisters service workers, clears browser caches, and reloads the app.
|
||||
- This is a repair tool for stale or broken cached app behavior.
|
||||
- It should be used when the app seems out of sync, stale, or stuck, not as a normal daily action.
|
||||
|
||||
## Progressive Web App Behavior
|
||||
|
||||
### Installation
|
||||
|
||||
- TasGrid is configured as a PWA with standalone display behavior.
|
||||
- Installation is browser-driven.
|
||||
- There is no confirmed custom in-app install prompt flow.
|
||||
- Users should install using the browser's own `Install App` or `Add to Home Screen` controls.
|
||||
|
||||
### Offline and Cached App Behavior
|
||||
|
||||
- Like many PWAs, TasGrid can continue showing older cached assets until a service worker update is applied.
|
||||
- `Update App` and `Reset PWA` exist to help recover from stale-client issues.
|
||||
|
||||
## Common Confusions and Caveats
|
||||
|
||||
- Sharing is primarily tag-driven, not driven by a separate share dialog.
|
||||
- `Ask Questions` is help-oriented and guide-grounded; it should not be treated as an authoritative source for behaviors that are not documented here.
|
||||
- Sharing a task to `#note` does not automatically mean every teammate can see it. It means the task is linked to that note workspace, and visibility then depends on whether the note itself is accessible.
|
||||
- Context tags may be hidden while still actively controlling task behavior.
|
||||
- Creating work in the wrong active context can place it somewhere unexpected.
|
||||
- Focus and Priority are not substantially different in current sorting behavior.
|
||||
- Search is only as complete as the currently loaded data set.
|
||||
- `Load All` may be necessary to find older completed work.
|
||||
- Notes search does not search full note body text. It searches title and tags.
|
||||
- The notes list does not include child tabs.
|
||||
- Linking existing tasks from a note uses task title search only and returns at most 10 results.
|
||||
- Task-link suggestions exclude tasks that belong only to unpinned buckets.
|
||||
- Child note tabs inherit privacy only when created, not continuously afterward.
|
||||
- Private note share links require the note to be made public before other users can view them.
|
||||
- Trash is auto-purged after 7 days on app load.
|
||||
- Completed tasks older than 90 days can be auto-moved to trash.
|
||||
- Some tasks with no valid active context can also be auto-trashed.
|
||||
- Bucket archive exists, but a confirmed user-facing bucket restore flow was not identified.
|
||||
- In the notes editor, note mention previews exist, but `Open Note` from that preview is not fully wired through the same way it is in task detail.
|
||||
|
||||
## Reference Notes
|
||||
|
||||
### Explicit Exclusions
|
||||
|
||||
- SDK and API documentation
|
||||
- integration-developer workflows
|
||||
- dev-data migration tools
|
||||
- production migration tools
|
||||
- admin-only repair or maintenance flows
|
||||
- inactive legacy settings UI hidden behind disabled conditions
|
||||
|
||||
### Summary for Future Help-Agent Use
|
||||
|
||||
- The biggest mental models to preserve are:
|
||||
- contexts are first-class behavior, not decorative tags
|
||||
- notes are workspaces, not just text documents
|
||||
- due date and urgency are linked
|
||||
- hidden tags can still matter
|
||||
- several views are ranking lenses over the same work pool
|
||||
- retrieval issues are often context, filtering, or loading issues rather than actual data loss
|
||||
|
||||
## Troubleshooting Checklist
|
||||
|
||||
When a user thinks something is missing or not working, check these in order:
|
||||
|
||||
1. Confirm whether they are in `Tasks` or `Notes`.
|
||||
2. Confirm the active context in the context switcher.
|
||||
3. Check active filters and whether a saved filter is still applied.
|
||||
4. Use `Load All` if the issue may involve older items.
|
||||
5. Check whether the item is in Trash.
|
||||
6. Check whether the task belongs to an unpinned bucket or hidden system context.
|
||||
7. For stale UI behavior, try `Update App`.
|
||||
8. For persistent cache issues, use `Reset PWA`.
|
||||
|
||||
+813
-57
@@ -1,11 +1,21 @@
|
||||
/**
|
||||
* TasGrid SDK
|
||||
*
|
||||
* A headless utility for creating tasks in the TasGrid PocketBase database
|
||||
* from sister applications, ensuring all TasGrid defaults and date calculations
|
||||
* are applied consistently.
|
||||
*
|
||||
* Headless helpers for sister apps that need to create TasGrid tasks and notes
|
||||
* using the same normalized creation model as the current TasGrid app.
|
||||
*/
|
||||
|
||||
const DEFAULT_COLLECTIONS = {
|
||||
tasks: "TasGrid",
|
||||
notes: "TasGrid_Notes",
|
||||
contexts: "TasGrid_Contexts",
|
||||
};
|
||||
|
||||
export const TASGRID_SDK_SIGNATURE = "context-model-v2-2026-03-26";
|
||||
|
||||
const NOTE_HANDOFF_POLICY_TAG = "__note_policy_handoff__";
|
||||
const USER_CONTEXT_KEY_PREFIX = "user-";
|
||||
|
||||
const URGENCY_HOURS = {
|
||||
10: 12,
|
||||
9: 24,
|
||||
@@ -16,7 +26,606 @@ const URGENCY_HOURS = {
|
||||
4: 504,
|
||||
3: 1080,
|
||||
2: 2160,
|
||||
1: 4320
|
||||
1: 4320,
|
||||
};
|
||||
|
||||
const referenceCacheByPb = new WeakMap();
|
||||
|
||||
const normalizeWhitespace = (value) => String(value || "").trim().replace(/\s+/g, " ");
|
||||
|
||||
const normalizeKey = (value) =>
|
||||
normalizeWhitespace(value)
|
||||
.toLowerCase()
|
||||
.replace(/^[@#]+/, "")
|
||||
.replace(/[^a-z0-9]+/g, "-")
|
||||
.replace(/^-+|-+$/g, "");
|
||||
|
||||
export const normalizeContextKey = (value) => normalizeKey(value);
|
||||
export const normalizeNoteKey = (value) => normalizeKey(value);
|
||||
export const buildShareTag = (displayName) => `@${normalizeWhitespace(displayName)}`;
|
||||
export const buildNoteTag = (keyOrTitle) => `#${normalizeWhitespace(keyOrTitle)}`;
|
||||
|
||||
const buildUserContextKey = (userId) => `${USER_CONTEXT_KEY_PREFIX}${userId}`;
|
||||
|
||||
const parseTag = (rawTag) => {
|
||||
const raw = normalizeWhitespace(rawTag);
|
||||
const prefix = raw[0];
|
||||
const display = prefix === "@" || prefix === "#" ? raw.slice(1).trim() : raw;
|
||||
const key = normalizeKey(display);
|
||||
|
||||
if (prefix === "@") {
|
||||
return { kind: "share", raw, key, display };
|
||||
}
|
||||
|
||||
if (prefix === "#") {
|
||||
return { kind: "note", raw, key, display };
|
||||
}
|
||||
|
||||
return { kind: "label", raw, key, display };
|
||||
};
|
||||
|
||||
const parseTags = (rawTags) =>
|
||||
(Array.isArray(rawTags) ? rawTags : [])
|
||||
.map(parseTag)
|
||||
.filter((tag) => !!tag.display);
|
||||
|
||||
const uniqueStrings = (values) => [...new Set((Array.isArray(values) ? values : []).filter(Boolean))];
|
||||
|
||||
const dedupeShareRefs = (refs) => {
|
||||
const seen = new Set();
|
||||
return (Array.isArray(refs) ? refs : []).filter((ref) => {
|
||||
if (!ref) return false;
|
||||
const key = `${ref.kind}:${ref.contextId}:${ref.key}`;
|
||||
if (seen.has(key)) return false;
|
||||
seen.add(key);
|
||||
return true;
|
||||
});
|
||||
};
|
||||
|
||||
const dedupeNoteRefs = (refs) => {
|
||||
const seen = new Set();
|
||||
return (Array.isArray(refs) ? refs : []).filter((ref) => {
|
||||
if (!ref) return false;
|
||||
const key = `${ref.noteId}:${ref.key}`;
|
||||
if (seen.has(key)) return false;
|
||||
seen.add(key);
|
||||
return true;
|
||||
});
|
||||
};
|
||||
|
||||
const getSdkCache = (pb) => {
|
||||
let cache = referenceCacheByPb.get(pb);
|
||||
if (!cache) {
|
||||
cache = {
|
||||
collectionsKey: null,
|
||||
contexts: null,
|
||||
notes: null,
|
||||
updatedByFieldSupport: null,
|
||||
};
|
||||
referenceCacheByPb.set(pb, cache);
|
||||
}
|
||||
return cache;
|
||||
};
|
||||
|
||||
const getCollections = (payload = {}) => ({
|
||||
tasks: payload.collections?.tasks || DEFAULT_COLLECTIONS.tasks,
|
||||
notes: payload.collections?.notes || DEFAULT_COLLECTIONS.notes,
|
||||
contexts: payload.collections?.contexts || DEFAULT_COLLECTIONS.contexts,
|
||||
});
|
||||
|
||||
const resetCacheIfCollectionsChanged = (cache, collections) => {
|
||||
const key = JSON.stringify(collections);
|
||||
if (cache.collectionsKey !== key) {
|
||||
cache.collectionsKey = key;
|
||||
cache.contexts = null;
|
||||
cache.notes = null;
|
||||
cache.updatedByFieldSupport = null;
|
||||
}
|
||||
};
|
||||
|
||||
const getCurrentUserId = (pb) => pb?.authStore?.model?.id || null;
|
||||
const getCurrentUserDisplayName = (pb) =>
|
||||
normalizeWhitespace(pb?.authStore?.model?.name || pb?.authStore?.model?.email || "");
|
||||
|
||||
const getRelationId = (value) => {
|
||||
if (!value) return null;
|
||||
if (typeof value === "string") return value;
|
||||
if (Array.isArray(value)) return value[0] || null;
|
||||
if (typeof value === "object" && typeof value.id === "string") return value.id;
|
||||
return null;
|
||||
};
|
||||
|
||||
const getDefaultSharePolicyByKind = (kind) => {
|
||||
if (kind === "user") return "collaborative";
|
||||
if (kind === "note") return "collaborative";
|
||||
return "handoff";
|
||||
};
|
||||
|
||||
const mapContextRecord = (record) => ({
|
||||
id: record.id,
|
||||
key: record.key || normalizeContextKey(record.displayName || record.name || ""),
|
||||
displayName: record.displayName || record.name || record.key || "",
|
||||
kind: record.kind || "bucket",
|
||||
policy: record.policy || (record.kind === "user" ? "collaborative" : "handoff"),
|
||||
targetUserId: getRelationId(record.targetUserId),
|
||||
deletedAt: record.deletedAt || null,
|
||||
});
|
||||
|
||||
const mapNoteRecord = (record) => ({
|
||||
id: record.id,
|
||||
key: record.key || normalizeNoteKey(record.title || record.id),
|
||||
title: record.title || "Untitled",
|
||||
tags: uniqueStrings(record.tags),
|
||||
isPrivate: !!record.isPrivate,
|
||||
user: getRelationId(record.user) || "",
|
||||
});
|
||||
|
||||
const findContextForShareTag = (contexts, parsedTag) =>
|
||||
contexts.find((context) => !context.deletedAt && context.key === parsedTag.key) ||
|
||||
contexts.find((context) => !context.deletedAt && context.kind === "user" && normalizeContextKey(context.displayName) === parsedTag.key) ||
|
||||
contexts.find((context) => !context.deletedAt && normalizeContextKey(context.displayName) === parsedTag.key) ||
|
||||
contexts.find((context) => !context.deletedAt && buildShareTag(context.displayName).toLowerCase() === parsedTag.raw.toLowerCase()) ||
|
||||
null;
|
||||
|
||||
const getContextByRef = (contexts, notes, ref) =>
|
||||
(ref.kind === "note"
|
||||
? (() => {
|
||||
const note = notes.find((existing) => existing.id === ref.contextId || existing.key === ref.key);
|
||||
return note
|
||||
? {
|
||||
id: note.id,
|
||||
key: note.key,
|
||||
displayName: note.key || note.title,
|
||||
kind: "note",
|
||||
policy: getNoteSharePolicy(note),
|
||||
targetUserId: note.user,
|
||||
deletedAt: null,
|
||||
}
|
||||
: null;
|
||||
})()
|
||||
: null) ||
|
||||
contexts.find((context) => !context.deletedAt && context.id === ref.contextId) ||
|
||||
contexts.find((context) => !context.deletedAt && context.key === ref.key && context.kind === ref.kind) ||
|
||||
contexts.find((context) => !context.deletedAt && context.kind === ref.kind && normalizeContextKey(context.displayName) === ref.key) ||
|
||||
contexts.find((context) => !context.deletedAt && normalizeContextKey(context.displayName) === ref.key) ||
|
||||
null;
|
||||
|
||||
const getNoteByRef = (notes, ref) =>
|
||||
notes.find((note) => note.id === ref.noteId) ||
|
||||
notes.find((note) => note.key === ref.key) ||
|
||||
null;
|
||||
|
||||
const getNoteSharePolicy = (note) =>
|
||||
note?.tags?.includes(NOTE_HANDOFF_POLICY_TAG) ? "handoff" : "collaborative";
|
||||
|
||||
const getTaskSharePolicy = (contexts, notes, ref) => {
|
||||
if (ref.kind === "note") {
|
||||
if (ref.policy) return ref.policy;
|
||||
const note = notes.find((existing) => existing.id === ref.contextId || existing.key === ref.key);
|
||||
return getNoteSharePolicy(note);
|
||||
}
|
||||
|
||||
const context = getContextByRef(contexts, notes, ref);
|
||||
if (ref.policy) return ref.policy;
|
||||
if (context?.policy) return context.policy;
|
||||
|
||||
return getDefaultSharePolicyByKind(ref.kind);
|
||||
};
|
||||
|
||||
const getPersonalContextUserIdFromRef = (contexts, notes, ref) => {
|
||||
if (ref.kind !== "user") return null;
|
||||
|
||||
const context = getContextByRef(contexts, notes, ref);
|
||||
if (context?.targetUserId) {
|
||||
return context.targetUserId;
|
||||
}
|
||||
|
||||
if (ref.key && ref.key.startsWith(USER_CONTEXT_KEY_PREFIX)) {
|
||||
return ref.key.replace(USER_CONTEXT_KEY_PREFIX, "") || null;
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
const getTaskPersonalRefs = (contexts, notes, task) =>
|
||||
(task.shareRefs || []).filter((ref) => ref.kind === "user" && !!getPersonalContextUserIdFromRef(contexts, notes, ref));
|
||||
|
||||
const taskHasLegacyPersonalAccess = (contexts, notes, task, userId) =>
|
||||
task.ownerId === userId &&
|
||||
getTaskPersonalRefs(contexts, notes, task).length === 0 &&
|
||||
!(task.shareRefs || []).some((ref) => ref.kind !== "user" && getTaskSharePolicy(contexts, notes, ref) === "handoff");
|
||||
|
||||
const taskHasPersonalContextAccess = (contexts, notes, task, userId, allowLegacyFallback = true) => {
|
||||
const personalUserIds = [...new Set(
|
||||
(task.shareRefs || [])
|
||||
.filter((ref) => ref.kind === "user")
|
||||
.map((ref) => getPersonalContextUserIdFromRef(contexts, notes, ref))
|
||||
.filter(Boolean)
|
||||
)];
|
||||
|
||||
return personalUserIds.includes(userId) || (allowLegacyFallback && taskHasLegacyPersonalAccess(contexts, notes, task, userId));
|
||||
};
|
||||
|
||||
const buildTaskTags = (contexts, notes, labelTags, shareRefs, noteRefs, favoriteTag) => {
|
||||
const tags = [
|
||||
...(labelTags || []),
|
||||
...(shareRefs || []).map((ref) => {
|
||||
const context = getContextByRef(contexts, notes, ref);
|
||||
if (ref.kind === "note") {
|
||||
return buildNoteTag(context?.key || ref.key);
|
||||
}
|
||||
if (context?.displayName) {
|
||||
return buildShareTag(context.displayName);
|
||||
}
|
||||
if (ref.kind === "user" && ref.key && ref.key.startsWith(USER_CONTEXT_KEY_PREFIX)) {
|
||||
return null;
|
||||
}
|
||||
return buildShareTag(ref.key);
|
||||
}),
|
||||
...(noteRefs || []).map((ref) => {
|
||||
const note = getNoteByRef(notes, ref);
|
||||
return buildNoteTag(note?.key || ref.key);
|
||||
}),
|
||||
];
|
||||
|
||||
if (favoriteTag && !tags.includes(favoriteTag)) {
|
||||
tags.push(favoriteTag);
|
||||
}
|
||||
|
||||
return [...new Set(tags.filter(Boolean))];
|
||||
};
|
||||
|
||||
const buildTaskShareRef = (contexts, notes, parsedTag, existingRefs = [], shareModeOverrides = {}) => {
|
||||
if (parsedTag.kind === "note") {
|
||||
const note = notes.find((existing) => existing.key === parsedTag.key);
|
||||
const nextRefBase = {
|
||||
contextId: note?.id || parsedTag.key,
|
||||
key: parsedTag.key,
|
||||
kind: "note",
|
||||
};
|
||||
const existing = existingRefs.find((ref) => ref.kind === "note" && (ref.contextId === nextRefBase.contextId || ref.key === nextRefBase.key));
|
||||
return {
|
||||
...nextRefBase,
|
||||
policy: shareModeOverrides[parsedTag.raw.toLowerCase()] || existing?.policy || getTaskSharePolicy(contexts, notes, nextRefBase),
|
||||
};
|
||||
}
|
||||
|
||||
const context = findContextForShareTag(contexts, parsedTag);
|
||||
const nextRefBase = {
|
||||
contextId: context?.id || parsedTag.key,
|
||||
key: parsedTag.key,
|
||||
kind: context?.kind || "bucket",
|
||||
};
|
||||
const existing = existingRefs.find((ref) =>
|
||||
ref.kind === nextRefBase.kind &&
|
||||
(ref.contextId === nextRefBase.contextId || ref.key === nextRefBase.key)
|
||||
);
|
||||
|
||||
return {
|
||||
...nextRefBase,
|
||||
policy: shareModeOverrides[parsedTag.raw.toLowerCase()] || existing?.policy || getTaskSharePolicy(contexts, notes, nextRefBase),
|
||||
};
|
||||
};
|
||||
|
||||
const deriveTaskRelationsFromTags = (contexts, notes, tags, existingShareRefs = [], shareModeOverrides = {}) => {
|
||||
const parsedTags = parseTags(tags);
|
||||
const labelTags = parsedTags.filter((tag) => tag.kind === "label").map((tag) => tag.display);
|
||||
const shareRefs = parsedTags
|
||||
.filter((tag) => tag.kind === "share" || tag.kind === "note")
|
||||
.map((tag) => buildTaskShareRef(contexts, notes, tag, existingShareRefs, shareModeOverrides));
|
||||
const noteRefs = parsedTags
|
||||
.filter((tag) => tag.kind === "note")
|
||||
.map((tag) => {
|
||||
const note = notes.find((existing) => existing.key === tag.key);
|
||||
return {
|
||||
noteId: note?.id || tag.key,
|
||||
key: tag.key,
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
labelTags,
|
||||
shareRefs: dedupeShareRefs(shareRefs),
|
||||
noteRefs: dedupeNoteRefs(noteRefs),
|
||||
};
|
||||
};
|
||||
|
||||
const resolveMirroredOwnerId = (contexts, notes, currentOwnerId, shareRefs, preferredOwnerId) => {
|
||||
const personalUserIds = [...new Set(
|
||||
(shareRefs || [])
|
||||
.filter((ref) => ref.kind === "user")
|
||||
.map((ref) => getPersonalContextUserIdFromRef(contexts, notes, ref))
|
||||
.filter(Boolean)
|
||||
)];
|
||||
|
||||
if (personalUserIds.length === 1) {
|
||||
return personalUserIds[0];
|
||||
}
|
||||
|
||||
if (personalUserIds.length > 1 && preferredOwnerId && personalUserIds.includes(preferredOwnerId)) {
|
||||
return preferredOwnerId;
|
||||
}
|
||||
|
||||
return currentOwnerId || null;
|
||||
};
|
||||
|
||||
const applyHandoffs = (contexts, notes, senderUserId, senderPersonalContext, task, updates) => {
|
||||
if (!senderUserId || !taskHasPersonalContextAccess(contexts, notes, task, senderUserId)) {
|
||||
return updates;
|
||||
}
|
||||
|
||||
const refs = updates.shareRefs || task.shareRefs || [];
|
||||
const stripSenderPersonalContext = (inputRefs) =>
|
||||
senderPersonalContext
|
||||
? inputRefs.filter((existingRef) => !(
|
||||
existingRef.kind === "user" &&
|
||||
(existingRef.contextId === senderPersonalContext.id || existingRef.key === senderPersonalContext.key)
|
||||
))
|
||||
: inputRefs;
|
||||
|
||||
for (const ref of refs) {
|
||||
const context = getContextByRef(contexts, notes, ref);
|
||||
if (senderPersonalContext && ref.kind === "user" && (
|
||||
ref.contextId === senderPersonalContext.id ||
|
||||
ref.key === senderPersonalContext.key
|
||||
)) {
|
||||
continue;
|
||||
}
|
||||
if (!context || getTaskSharePolicy(contexts, notes, ref) !== "handoff") continue;
|
||||
|
||||
const nextShareRefs = stripSenderPersonalContext(refs);
|
||||
const labelTags = updates.labelTags || task.labelTags || [];
|
||||
const noteRefs = updates.noteRefs || task.noteRefs || [];
|
||||
const favoriteTag = (updates.tags || task.tags || []).find((tag) => tag.endsWith("_favorite__"));
|
||||
|
||||
if (context.kind === "user" && context.targetUserId) {
|
||||
return {
|
||||
...updates,
|
||||
ownerId: context.targetUserId,
|
||||
shareRefs: nextShareRefs,
|
||||
tags: buildTaskTags(contexts, notes, labelTags, nextShareRefs, noteRefs, favoriteTag),
|
||||
};
|
||||
}
|
||||
|
||||
if (context.kind === "bucket" || context.kind === "note") {
|
||||
return {
|
||||
...updates,
|
||||
shareRefs: nextShareRefs,
|
||||
tags: buildTaskTags(contexts, notes, labelTags, nextShareRefs, noteRefs, favoriteTag),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return updates;
|
||||
};
|
||||
|
||||
const mapTaskRecord = (contexts, notes, record) => {
|
||||
const legacyTags = uniqueStrings(record.tags).filter((tag) => tag !== "__template__");
|
||||
const parsedTags = parseTags(legacyTags);
|
||||
const shareRefs = Array.isArray(record.shareRefs)
|
||||
? record.shareRefs.map((ref) => ({
|
||||
contextId: ref.contextId,
|
||||
key: ref.key,
|
||||
kind: ref.kind,
|
||||
policy: ref.policy,
|
||||
}))
|
||||
: parsedTags
|
||||
.filter((tag) => tag.kind === "share" || tag.kind === "note")
|
||||
.map((tag) => buildTaskShareRef(contexts, notes, tag));
|
||||
|
||||
const noteRefs = Array.isArray(record.noteRefs)
|
||||
? record.noteRefs.map((ref) => ({ noteId: ref.noteId, key: ref.key }))
|
||||
: parsedTags
|
||||
.filter((tag) => tag.kind === "note")
|
||||
.map((tag) => {
|
||||
const note = notes.find((existing) => existing.key === tag.key);
|
||||
return {
|
||||
noteId: note?.id || tag.key,
|
||||
key: tag.key,
|
||||
};
|
||||
});
|
||||
|
||||
const noteShareRefs = noteRefs.map((ref) => ({
|
||||
contextId: ref.noteId || ref.key,
|
||||
key: ref.key,
|
||||
kind: "note",
|
||||
policy: shareRefs.find((existing) => existing.kind === "note" && (existing.contextId === ref.noteId || existing.key === ref.key))?.policy,
|
||||
}));
|
||||
|
||||
const mergedShareRefs = dedupeShareRefs([...shareRefs, ...noteShareRefs]);
|
||||
const labelTags = Array.isArray(record.labelTags)
|
||||
? record.labelTags
|
||||
: parsedTags.filter((tag) => tag.kind === "label").map((tag) => tag.display);
|
||||
const favoriteTag = legacyTags.find((tag) => tag.endsWith("_favorite__"));
|
||||
|
||||
return {
|
||||
id: record.id,
|
||||
title: record.title || "Untitled",
|
||||
tags: buildTaskTags(contexts, notes, labelTags, mergedShareRefs, noteRefs, favoriteTag),
|
||||
labelTags,
|
||||
shareRefs: mergedShareRefs,
|
||||
noteRefs,
|
||||
ownerId: getRelationId(record.user),
|
||||
createdBy: getRelationId(record.createdBy) || getRelationId(record.user),
|
||||
};
|
||||
};
|
||||
|
||||
const loadContexts = async (pb, collections, cache) => {
|
||||
if (cache.contexts) return cache.contexts;
|
||||
|
||||
const records = await pb.collection(collections.contexts).getFullList({
|
||||
sort: "displayName,key",
|
||||
fields: "id,key,displayName,name,kind,policy,targetUserId,deletedAt",
|
||||
requestKey: null,
|
||||
}).catch(() => []);
|
||||
|
||||
cache.contexts = records.map(mapContextRecord);
|
||||
return cache.contexts;
|
||||
};
|
||||
|
||||
const loadNotes = async (pb, collections, cache) => {
|
||||
if (cache.notes) return cache.notes;
|
||||
|
||||
const records = await pb.collection(collections.notes).getFullList({
|
||||
fields: "id,key,title,tags,isPrivate,user",
|
||||
requestKey: null,
|
||||
}).catch(() => []);
|
||||
|
||||
cache.notes = records.map(mapNoteRecord);
|
||||
return cache.notes;
|
||||
};
|
||||
|
||||
const ensureOwnerPersonalContext = async (pb, ownerId, collections, cache) => {
|
||||
if (!ownerId) return null;
|
||||
|
||||
const contexts = await loadContexts(pb, collections, cache);
|
||||
const existing = contexts.find((context) =>
|
||||
!context.deletedAt &&
|
||||
context.kind === "user" &&
|
||||
context.targetUserId === ownerId
|
||||
);
|
||||
if (existing) return existing;
|
||||
|
||||
if (ownerId !== getCurrentUserId(pb)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const displayName = getCurrentUserDisplayName(pb);
|
||||
if (!displayName) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const created = await pb.collection(collections.contexts).create({
|
||||
key: buildUserContextKey(ownerId),
|
||||
displayName,
|
||||
kind: "user",
|
||||
policy: "collaborative",
|
||||
targetUserId: ownerId,
|
||||
}, { requestKey: null });
|
||||
|
||||
const mapped = mapContextRecord(created);
|
||||
cache.contexts = [...contexts, mapped];
|
||||
return mapped;
|
||||
} catch (_err) {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const detectUpdatedBySupport = async (pb, collectionName, cache) => {
|
||||
if (cache.updatedByFieldSupport !== null) {
|
||||
return cache.updatedByFieldSupport;
|
||||
}
|
||||
|
||||
try {
|
||||
const sample = await pb.collection(collectionName).getList(1, 1, {
|
||||
fields: "id,updatedBy",
|
||||
requestKey: null,
|
||||
});
|
||||
const item = sample?.items?.[0];
|
||||
cache.updatedByFieldSupport = !!(item && Object.prototype.hasOwnProperty.call(item, "updatedBy"));
|
||||
} catch (_err) {
|
||||
cache.updatedByFieldSupport = false;
|
||||
}
|
||||
|
||||
return cache.updatedByFieldSupport;
|
||||
};
|
||||
|
||||
const addOwnerContextTag = (tags, ownerContext) => {
|
||||
const nextTags = uniqueStrings(tags);
|
||||
if (!ownerContext?.displayName) return nextTags;
|
||||
|
||||
const ownerTag = buildShareTag(ownerContext.displayName);
|
||||
if (!nextTags.some((tag) => tag.toLowerCase() === ownerTag.toLowerCase())) {
|
||||
nextTags.push(ownerTag);
|
||||
}
|
||||
|
||||
return nextTags;
|
||||
};
|
||||
|
||||
const buildTaskCreatePayload = async (pb, payload = {}) => {
|
||||
const collections = getCollections(payload);
|
||||
const cache = getSdkCache(pb);
|
||||
resetCacheIfCollectionsChanged(cache, collections);
|
||||
|
||||
const senderUserId = getCurrentUserId(pb);
|
||||
const ownerId = payload.owner || senderUserId;
|
||||
const ownerContext = await ensureOwnerPersonalContext(pb, ownerId, collections, cache);
|
||||
const senderPersonalContext = senderUserId
|
||||
? await ensureOwnerPersonalContext(pb, senderUserId, collections, cache)
|
||||
: null;
|
||||
const contexts = await loadContexts(pb, collections, cache);
|
||||
const notes = await loadNotes(pb, collections, cache);
|
||||
|
||||
const urgencyVal = payload.urgency ?? 5;
|
||||
const startDate = payload.startDate || new Date().toISOString();
|
||||
const dueDate = payload.dueDate || calculateDateFromUrgency(urgencyVal);
|
||||
const priority = payload.priority ?? 5;
|
||||
const size = payload.size ?? 3;
|
||||
const baseTags = Array.isArray(payload.tags) && payload.tags.length > 0 ? payload.tags : ["work"];
|
||||
const tagsWithOwnerContext = addOwnerContextTag(baseTags, ownerContext);
|
||||
const content = payload.content || "";
|
||||
const { labelTags, shareRefs, noteRefs } = deriveTaskRelationsFromTags(
|
||||
contexts,
|
||||
notes,
|
||||
tagsWithOwnerContext,
|
||||
[],
|
||||
payload.shareModeByTag || {}
|
||||
);
|
||||
|
||||
const initialTask = {
|
||||
title: payload.title || "New Task",
|
||||
tags: buildTaskTags(contexts, notes, labelTags, shareRefs, noteRefs),
|
||||
labelTags,
|
||||
shareRefs,
|
||||
noteRefs,
|
||||
ownerId: ownerId || "",
|
||||
createdBy: senderUserId || ownerId || "",
|
||||
};
|
||||
|
||||
const handoffUpdates = applyHandoffs(
|
||||
contexts,
|
||||
notes,
|
||||
senderUserId,
|
||||
senderPersonalContext,
|
||||
initialTask,
|
||||
initialTask
|
||||
);
|
||||
|
||||
const finalShareRefs = handoffUpdates.shareRefs || initialTask.shareRefs;
|
||||
const finalOwnerId = resolveMirroredOwnerId(
|
||||
contexts,
|
||||
notes,
|
||||
handoffUpdates.ownerId ?? initialTask.ownerId,
|
||||
finalShareRefs,
|
||||
handoffUpdates.ownerId ?? initialTask.ownerId
|
||||
);
|
||||
|
||||
const finalTask = {
|
||||
...initialTask,
|
||||
...handoffUpdates,
|
||||
ownerId: finalOwnerId,
|
||||
};
|
||||
|
||||
return {
|
||||
collections,
|
||||
cache,
|
||||
createPayload: {
|
||||
createdBy: finalTask.createdBy,
|
||||
title: finalTask.title,
|
||||
startDate,
|
||||
dueDate,
|
||||
priority,
|
||||
status: payload.status ?? 0,
|
||||
completed: payload.completed ?? false,
|
||||
tags: finalTask.tags,
|
||||
labelTags: finalTask.labelTags,
|
||||
shareRefs: finalTask.shareRefs,
|
||||
noteRefs: finalTask.noteRefs,
|
||||
content,
|
||||
size,
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -31,81 +640,228 @@ export const calculateDateFromUrgency = (level) => {
|
||||
};
|
||||
|
||||
/**
|
||||
* Creates a new task in the TasGrid database using the provided PocketBase instance.
|
||||
*
|
||||
* @param {object} pb - An authenticated PocketBase JS SDK instance.
|
||||
* @param {object} payload - The task payload.
|
||||
* @param {string} [payload.title="New Task"] - The task title.
|
||||
* @param {string} [payload.owner] - PocketBase User ID. Defaults to pb.authStore.model.id.
|
||||
* @param {number} [payload.priority=5] - Priority (1-10).
|
||||
* @param {number} [payload.urgency=5] - Urgency (1-10). Used to calculate dueDate if omitted.
|
||||
* @param {number} [payload.size=3] - Size (0-10).
|
||||
* @param {number} [payload.status=0] - Status (0-10).
|
||||
* @param {boolean} [payload.completed=false] - Completion status.
|
||||
* @param {string[]} [payload.tags=[]] - Array of tag strings.
|
||||
* @param {string} [payload.content=""] - HTML string for task description/notes.
|
||||
* @param {string} [payload.dueDate] - Strict ISO Date string. Overrides urgency calculation.
|
||||
* @param {string} [payload.startDate=""] - ISO Date string.
|
||||
* @returns {Promise<object>} The created PocketBase task record.
|
||||
* Creates a new task using TasGrid's current normalized creation model.
|
||||
*
|
||||
* @param {object} pb Authenticated PocketBase instance.
|
||||
* @param {object} payload Task payload.
|
||||
* @param {string} [payload.title="New Task"] Task title.
|
||||
* @param {string} [payload.owner] PocketBase user id whose personal context should own the task.
|
||||
* @param {number} [payload.priority=5] Priority (1-10).
|
||||
* @param {number} [payload.urgency=5] Urgency (1-10). Used when dueDate is omitted.
|
||||
* @param {number} [payload.size=3] Size (0-10).
|
||||
* @param {number} [payload.status=0] Status (0-10).
|
||||
* @param {boolean} [payload.completed=false] Completion status.
|
||||
* @param {string[]} [payload.tags=["work"]] Display tags. Supports labels, @contexts, and #notes.
|
||||
* @param {Record<string,"collaborative"|"handoff">} [payload.shareModeByTag] Share mode overrides keyed by tag.
|
||||
* @param {string} [payload.content=""] HTML content.
|
||||
* @param {string} [payload.dueDate] ISO date string.
|
||||
* @param {string} [payload.startDate] ISO date string.
|
||||
* @param {{tasks?: string, notes?: string, contexts?: string}} [payload.collections] Optional collection overrides.
|
||||
* @returns {Promise<object>} Created PocketBase record.
|
||||
*/
|
||||
export const createTasgridTask = async (pb, payload = {}) => {
|
||||
if (!pb || !pb.authStore || !pb.authStore.isValid || !pb.authStore.model) {
|
||||
throw new Error("Invalid or unauthenticated PocketBase instance provided.");
|
||||
}
|
||||
|
||||
const urgencyVal = payload.urgency ?? 5;
|
||||
|
||||
const taskData = {
|
||||
title: payload.title || "New Task",
|
||||
priority: payload.priority ?? 5,
|
||||
size: payload.size ?? 3,
|
||||
status: payload.status ?? 0,
|
||||
completed: payload.completed ?? false,
|
||||
tags: payload.tags || [],
|
||||
content: payload.content || "",
|
||||
user: payload.owner || pb.authStore.model.id,
|
||||
dueDate: payload.dueDate || calculateDateFromUrgency(urgencyVal),
|
||||
startDate: payload.startDate || "",
|
||||
};
|
||||
const { collections, cache, createPayload } = await buildTaskCreatePayload(pb, payload);
|
||||
const updatedBySupported = await detectUpdatedBySupport(pb, collections.tasks, cache);
|
||||
if (updatedBySupported) {
|
||||
createPayload.updatedBy = getCurrentUserId(pb);
|
||||
}
|
||||
|
||||
try {
|
||||
const record = await pb.collection('TasGrid').create(taskData);
|
||||
return record;
|
||||
return await pb.collection(collections.tasks).create(createPayload, { requestKey: null });
|
||||
} catch (err) {
|
||||
if (updatedBySupported && err?.data?.data?.updatedBy) {
|
||||
delete createPayload.updatedBy;
|
||||
cache.updatedByFieldSupport = false;
|
||||
return await pb.collection(collections.tasks).create(createPayload, { requestKey: null });
|
||||
}
|
||||
console.error("TasGrid SDK: Failed to create task", err);
|
||||
throw err;
|
||||
}
|
||||
};
|
||||
|
||||
const buildNoteCreatePayload = async (pb, payload = {}) => {
|
||||
const collections = getCollections(payload);
|
||||
const cache = getSdkCache(pb);
|
||||
resetCacheIfCollectionsChanged(cache, collections);
|
||||
|
||||
const title = payload.title || "New Note";
|
||||
const explicitKey = normalizeNoteKey(payload.key || "");
|
||||
const keyBase = explicitKey || normalizeNoteKey(title || "new-note") || "new-note";
|
||||
const key = explicitKey || `${keyBase}-${Date.now()}`;
|
||||
const baseTags = uniqueStrings(payload.tags);
|
||||
const nextTags = payload.noteShareMode === "handoff" && !baseTags.includes(NOTE_HANDOFF_POLICY_TAG)
|
||||
? [...baseTags, NOTE_HANDOFF_POLICY_TAG]
|
||||
: baseTags;
|
||||
|
||||
return {
|
||||
collections,
|
||||
cache,
|
||||
noteData: {
|
||||
title,
|
||||
key,
|
||||
content: payload.content || "",
|
||||
tags: nextTags,
|
||||
isPrivate: !!payload.isPrivate,
|
||||
tasks: uniqueStrings(payload.tasks),
|
||||
user: payload.owner || getCurrentUserId(pb),
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Creates a new note in the TasGrid database using the provided PocketBase instance.
|
||||
*
|
||||
* @param {object} pb - An authenticated PocketBase JS SDK instance.
|
||||
* @param {object} payload - The note payload.
|
||||
* @param {string} [payload.title="New Note"] - The note title.
|
||||
* @param {string} [payload.content=""] - HTML string for note content.
|
||||
* @param {string[]} [payload.tags=[]] - Array of tag strings.
|
||||
* @param {boolean} [payload.isPrivate=false] - Whether the note is private.
|
||||
* @param {string[]} [payload.tasks=[]] - Array of task IDs to link.
|
||||
* @param {string} [payload.owner] - PocketBase User ID. Defaults to pb.authStore.model.id.
|
||||
* @returns {Promise<object>} The created PocketBase note record.
|
||||
* Adds current note-link metadata to existing tasks so they behave like note-linked
|
||||
* tasks created inside TasGrid.
|
||||
*
|
||||
* @param {object} pb Authenticated PocketBase instance.
|
||||
* @param {string|object} noteOrId Note id or note-like object with id/key/title/tags/user.
|
||||
* @param {string[]} taskIds Task ids to link.
|
||||
* @param {{collections?: {tasks?: string, notes?: string, contexts?: string}}} [options] Optional collection overrides.
|
||||
* @returns {Promise<object[]>} Updated task records.
|
||||
*/
|
||||
export const linkTasgridTasksToNote = async (pb, noteOrId, taskIds, options = {}) => {
|
||||
if (!pb || !pb.authStore || !pb.authStore.isValid || !pb.authStore.model) {
|
||||
throw new Error("Invalid or unauthenticated PocketBase instance provided.");
|
||||
}
|
||||
|
||||
const collections = getCollections(options);
|
||||
const cache = getSdkCache(pb);
|
||||
resetCacheIfCollectionsChanged(cache, collections);
|
||||
|
||||
const senderUserId = getCurrentUserId(pb);
|
||||
const senderPersonalContext = senderUserId
|
||||
? await ensureOwnerPersonalContext(pb, senderUserId, collections, cache)
|
||||
: null;
|
||||
const contexts = await loadContexts(pb, collections, cache);
|
||||
const notes = await loadNotes(pb, collections, cache);
|
||||
const note = typeof noteOrId === "string"
|
||||
? notes.find((existing) => existing.id === noteOrId) || mapNoteRecord(await pb.collection(collections.notes).getOne(noteOrId, {
|
||||
fields: "id,key,title,tags,isPrivate,user",
|
||||
requestKey: null,
|
||||
}))
|
||||
: {
|
||||
id: noteOrId.id,
|
||||
key: noteOrId.key || normalizeNoteKey(noteOrId.title || noteOrId.id),
|
||||
title: noteOrId.title || "Untitled",
|
||||
tags: uniqueStrings(noteOrId.tags),
|
||||
isPrivate: !!noteOrId.isPrivate,
|
||||
user: noteOrId.user || "",
|
||||
};
|
||||
|
||||
if (!notes.some((existing) => existing.id === note.id)) {
|
||||
cache.notes = [...notes, note];
|
||||
}
|
||||
|
||||
const noteTag = buildNoteTag(note.key || note.title);
|
||||
const uniqueTaskIds = uniqueStrings(taskIds);
|
||||
const updatedBySupported = await detectUpdatedBySupport(pb, collections.tasks, cache);
|
||||
const updatedRecords = [];
|
||||
|
||||
for (const taskId of uniqueTaskIds) {
|
||||
const record = await pb.collection(collections.tasks).getOne(taskId, {
|
||||
fields: "id,title,tags,labelTags,shareRefs,noteRefs,user,createdBy",
|
||||
requestKey: null,
|
||||
});
|
||||
const currentTask = mapTaskRecord(contexts, cache.notes || notes, record);
|
||||
const favoriteTag = currentTask.tags.find((tag) => tag.endsWith("_favorite__"));
|
||||
const nextTags = uniqueStrings([...(currentTask.tags || []), noteTag]);
|
||||
const derived = deriveTaskRelationsFromTags(
|
||||
contexts,
|
||||
cache.notes || notes,
|
||||
nextTags,
|
||||
currentTask.shareRefs,
|
||||
{}
|
||||
);
|
||||
const baseUpdates = {
|
||||
labelTags: derived.labelTags,
|
||||
shareRefs: derived.shareRefs,
|
||||
noteRefs: derived.noteRefs,
|
||||
tags: buildTaskTags(contexts, cache.notes || notes, derived.labelTags, derived.shareRefs, derived.noteRefs, favoriteTag),
|
||||
};
|
||||
|
||||
const handoffUpdates = applyHandoffs(
|
||||
contexts,
|
||||
cache.notes || notes,
|
||||
senderUserId,
|
||||
senderPersonalContext,
|
||||
currentTask,
|
||||
baseUpdates
|
||||
);
|
||||
const finalShareRefs = handoffUpdates.shareRefs || baseUpdates.shareRefs;
|
||||
const finalOwnerId = resolveMirroredOwnerId(
|
||||
contexts,
|
||||
cache.notes || notes,
|
||||
handoffUpdates.ownerId ?? currentTask.ownerId,
|
||||
finalShareRefs,
|
||||
handoffUpdates.ownerId ?? currentTask.ownerId
|
||||
);
|
||||
|
||||
const updatePayload = {
|
||||
tags: handoffUpdates.tags || baseUpdates.tags,
|
||||
labelTags: handoffUpdates.labelTags || baseUpdates.labelTags,
|
||||
shareRefs: finalShareRefs,
|
||||
noteRefs: handoffUpdates.noteRefs || baseUpdates.noteRefs,
|
||||
};
|
||||
|
||||
if (updatedBySupported) {
|
||||
updatePayload.updatedBy = senderUserId;
|
||||
}
|
||||
|
||||
try {
|
||||
const updated = await pb.collection(collections.tasks).update(taskId, updatePayload, { requestKey: null });
|
||||
updatedRecords.push(updated);
|
||||
} catch (err) {
|
||||
if (updatedBySupported && err?.data?.data?.updatedBy) {
|
||||
delete updatePayload.updatedBy;
|
||||
cache.updatedByFieldSupport = false;
|
||||
const updated = await pb.collection(collections.tasks).update(taskId, updatePayload, { requestKey: null });
|
||||
updatedRecords.push(updated);
|
||||
continue;
|
||||
}
|
||||
console.error("TasGrid SDK: Failed to link task to note", err);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
return updatedRecords;
|
||||
};
|
||||
|
||||
/**
|
||||
* Creates a new note using TasGrid's current creation model.
|
||||
*
|
||||
* @param {object} pb Authenticated PocketBase instance.
|
||||
* @param {object} payload Note payload.
|
||||
* @param {string} [payload.title="New Note"] Note title.
|
||||
* @param {string} [payload.key] Optional canonical note key override.
|
||||
* @param {string} [payload.content=""] HTML content.
|
||||
* @param {string[]} [payload.tags=[]] Note tags.
|
||||
* @param {"collaborative"|"handoff"} [payload.noteShareMode] Note workspace share mode.
|
||||
* @param {boolean} [payload.isPrivate=false] Privacy flag.
|
||||
* @param {string[]} [payload.tasks=[]] Task ids to store and link to this note.
|
||||
* @param {boolean} [payload.linkTasks=true] When tasks are provided, also apply task-side note linkage metadata.
|
||||
* @param {string} [payload.owner] PocketBase user id. Defaults to the current auth user.
|
||||
* @param {{tasks?: string, notes?: string, contexts?: string}} [payload.collections] Optional collection overrides.
|
||||
* @returns {Promise<object>} Created PocketBase record.
|
||||
*/
|
||||
export const createTasgridNote = async (pb, payload = {}) => {
|
||||
if (!pb || !pb.authStore || !pb.authStore.isValid || !pb.authStore.model) {
|
||||
throw new Error("Invalid or unauthenticated PocketBase instance provided.");
|
||||
}
|
||||
|
||||
const noteData = {
|
||||
title: payload.title || "New Note",
|
||||
content: payload.content || "",
|
||||
tags: payload.tags || [],
|
||||
isPrivate: payload.isPrivate || false,
|
||||
tasks: payload.tasks || [],
|
||||
user: payload.owner || pb.authStore.model.id,
|
||||
};
|
||||
const { collections, cache, noteData } = await buildNoteCreatePayload(pb, payload);
|
||||
|
||||
try {
|
||||
const record = await pb.collection('TasGrid_Notes').create(noteData);
|
||||
const record = await pb.collection(collections.notes).create(noteData, { requestKey: null });
|
||||
const mappedNote = mapNoteRecord({ ...record, ...noteData });
|
||||
cache.notes = [...(cache.notes || []), mappedNote];
|
||||
|
||||
if ((noteData.tasks || []).length > 0 && payload.linkTasks !== false) {
|
||||
await linkTasgridTasksToNote(pb, mappedNote, noteData.tasks, { collections });
|
||||
}
|
||||
|
||||
return record;
|
||||
} catch (err) {
|
||||
console.error("TasGrid SDK: Failed to create note", err);
|
||||
|
||||
+253
-18
@@ -1,11 +1,12 @@
|
||||
import { type Component, createSignal, onMount, onCleanup, createMemo, lazy, Suspense, Show } from 'solid-js';
|
||||
import { type Component, createSignal, onMount, onCleanup, createMemo, createEffect, lazy, Suspense, Show, untrack } from 'solid-js';
|
||||
import { Layout } from './components/Layout';
|
||||
import { EmbedLayout } from './components/EmbedLayout';
|
||||
import { EmbedAuthWrapper } from './components/EmbedAuthWrapper';
|
||||
import { CriticalView } from './views/CriticalView';
|
||||
import { AuthCallback } from './components/Auth';
|
||||
import { pb } from './lib/pocketbase';
|
||||
import { initStore } from './store';
|
||||
import { initStore, setStore, store, advanceAIHelpTipSectionIndex } from './store';
|
||||
import { generateAIHelpTip, hasFireworksApiKey } from './lib/ai-help';
|
||||
|
||||
// const CriticalView = lazy(() => import('./views/CriticalView').then(m => ({ default: m.CriticalView })));
|
||||
const UrgencyView = lazy(() => import('./views/UrgencyView').then(m => ({ default: m.UrgencyView })));
|
||||
@@ -16,24 +17,242 @@ const SnowballView = lazy(() => import('./views/SnowballView').then(m => ({ defa
|
||||
const DigInView = lazy(() => import('./views/DigInView').then(m => ({ default: m.DigInView })));
|
||||
const ProgressView = lazy(() => import('./views/ProgressView').then(m => ({ default: m.ProgressView })));
|
||||
const HelpView = lazy(() => import('./views/HelpView').then(m => ({ default: m.HelpView })));
|
||||
const AIHelpView = lazy(() => import('./views/AIHelpView').then(m => ({ default: m.AIHelpView })));
|
||||
const NotepadView = lazy(() => import('./views/NotepadView').then(m => ({ default: m.NotepadView })));
|
||||
const QuickEntryForm = lazy(() => import('./components/QuickEntryForm').then(m => ({ default: m.QuickEntryForm })));
|
||||
|
||||
const DEFAULT_VIEW = "critical";
|
||||
const routeToView: Record<string, string> = {
|
||||
critical: "critical",
|
||||
focus: "critical",
|
||||
urgency: "urgency",
|
||||
priority: "priority",
|
||||
matrix: "matrix",
|
||||
snowball: "snowball",
|
||||
"dig-in": "dig_in",
|
||||
"dig_in": "dig_in",
|
||||
progress: "progress",
|
||||
settings: "settings",
|
||||
help: "help",
|
||||
ask: "help_ai",
|
||||
"help-ai": "help_ai",
|
||||
"help_ai": "help_ai",
|
||||
};
|
||||
|
||||
const viewToRoute: Record<string, string> = {
|
||||
critical: "focus",
|
||||
urgency: "urgency",
|
||||
priority: "priority",
|
||||
matrix: "matrix",
|
||||
snowball: "snowball",
|
||||
dig_in: "dig-in",
|
||||
progress: "progress",
|
||||
settings: "settings",
|
||||
help: "help",
|
||||
help_ai: "ask",
|
||||
};
|
||||
|
||||
const normalizeHash = (hash: string) => {
|
||||
if (hash === "#help" || hash.startsWith("#help/")) {
|
||||
return `#/${hash.slice(1)}`;
|
||||
}
|
||||
return hash;
|
||||
};
|
||||
|
||||
const parseHashRoute = (hash: string): { view: string; sectionId?: string } | null => {
|
||||
const normalizedHash = normalizeHash(hash);
|
||||
if (!normalizedHash.startsWith("#/")) return null;
|
||||
|
||||
const routePath = normalizedHash.slice(2);
|
||||
if (!routePath || routePath.startsWith("embed/")) return null;
|
||||
|
||||
const [viewSegment, ...rest] = routePath.split("/");
|
||||
if (decodeURIComponent(viewSegment) === "notes") {
|
||||
return {
|
||||
view: "notes",
|
||||
sectionId: rest.length > 0 ? decodeURIComponent(rest.join("/")) : undefined,
|
||||
};
|
||||
}
|
||||
const view = routeToView[decodeURIComponent(viewSegment)];
|
||||
if (!view) return null;
|
||||
|
||||
if (view === "help") {
|
||||
return {
|
||||
view,
|
||||
sectionId: rest.length > 0 ? decodeURIComponent(rest.join("/")) : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
return { view };
|
||||
};
|
||||
|
||||
const getHashForView = (view: string, sectionId?: string) => {
|
||||
if (view === "notes") {
|
||||
return sectionId ? `#/notes/${encodeURIComponent(sectionId)}` : "#/notes";
|
||||
}
|
||||
const route = viewToRoute[view] || viewToRoute[DEFAULT_VIEW];
|
||||
if (view === "help" && sectionId) {
|
||||
return `#/${route}/${encodeURIComponent(sectionId)}`;
|
||||
}
|
||||
return `#/${route}`;
|
||||
};
|
||||
|
||||
const App: Component = () => {
|
||||
// Basic routing state
|
||||
const [currentView, setCurrentView] = createSignal("critical");
|
||||
const [currentView, setCurrentView] = createSignal(DEFAULT_VIEW);
|
||||
const [lastNonAIView, setLastNonAIView] = createSignal(DEFAULT_VIEW);
|
||||
const [isAuthenticated, setIsAuthenticated] = createSignal(pb.authStore.isValid);
|
||||
const [authUserId, setAuthUserId] = createSignal<string | null>(pb.authStore.model?.id || null);
|
||||
const [location, setLocation] = createSignal(window.location.href);
|
||||
const [isDesktop, setIsDesktop] = createSignal(window.matchMedia("(min-width: 768px)").matches);
|
||||
const [aiHelpTip, setAiHelpTip] = createSignal<string | null>(null);
|
||||
const [tipGeneratedForUserId, setTipGeneratedForUserId] = createSignal<string | null>(null);
|
||||
const [selectedNoteId, setSelectedNoteId] = createSignal<string | null>(null);
|
||||
|
||||
createEffect(() => {
|
||||
const view = currentView();
|
||||
if (view !== "help_ai") {
|
||||
setLastNonAIView(view);
|
||||
}
|
||||
});
|
||||
|
||||
const effectiveView = createMemo(() =>
|
||||
currentView() === "help_ai" && isDesktop() ? lastNonAIView() : currentView()
|
||||
);
|
||||
|
||||
const syncNotesRoute = (noteId?: string | null, options?: { replace?: boolean }) => {
|
||||
syncUrlToView("notes", { sectionId: noteId || undefined, replace: options?.replace });
|
||||
};
|
||||
|
||||
const openNotes = (noteId?: string | null, options?: { replace?: boolean }) => {
|
||||
setStore('isNotepadMode', true);
|
||||
setSelectedNoteId(noteId || null);
|
||||
syncNotesRoute(noteId, options);
|
||||
};
|
||||
|
||||
const openTasks = (view?: string, options?: { replace?: boolean }) => {
|
||||
setStore('isNotepadMode', false);
|
||||
navigateToView(view || currentView(), options);
|
||||
};
|
||||
|
||||
createEffect(() => {
|
||||
const userId = authUserId();
|
||||
const authenticated = isAuthenticated();
|
||||
const desktop = isDesktop();
|
||||
const initializing = store.isInitializing;
|
||||
const hasApiKey = hasFireworksApiKey();
|
||||
const alreadyGeneratedForUser = tipGeneratedForUserId() === userId;
|
||||
const prefsLoadedForUser = !!userId && store.prefId === userId;
|
||||
|
||||
if (!authenticated || !userId || !desktop || initializing || !prefsLoadedForUser || !hasApiKey || alreadyGeneratedForUser) return;
|
||||
|
||||
setTipGeneratedForUserId(userId);
|
||||
const sectionIndex = untrack(() => store.aiHelpTipSectionIndex);
|
||||
void (async () => {
|
||||
try {
|
||||
const nextTip = await generateAIHelpTip(sectionIndex);
|
||||
if (!nextTip) return;
|
||||
setAiHelpTip(nextTip);
|
||||
await advanceAIHelpTipSectionIndex();
|
||||
} catch (err) {
|
||||
console.warn("Tip generation skipped", err);
|
||||
}
|
||||
})();
|
||||
});
|
||||
|
||||
const syncUrlToView = (view: string, options?: { sectionId?: string; replace?: boolean }) => {
|
||||
if (isEmbed()) return;
|
||||
|
||||
const nextHash = getHashForView(view, options?.sectionId);
|
||||
if (window.location.hash === nextHash) return;
|
||||
|
||||
if (options?.replace) {
|
||||
const nextUrl = `${window.location.pathname}${window.location.search}${nextHash}`;
|
||||
window.history.replaceState(window.history.state, "", nextUrl);
|
||||
window.dispatchEvent(new HashChangeEvent("hashchange"));
|
||||
return;
|
||||
}
|
||||
|
||||
window.location.hash = nextHash;
|
||||
};
|
||||
|
||||
const navigateToView = (view: string, options?: { sectionId?: string; replace?: boolean }) => {
|
||||
setCurrentView(view);
|
||||
syncUrlToView(view, options);
|
||||
};
|
||||
|
||||
onMount(() => {
|
||||
const handleLocChange = () => setLocation(window.location.href);
|
||||
const mediaQuery = window.matchMedia("(min-width: 768px)");
|
||||
const openHelpGuide = (sectionId?: string) => {
|
||||
setStore('isNotepadMode', false);
|
||||
navigateToView("help", { sectionId });
|
||||
window.dispatchEvent(new CustomEvent("taskgrid:help-target", { detail: { sectionId } }));
|
||||
};
|
||||
|
||||
const openAIHelp = () => {
|
||||
setStore('isNotepadMode', false);
|
||||
navigateToView("help_ai");
|
||||
};
|
||||
|
||||
const handleMediaChange = (event: MediaQueryListEvent) => {
|
||||
setIsDesktop(event.matches);
|
||||
};
|
||||
|
||||
const handleLocChange = () => {
|
||||
setLocation(window.location.href);
|
||||
|
||||
if (isEmbed()) return;
|
||||
|
||||
const routeState = parseHashRoute(window.location.hash);
|
||||
if (routeState) {
|
||||
if (routeState.view === "notes") {
|
||||
setStore('isNotepadMode', true);
|
||||
setSelectedNoteId(routeState.sectionId || null);
|
||||
return;
|
||||
}
|
||||
|
||||
setStore('isNotepadMode', false);
|
||||
setCurrentView(routeState.view);
|
||||
return;
|
||||
}
|
||||
|
||||
const normalizedHash = normalizeHash(window.location.hash);
|
||||
if (normalizedHash !== window.location.hash) {
|
||||
const nextUrl = `${window.location.pathname}${window.location.search}${normalizedHash}`;
|
||||
window.history.replaceState(window.history.state, "", nextUrl);
|
||||
setLocation(window.location.href);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!window.location.hash) {
|
||||
if (store.isNotepadMode) {
|
||||
syncNotesRoute(selectedNoteId(), { replace: true });
|
||||
} else {
|
||||
syncUrlToView(currentView(), { replace: true });
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
(window as any).setCurrentView = navigateToView;
|
||||
(window as any).openTasGridHelp = openHelpGuide;
|
||||
(window as any).openTasGridAIHelp = openAIHelp;
|
||||
|
||||
window.addEventListener('popstate', handleLocChange);
|
||||
window.addEventListener('hashchange', handleLocChange);
|
||||
mediaQuery.addEventListener('change', handleMediaChange);
|
||||
|
||||
let isFirstRun = true;
|
||||
const removeListener = pb.authStore.onChange((token) => {
|
||||
const wasAuthenticated = isAuthenticated();
|
||||
const previousUserId = authUserId();
|
||||
const nextUserId = token ? (pb.authStore.model?.id || null) : null;
|
||||
setIsAuthenticated(!!token);
|
||||
setAuthUserId(nextUserId);
|
||||
|
||||
if (!token || nextUserId !== previousUserId) {
|
||||
setTipGeneratedForUserId(null);
|
||||
setAiHelpTip(null);
|
||||
}
|
||||
|
||||
if (token && (isFirstRun || !wasAuthenticated)) {
|
||||
initStore();
|
||||
@@ -43,8 +262,12 @@ const App: Component = () => {
|
||||
|
||||
onCleanup(() => {
|
||||
removeListener();
|
||||
delete (window as any).setCurrentView;
|
||||
delete (window as any).openTasGridHelp;
|
||||
delete (window as any).openTasGridAIHelp;
|
||||
window.removeEventListener('popstate', handleLocChange);
|
||||
window.removeEventListener('hashchange', handleLocChange);
|
||||
mediaQuery.removeEventListener('change', handleMediaChange);
|
||||
});
|
||||
|
||||
// Background preload of other views when idle
|
||||
@@ -59,6 +282,7 @@ const App: Component = () => {
|
||||
import('./views/DigInView');
|
||||
import('./views/ProgressView');
|
||||
import('./views/HelpView');
|
||||
import('./views/AIHelpView');
|
||||
|
||||
// Remove splash screen after high priority work is done
|
||||
const splash = document.getElementById('splash');
|
||||
@@ -67,6 +291,8 @@ const App: Component = () => {
|
||||
setTimeout(() => splash.remove(), 500);
|
||||
}
|
||||
});
|
||||
|
||||
handleLocChange();
|
||||
});
|
||||
|
||||
// Determine if we are in an embed route (check both path and hash for compatibility)
|
||||
@@ -75,9 +301,7 @@ const App: Component = () => {
|
||||
const url = new URL(loc);
|
||||
const path = url.pathname;
|
||||
const hash = url.hash;
|
||||
const res = path.startsWith('/embed/') || hash.startsWith('#/embed/');
|
||||
console.log('[DEBUG] isEmbed check:', { res, path, hash, href: loc });
|
||||
return res;
|
||||
return path.startsWith('/embed/') || hash.startsWith('#/embed/');
|
||||
};
|
||||
|
||||
const getEmbedView = () => {
|
||||
@@ -87,7 +311,6 @@ const App: Component = () => {
|
||||
let view = null;
|
||||
if (fullPath.includes('/notes')) view = 'embed_notes';
|
||||
else if (fullPath.includes('/quick-add')) view = 'embed_quick_add';
|
||||
console.log('[DEBUG] getEmbedView detected:', view, 'Full URL context:', fullPath);
|
||||
return view;
|
||||
};
|
||||
|
||||
@@ -96,17 +319,29 @@ const App: Component = () => {
|
||||
when={isEmbed()}
|
||||
fallback={
|
||||
<Show when={isAuthenticated()} fallback={<AuthCallback onSuccess={() => { }} />}>
|
||||
<Layout currentView={currentView()} setView={setCurrentView}>
|
||||
<Layout
|
||||
currentView={effectiveView()}
|
||||
setView={navigateToView}
|
||||
selectedNoteId={selectedNoteId()}
|
||||
setSelectedNoteId={setSelectedNoteId}
|
||||
onOpenTasks={openTasks}
|
||||
onOpenNotes={openNotes}
|
||||
isAIHelpOpen={currentView() === "help_ai" && isDesktop()}
|
||||
onToggleAIHelp={() => navigateToView(currentView() === "help_ai" ? lastNonAIView() : "help_ai")}
|
||||
aiHelpTip={aiHelpTip()}
|
||||
hideQuickEntry={currentView() === "help_ai" && !isDesktop()}
|
||||
>
|
||||
<Suspense fallback={<div class="flex items-center justify-center h-full"><div class="animate-spin rounded-full h-8 w-8 border-b-2 border-primary"></div></div>}>
|
||||
<Show when={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={navigateToView} /></Show>
|
||||
<Show when={effectiveView() === "help_ai"}><AIHelpView /></Show>
|
||||
<Show when={effectiveView() === "help"}><HelpView onBack={() => navigateToView("settings")} /></Show>
|
||||
</Suspense>
|
||||
</Layout>
|
||||
</Show>
|
||||
|
||||
@@ -0,0 +1,281 @@
|
||||
import { type Component, For, Show, createEffect, createMemo, createSignal } from "solid-js";
|
||||
import { MessageCircle, LoaderCircle, RefreshCw, ArrowUp } from "lucide-solid";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { FIREWORKS_HELP_MODEL } from "@/lib/app-config";
|
||||
import { askAIHelp, hasFireworksApiKey } from "@/lib/ai-help";
|
||||
import { MarkdownMessage } from "./MarkdownMessage";
|
||||
|
||||
type ChatMessage = {
|
||||
role: "user" | "assistant";
|
||||
content: string;
|
||||
};
|
||||
|
||||
const SUGGESTED_PROMPTS = [
|
||||
"How does sharing work in TasGrid?",
|
||||
"What is the difference between Handoff and Collaborative?",
|
||||
"How do linked tasks and notes work together?",
|
||||
"Why might a task disappear from my view?",
|
||||
"How do recurrence and templates behave?"
|
||||
];
|
||||
|
||||
const INITIAL_MESSAGE = "Ask a question about how TasGrid works.";
|
||||
|
||||
export const AIHelpPanel: Component<{
|
||||
variant?: "page" | "sidebar";
|
||||
class?: string;
|
||||
}> = (props) => {
|
||||
const variant = () => props.variant ?? "page";
|
||||
const [messages, setMessages] = createSignal<ChatMessage[]>([
|
||||
{ role: "assistant", content: INITIAL_MESSAGE }
|
||||
]);
|
||||
const [input, setInput] = createSignal("");
|
||||
const [isLoading, setIsLoading] = createSignal(false);
|
||||
const [error, setError] = createSignal<string | null>(null);
|
||||
let messagesViewport: HTMLDivElement | undefined;
|
||||
|
||||
const hasApiKey = createMemo(() => hasFireworksApiKey());
|
||||
const showSuggestions = createMemo(() =>
|
||||
hasApiKey() && messages().length === 1 && messages()[0]?.role === "assistant"
|
||||
);
|
||||
|
||||
createEffect(() => {
|
||||
messages();
|
||||
isLoading();
|
||||
queueMicrotask(() => {
|
||||
messagesViewport?.scrollTo({
|
||||
top: messagesViewport.scrollHeight,
|
||||
behavior: "smooth"
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
const submitQuestion = async (questionOverride?: string) => {
|
||||
const question = (questionOverride ?? input()).trim();
|
||||
if (!question || isLoading()) return;
|
||||
|
||||
const priorMessages = messages();
|
||||
const userMessage: ChatMessage = { role: "user", content: question };
|
||||
const nextMessages = [...priorMessages, userMessage];
|
||||
|
||||
setMessages(nextMessages);
|
||||
setInput("");
|
||||
setError(null);
|
||||
setIsLoading(true);
|
||||
|
||||
if (!hasApiKey()) {
|
||||
setMessages([
|
||||
...nextMessages,
|
||||
{
|
||||
role: "assistant",
|
||||
content: "Questions are not configured yet. Add `VITE_FIREWORKS_API_KEY` to the app environment to enable responses."
|
||||
}
|
||||
]);
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const assistantText = await askAIHelp(nextMessages);
|
||||
setMessages(prev => [...prev, { role: "assistant", content: assistantText }]);
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : "Unknown error";
|
||||
setError(message);
|
||||
setMessages(prev => [
|
||||
...prev,
|
||||
{
|
||||
role: "assistant",
|
||||
content: "I couldn't get a response from the help model just now. Try again in a moment."
|
||||
}
|
||||
]);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const resetChat = () => {
|
||||
setMessages([{ role: "assistant", content: INITIAL_MESSAGE }]);
|
||||
setInput("");
|
||||
setError(null);
|
||||
};
|
||||
|
||||
const isSidebar = () => variant() === "sidebar";
|
||||
|
||||
return (
|
||||
<div
|
||||
class={cn(
|
||||
"flex min-h-0 flex-col overflow-hidden",
|
||||
isSidebar()
|
||||
? "rounded-2xl border border-border bg-card"
|
||||
: "h-full rounded-2xl border border-border bg-card",
|
||||
props.class
|
||||
)}
|
||||
>
|
||||
<div class={cn(
|
||||
"flex items-center justify-between gap-3 border-b border-border",
|
||||
isSidebar() ? "px-3 py-3" : "px-4 py-4 sm:px-5"
|
||||
)}>
|
||||
<div class="flex items-center gap-2 min-w-0">
|
||||
<MessageCircle size={16} class="text-primary shrink-0" />
|
||||
<h1 class={cn("font-semibold tracking-tight", isSidebar() ? "text-sm" : "text-lg")}>Ask Questions</h1>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class="gap-2 rounded-lg shrink-0"
|
||||
onClick={resetChat}
|
||||
>
|
||||
<RefreshCw size={13} />
|
||||
New
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div class={cn(
|
||||
"flex min-h-0 flex-1 flex-col",
|
||||
isSidebar() ? "p-3 gap-3" : "p-4 gap-3 sm:p-5"
|
||||
)}>
|
||||
<Show when={!hasApiKey()}>
|
||||
<div class={cn(
|
||||
"rounded-2xl border border-amber-500/30 bg-amber-500/10",
|
||||
isSidebar() ? "p-3 text-[0.75rem]" : "p-4 text-sm"
|
||||
)}>
|
||||
Add `VITE_FIREWORKS_API_KEY` to enable Ask Questions. The view is wired to Fireworks model `{FIREWORKS_HELP_MODEL}`.
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<div
|
||||
ref={messagesViewport}
|
||||
class={cn(
|
||||
"min-h-0 flex-1 overflow-y-auto space-y-3",
|
||||
isSidebar() ? "px-0.5" : "px-0.5"
|
||||
)}
|
||||
>
|
||||
<For each={messages()}>
|
||||
{(message) => (
|
||||
<div class="space-y-2">
|
||||
<div
|
||||
class={cn(
|
||||
"flex",
|
||||
message.role === "user"
|
||||
? "justify-center pt-8 pb-0"
|
||||
: "justify-start"
|
||||
)}
|
||||
>
|
||||
<div
|
||||
class={cn(
|
||||
"rounded-2xl px-4 leading-relaxed whitespace-pre-wrap shadow-sm",
|
||||
message.role === "assistant"
|
||||
? "w-full bg-card border border-border px-4 py-3 text-foreground"
|
||||
: cn(
|
||||
"bg-card px-4 pt-3 pb-0 text-foreground",
|
||||
isSidebar() ? "max-w-[92%]" : "max-w-[90%] sm:max-w-[80%]"
|
||||
)
|
||||
)}
|
||||
>
|
||||
<Show when={message.role === "assistant"}>
|
||||
<div class="mb-1 flex items-center gap-2 text-[0.625rem] font-black uppercase tracking-[0.18em] opacity-70">
|
||||
<MessageCircle size={12} />
|
||||
<span>TasGrid Help</span>
|
||||
</div>
|
||||
</Show>
|
||||
<MarkdownMessage
|
||||
content={message.content}
|
||||
class=""
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<Show when={showSuggestions() && message.role === "assistant"}>
|
||||
<div class={cn("flex flex-wrap gap-2", isSidebar() ? "pl-1" : "pl-1")}>
|
||||
<For each={SUGGESTED_PROMPTS}>
|
||||
{(prompt) => (
|
||||
<button
|
||||
class={cn(
|
||||
"rounded-full border border-border bg-card text-left font-medium text-foreground transition-colors hover:bg-muted",
|
||||
isSidebar() ? "px-2.5 py-1.5 text-[0.6875rem]" : "px-3 py-2 text-xs sm:text-sm"
|
||||
)}
|
||||
onClick={() => void submitQuestion(prompt)}
|
||||
disabled={isLoading()}
|
||||
>
|
||||
{prompt}
|
||||
</button>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
|
||||
<Show when={isLoading()}>
|
||||
<div class="flex justify-start">
|
||||
<div class={cn(
|
||||
"rounded-2xl px-4 py-3 leading-relaxed shadow-sm bg-card border border-border text-foreground",
|
||||
isSidebar() ? "max-w-[92%] text-sm" : "max-w-[80%] text-sm"
|
||||
)}>
|
||||
<div class="mb-1 flex items-center gap-2 text-[0.625rem] font-black uppercase tracking-[0.18em] opacity-70">
|
||||
<MessageCircle size={12} />
|
||||
<span>TasGrid Help</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2 text-muted-foreground">
|
||||
<LoaderCircle size={14} class="animate-spin" />
|
||||
Finding an answer...
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
<Show when={error()}>
|
||||
{(message) => (
|
||||
<div class={cn(
|
||||
"rounded-xl border border-destructive/30 bg-destructive/10 text-destructive",
|
||||
isSidebar() ? "px-3 py-2 text-[0.75rem]" : "px-4 py-3 text-sm"
|
||||
)}>
|
||||
Request error: {message()}
|
||||
</div>
|
||||
)}
|
||||
</Show>
|
||||
|
||||
<form
|
||||
class={cn(
|
||||
"space-y-2"
|
||||
)}
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
void submitQuestion();
|
||||
}}
|
||||
>
|
||||
<div class="relative">
|
||||
<textarea
|
||||
value={input()}
|
||||
onInput={(e) => setInput(e.currentTarget.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
void submitQuestion();
|
||||
}
|
||||
}}
|
||||
placeholder="Ask how a TasGrid feature works, what a setting does, or why something may behave a certain way..."
|
||||
class={cn(
|
||||
"flex w-full rounded-xl border border-input bg-background transition-colors placeholder:text-muted-foreground/60 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring resize-none leading-relaxed",
|
||||
isSidebar()
|
||||
? "min-h-[88px] px-3 py-2.5 pr-14 text-sm"
|
||||
: "min-h-[96px] px-3 py-2.5 pr-14 text-sm"
|
||||
)}
|
||||
/>
|
||||
<Button
|
||||
type="submit"
|
||||
size="icon"
|
||||
class="absolute bottom-3 right-3 h-8 w-8 rounded-full shadow-sm"
|
||||
disabled={isLoading() || !input().trim()}
|
||||
aria-label="Ask Question"
|
||||
title="Ask Question"
|
||||
>
|
||||
<ArrowUp size={15} />
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
+89
-25
@@ -1,6 +1,6 @@
|
||||
import { type Component, type JSX, createSignal, createEffect, Show, lazy, Suspense, onMount, onCleanup } from "solid-js";
|
||||
import { Sidebar, BottomNav, ContextSwitcher } from "./Navigation";
|
||||
import { store, setStore, activeTaskId, setActiveTaskId, requestImmediateNotesMetadataLoad } from "@/store";
|
||||
import { store, activeTaskId, setActiveTaskId, requestImmediateNotesMetadataLoad, fetchNoteById, updateStoreWithNote } from "@/store";
|
||||
import { PanelLeftOpen, PanelLeftClose } from "lucide-solid";
|
||||
|
||||
const TaskDetail = lazy(() => import("./TaskDetail").then(m => ({ default: m.TaskDetail })));
|
||||
@@ -15,27 +15,39 @@ interface LayoutProps {
|
||||
children: JSX.Element;
|
||||
currentView: string;
|
||||
setView: (v: string) => void;
|
||||
selectedNoteId: string | null;
|
||||
setSelectedNoteId: (id: string | null) => void;
|
||||
onOpenTasks: (view?: string, options?: { replace?: boolean }) => void;
|
||||
onOpenNotes: (noteId?: string | null, options?: { replace?: boolean }) => void;
|
||||
isAIHelpOpen?: boolean;
|
||||
onToggleAIHelp?: () => void;
|
||||
aiHelpTip?: string | null;
|
||||
hideQuickEntry?: boolean;
|
||||
}
|
||||
|
||||
export const Layout: Component<LayoutProps> = (props) => {
|
||||
const [isSidebarLocked, setIsSidebarLocked] = createSignal(true);
|
||||
const [isSidebarPeeking, setIsSidebarPeeking] = createSignal(false);
|
||||
const [isDropdownOpen, setIsDropdownOpen] = createSignal(false);
|
||||
const [resolvingRoutedNoteId, setResolvingRoutedNoteId] = createSignal<string | null>(null);
|
||||
|
||||
// Lift selectedNoteId up to Layout so it can be passed to NotesSidebar and NotepadView
|
||||
const [selectedNoteId, setSelectedNoteId] = createSignal<string | null>(null);
|
||||
const setActiveNoteId = (id: string | null) => {
|
||||
props.setSelectedNoteId(id);
|
||||
if (store.isNotepadMode) {
|
||||
props.onOpenNotes(id);
|
||||
}
|
||||
};
|
||||
|
||||
// Make it available globally for QuickEntry auto-tagging without circular deps
|
||||
createEffect(() => {
|
||||
(window as any)._activeNoteId = selectedNoteId();
|
||||
(window as any)._activeNoteId = props.selectedNoteId;
|
||||
});
|
||||
|
||||
onMount(() => {
|
||||
const handleOpenNote = (e: Event) => {
|
||||
const detail = (e as CustomEvent).detail;
|
||||
if (detail && detail.noteId) {
|
||||
setStore('isNotepadMode', true);
|
||||
setSelectedNoteId(detail.noteId);
|
||||
props.onOpenNotes(detail.noteId);
|
||||
}
|
||||
};
|
||||
window.addEventListener('open-note', handleOpenNote);
|
||||
@@ -61,23 +73,63 @@ export const Layout: Component<LayoutProps> = (props) => {
|
||||
});
|
||||
|
||||
createEffect(() => {
|
||||
const noteId = selectedNoteId();
|
||||
const noteId = props.selectedNoteId;
|
||||
if (!noteId) return;
|
||||
|
||||
const noteExists = store.notes.some(note => note.id === noteId);
|
||||
if (!noteExists) {
|
||||
setSelectedNoteId(null);
|
||||
if (noteExists) {
|
||||
if (resolvingRoutedNoteId() === noteId) {
|
||||
setResolvingRoutedNoteId(null);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (store.isNotesLoading || !store.hasLoadedNotesMetadata) return;
|
||||
if (resolvingRoutedNoteId() === noteId) return;
|
||||
|
||||
setResolvingRoutedNoteId(noteId);
|
||||
void (async () => {
|
||||
try {
|
||||
const fetched = await fetchNoteById(noteId);
|
||||
if (fetched) {
|
||||
updateStoreWithNote(fetched);
|
||||
return;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to resolve routed note", error);
|
||||
}
|
||||
|
||||
if (props.selectedNoteId === noteId) {
|
||||
setActiveNoteId(null);
|
||||
props.onOpenNotes(null, { replace: true });
|
||||
}
|
||||
setResolvingRoutedNoteId(current => current === noteId ? null : current);
|
||||
})();
|
||||
});
|
||||
|
||||
return (
|
||||
(() => {
|
||||
const sidebarWidthClass = () => {
|
||||
if (store.isNotepadMode) return "w-80";
|
||||
if (props.isAIHelpOpen) return "w-[24rem]";
|
||||
return "w-48";
|
||||
};
|
||||
|
||||
const sidebarPaddingClass = () => {
|
||||
if (store.isNotepadMode) return "md:pl-80";
|
||||
if (props.isAIHelpOpen) return "md:pl-[24rem]";
|
||||
return "md:pl-48";
|
||||
};
|
||||
|
||||
return (
|
||||
<div class="flex h-screen w-full min-w-0 bg-background overflow-hidden relative font-sans antialiased text-foreground">
|
||||
{/* Desktop UI Container */}
|
||||
<div
|
||||
style={{ "will-change": "width, transform" }}
|
||||
class={cn(
|
||||
"hidden md:flex flex-col border-r border-border bg-card h-screen fixed left-0 top-0 transition-all duration-200 ease-in-out z-[50] overflow-hidden",
|
||||
isSidebarLocked() ? (store.isNotepadMode ? "w-80 translate-x-0" : "w-48 translate-x-0") : (store.isNotepadMode ? "w-80 -translate-x-full" : "w-48 -translate-x-full"),
|
||||
!isSidebarLocked() && (isSidebarPeeking() || (store.isNotepadMode && !selectedNoteId())) && "translate-x-0 shadow-2xl ring-1 ring-border"
|
||||
isSidebarLocked() ? `${sidebarWidthClass()} translate-x-0` : `${sidebarWidthClass()} -translate-x-full`,
|
||||
!isSidebarLocked() && (isSidebarPeeking() || (store.isNotepadMode && !props.selectedNoteId)) && "translate-x-0 shadow-2xl ring-1 ring-border"
|
||||
)}
|
||||
onMouseEnter={() => !isSidebarLocked() && setIsSidebarPeeking(true)}
|
||||
onMouseLeave={() => {
|
||||
@@ -97,6 +149,9 @@ export const Layout: Component<LayoutProps> = (props) => {
|
||||
<Sidebar
|
||||
currentView={props.currentView}
|
||||
setView={props.setView}
|
||||
isAIHelpOpen={!!props.isAIHelpOpen}
|
||||
onToggleAIHelp={() => props.onToggleAIHelp?.()}
|
||||
aiHelpTip={props.aiHelpTip}
|
||||
isLocked={isSidebarLocked()}
|
||||
setIsLocked={setIsSidebarLocked}
|
||||
isPeeking={isSidebarPeeking()}
|
||||
@@ -137,8 +192,8 @@ export const Layout: Component<LayoutProps> = (props) => {
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
<div class={cn("flex-1 overflow-hidden transition-all duration-200", (!isSidebarLocked() && !isSidebarPeeking() && !(store.isNotepadMode && !selectedNoteId())) ? "opacity-0 pointer-events-none scale-95" : "opacity-100 scale-100")}>
|
||||
<NotesSidebar selectedNoteId={selectedNoteId()} setSelectedNoteId={setSelectedNoteId} />
|
||||
<div class={cn("flex-1 overflow-hidden transition-all duration-200", (!isSidebarLocked() && !isSidebarPeeking() && !(store.isNotepadMode && !props.selectedNoteId)) ? "opacity-0 pointer-events-none scale-95" : "opacity-100 scale-100")}>
|
||||
<NotesSidebar selectedNoteId={props.selectedNoteId} setSelectedNoteId={setActiveNoteId} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -149,7 +204,7 @@ export const Layout: Component<LayoutProps> = (props) => {
|
||||
class={cn(
|
||||
"flex-1 flex flex-col min-w-0 transition-all duration-200 ease-in-out h-screen",
|
||||
// Only apply md padding if locked
|
||||
isSidebarLocked() ? (store.isNotepadMode ? "md:pl-80" : "md:pl-48") : "md:pl-0"
|
||||
isSidebarLocked() ? sidebarPaddingClass() : "md:pl-0"
|
||||
)}>
|
||||
{/* Expand Trigger Button (visible when not locked) */}
|
||||
{!isSidebarLocked() && (
|
||||
@@ -187,7 +242,7 @@ export const Layout: Component<LayoutProps> = (props) => {
|
||||
"flex-1 h-8 text-[0.7rem] font-bold uppercase tracking-widest z-10 transition-colors duration-200 flex items-center justify-center rounded-lg",
|
||||
!store.isNotepadMode ? "text-primary-foreground" : "text-muted-foreground hover:text-foreground"
|
||||
)}
|
||||
onClick={() => setStore('isNotepadMode', false)}
|
||||
onClick={() => props.onOpenTasks()}
|
||||
>
|
||||
Tasks
|
||||
</button>
|
||||
@@ -196,7 +251,7 @@ export const Layout: Component<LayoutProps> = (props) => {
|
||||
"flex-1 h-8 text-[0.7rem] font-bold uppercase tracking-widest z-10 transition-colors duration-200 flex items-center justify-center rounded-lg",
|
||||
store.isNotepadMode ? "text-primary-foreground" : "text-muted-foreground hover:text-foreground"
|
||||
)}
|
||||
onClick={() => setStore('isNotepadMode', true)}
|
||||
onClick={() => props.onOpenNotes(props.selectedNoteId)}
|
||||
>
|
||||
Notes
|
||||
</button>
|
||||
@@ -212,22 +267,27 @@ 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}
|
||||
</div>
|
||||
<div class={cn(!store.isNotepadMode ? "hidden" : "block", "h-[calc(100vh-3.5rem)] md:h-[calc(100vh-8rem)] w-full")}>
|
||||
<Suspense>
|
||||
<NotepadView selectedNoteId={selectedNoteId()} setSelectedNoteId={setSelectedNoteId} />
|
||||
<NotepadView selectedNoteId={props.selectedNoteId} setSelectedNoteId={setActiveNoteId} />
|
||||
</Suspense>
|
||||
</div>
|
||||
</div>
|
||||
@@ -238,9 +298,11 @@ export const Layout: Component<LayoutProps> = (props) => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Suspense>
|
||||
<QuickEntry />
|
||||
</Suspense>
|
||||
<Show when={!props.hideQuickEntry}>
|
||||
<Suspense>
|
||||
<QuickEntry />
|
||||
</Suspense>
|
||||
</Show>
|
||||
|
||||
<Show when={activeTask()}>
|
||||
<Suspense>
|
||||
@@ -252,5 +314,7 @@ export const Layout: Component<LayoutProps> = (props) => {
|
||||
</Suspense>
|
||||
</Show>
|
||||
</div >
|
||||
);
|
||||
})()
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,165 @@
|
||||
import { type Component, createMemo } from "solid-js";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const escapeHtml = (value: string) =>
|
||||
value
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'");
|
||||
|
||||
const applyInlineMarkdown = (value: string) => {
|
||||
let text = escapeHtml(value);
|
||||
|
||||
text = text.replace(/`([^`]+)`/g, "<code>$1</code>");
|
||||
text = text.replace(/\[([^\]]+)\]\((https?:\/\/[^\s)]+)\)/g, '<a href="$2" target="_blank" rel="noreferrer">$1</a>');
|
||||
text = text.replace(/\*\*([^*]+)\*\*/g, "<strong>$1</strong>");
|
||||
text = text.replace(/(^|[\s(])\*([^*]+)\*(?=[\s).,!?:;]|$)/g, '$1<em>$2</em>');
|
||||
|
||||
return text;
|
||||
};
|
||||
|
||||
const splitTableRow = (line: string) => {
|
||||
const trimmed = line.trim().replace(/^\|/, "").replace(/\|$/, "");
|
||||
return trimmed.split("|").map(cell => applyInlineMarkdown(cell.trim()));
|
||||
};
|
||||
|
||||
const isTableSeparator = (line: string) => {
|
||||
const trimmed = line.trim().replace(/^\|/, "").replace(/\|$/, "");
|
||||
if (!trimmed) return false;
|
||||
|
||||
return trimmed
|
||||
.split("|")
|
||||
.every(cell => /^:?-{3,}:?$/.test(cell.trim()));
|
||||
};
|
||||
|
||||
const isTableRow = (line: string) => {
|
||||
const trimmed = line.trim();
|
||||
return trimmed.includes("|") && splitTableRow(trimmed).length > 1;
|
||||
};
|
||||
|
||||
const renderMarkdown = (markdown: string) => {
|
||||
const lines = markdown.replace(/\r\n/g, "\n").split("\n");
|
||||
const html: string[] = [];
|
||||
let i = 0;
|
||||
|
||||
while (i < lines.length) {
|
||||
const line = lines[i];
|
||||
const trimmed = line.trim();
|
||||
|
||||
if (!trimmed) {
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (trimmed.startsWith("```")) {
|
||||
const codeLines: string[] = [];
|
||||
i += 1;
|
||||
while (i < lines.length && !lines[i].trim().startsWith("```")) {
|
||||
codeLines.push(lines[i]);
|
||||
i += 1;
|
||||
}
|
||||
if (i < lines.length) i += 1;
|
||||
html.push(`<pre><code>${escapeHtml(codeLines.join("\n"))}</code></pre>`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const headingMatch = trimmed.match(/^(#{1,6})\s+(.+)$/);
|
||||
if (headingMatch) {
|
||||
const level = headingMatch[1].length;
|
||||
const text = applyInlineMarkdown(headingMatch[2].trim());
|
||||
html.push(`<h${level}>${text}</h${level}>`);
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (
|
||||
i + 1 < lines.length &&
|
||||
isTableRow(lines[i]) &&
|
||||
isTableSeparator(lines[i + 1])
|
||||
) {
|
||||
const headerCells = splitTableRow(lines[i]);
|
||||
const bodyRows: string[] = [];
|
||||
i += 2;
|
||||
|
||||
while (i < lines.length && lines[i].trim() && isTableRow(lines[i])) {
|
||||
const cells = splitTableRow(lines[i]).map(cell => `<td>${cell}</td>`);
|
||||
bodyRows.push(`<tr>${cells.join("")}</tr>`);
|
||||
i += 1;
|
||||
}
|
||||
|
||||
html.push(
|
||||
`<div class="table-wrap"><table><thead><tr>${headerCells.map(cell => `<th>${cell}</th>`).join("")}</tr></thead>${bodyRows.length > 0 ? `<tbody>${bodyRows.join("")}</tbody>` : ""}</table></div>`
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (/^[-*]\s+/.test(trimmed)) {
|
||||
const items: string[] = [];
|
||||
while (i < lines.length && /^[-*]\s+/.test(lines[i].trim())) {
|
||||
items.push(`<li>${applyInlineMarkdown(lines[i].trim().replace(/^[-*]\s+/, ""))}</li>`);
|
||||
i += 1;
|
||||
}
|
||||
html.push(`<ul>${items.join("")}</ul>`);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (/^\d+\.\s+/.test(trimmed)) {
|
||||
const items: string[] = [];
|
||||
while (i < lines.length && /^\d+\.\s+/.test(lines[i].trim())) {
|
||||
items.push(`<li>${applyInlineMarkdown(lines[i].trim().replace(/^\d+\.\s+/, ""))}</li>`);
|
||||
i += 1;
|
||||
}
|
||||
html.push(`<ol>${items.join("")}</ol>`);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (/^>\s?/.test(trimmed)) {
|
||||
const quoteLines: string[] = [];
|
||||
while (i < lines.length && /^>\s?/.test(lines[i].trim())) {
|
||||
quoteLines.push(applyInlineMarkdown(lines[i].trim().replace(/^>\s?/, "")));
|
||||
i += 1;
|
||||
}
|
||||
html.push(`<blockquote><p>${quoteLines.join("<br />")}</p></blockquote>`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const paragraphLines: string[] = [];
|
||||
while (i < lines.length && lines[i].trim() && !lines[i].trim().startsWith("```") && !/^[-*]\s+/.test(lines[i].trim()) && !/^\d+\.\s+/.test(lines[i].trim()) && !/^>\s?/.test(lines[i].trim())) {
|
||||
paragraphLines.push(applyInlineMarkdown(lines[i].trim()));
|
||||
i += 1;
|
||||
}
|
||||
html.push(`<p>${paragraphLines.join("<br />")}</p>`);
|
||||
}
|
||||
|
||||
return html.join("");
|
||||
};
|
||||
|
||||
export const MarkdownMessage: Component<{
|
||||
content: string;
|
||||
class?: string;
|
||||
}> = (props) => {
|
||||
const html = createMemo(() => renderMarkdown(props.content));
|
||||
|
||||
return (
|
||||
<div
|
||||
class={cn(
|
||||
"prose prose-sm dark:prose-invert max-w-none",
|
||||
"prose-headings:mt-4 prose-headings:mb-2 prose-headings:font-semibold prose-headings:tracking-tight",
|
||||
"prose-h1:text-xl prose-h2:text-lg prose-h3:text-base prose-h4:text-sm",
|
||||
"prose-p:my-2 prose-ul:my-2 prose-ol:my-2 prose-li:my-0",
|
||||
"prose-pre:my-3 prose-pre:rounded-lg prose-pre:bg-muted prose-pre:p-3",
|
||||
"prose-code:rounded prose-code:bg-muted prose-code:px-1 prose-code:py-0.5 prose-code:text-[0.9em]",
|
||||
"prose-code:before:content-none prose-code:after:content-none",
|
||||
"prose-a:text-primary prose-a:underline prose-a:underline-offset-4",
|
||||
"prose-blockquote:border-l-4 prose-blockquote:border-primary prose-blockquote:pl-4 prose-blockquote:italic",
|
||||
"prose-table:my-3 prose-table:w-full prose-table:border-collapse prose-th:border prose-th:border-border prose-th:bg-muted prose-th:px-2 prose-th:py-1 prose-th:text-left prose-th:font-semibold",
|
||||
"prose-td:border prose-td:border-border prose-td:px-2 prose-td:py-1 align-top",
|
||||
"[&_div.table-wrap]:my-3 [&_div.table-wrap]:overflow-x-auto",
|
||||
props.class
|
||||
)}
|
||||
innerHTML={html()}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -1,11 +1,12 @@
|
||||
import { type Component, For, Show } from "solid-js";
|
||||
import { LayoutDashboard, ListTodo, Settings, Clock, ArrowUpCircle, PanelLeftClose, Snowflake, Pickaxe, ChevronDown, Gauge, TrendingUp, Users, Box, BarChart3, HelpCircle } from "lucide-solid";
|
||||
import { LayoutDashboard, ListTodo, Settings, Clock, ArrowUpCircle, PanelLeftClose, Snowflake, Pickaxe, ChevronDown, Gauge, TrendingUp, Users, Box, BarChart3, HelpCircle, MessageCircle } from "lucide-solid";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Button } from "./ui/button";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||
import { store, currentTaskContext, setCurrentTaskContext, loadTasksForOwner } from "@/store";
|
||||
import { pb } from "@/lib/pocketbase";
|
||||
import { createSignal, createEffect } from "solid-js";
|
||||
import { AIHelpPanel } from "./AIHelpPanel";
|
||||
|
||||
interface NavItem {
|
||||
icon: any;
|
||||
@@ -34,11 +35,11 @@ interface MobileNavGroup {
|
||||
|
||||
const mobileNavGroups: MobileNavGroup[] = [
|
||||
{ icon: ListTodo, label: "Focus", view: "critical" },
|
||||
{ icon: LayoutDashboard, label: "Matrix", view: "matrix" },
|
||||
{
|
||||
icon: TrendingUp, label: "Value", items: [
|
||||
{ icon: ArrowUpCircle, label: "Priority", view: "priority" },
|
||||
{ icon: Clock, label: "Urgency", view: "urgency" },
|
||||
{ icon: LayoutDashboard, label: "Matrix", view: "matrix" },
|
||||
]
|
||||
},
|
||||
{
|
||||
@@ -48,6 +49,7 @@ const mobileNavGroups: MobileNavGroup[] = [
|
||||
{ icon: BarChart3, label: "Progress", view: "progress" },
|
||||
]
|
||||
},
|
||||
{ icon: MessageCircle, label: "Ask", view: "help_ai" },
|
||||
{ icon: Settings, label: "Settings", view: "settings" },
|
||||
];
|
||||
|
||||
@@ -302,6 +304,9 @@ export const ContextSwitcher: Component<{
|
||||
export const Sidebar: Component<{
|
||||
currentView: string;
|
||||
setView: (v: string) => void;
|
||||
isAIHelpOpen: boolean;
|
||||
onToggleAIHelp: () => void;
|
||||
aiHelpTip?: string | null;
|
||||
isLocked: boolean;
|
||||
setIsLocked: (v: boolean) => void;
|
||||
isPeeking: boolean;
|
||||
@@ -352,9 +357,19 @@ export const Sidebar: Component<{
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
<div class="px-3 pb-2">
|
||||
<ContextSwitcher
|
||||
isLocked={props.isLocked}
|
||||
isPeeking={props.isPeeking}
|
||||
setIsPeeking={props.setIsPeeking}
|
||||
onOpenChange={(open) => {
|
||||
setSwitcherOpen(open);
|
||||
props.onDropdownOpenChange?.(open);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
<nav class="flex-1 px-3 space-y-1 mt-2">
|
||||
<nav class="flex-1 px-3 space-y-1 mt-2 min-h-0">
|
||||
<For each={desktopNavItems}>
|
||||
{(item) => (
|
||||
<button
|
||||
@@ -373,23 +388,39 @@ export const Sidebar: Component<{
|
||||
</For>
|
||||
</nav>
|
||||
|
||||
{/* Context Switcher - Only visible if there are oversight contexts */}
|
||||
<div class={cn(
|
||||
"px-3 mb-2 transition-opacity duration-200",
|
||||
(!props.isLocked && !props.isPeeking) ? "opacity-0 pointer-events-none" : "opacity-100 delay-100"
|
||||
)}>
|
||||
<ContextSwitcher
|
||||
isLocked={props.isLocked}
|
||||
isPeeking={props.isPeeking}
|
||||
setIsPeeking={props.setIsPeeking}
|
||||
onOpenChange={(open) => {
|
||||
setSwitcherOpen(open);
|
||||
props.onDropdownOpenChange?.(open);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div class="px-3 mt-auto pb-3 space-y-3">
|
||||
<div
|
||||
class={cn(
|
||||
"transition-all duration-300 ease-out overflow-hidden",
|
||||
props.isAIHelpOpen ? "max-h-[44rem] opacity-100" : "max-h-0 opacity-0 pointer-events-none"
|
||||
)}
|
||||
>
|
||||
<AIHelpPanel variant="sidebar" class="h-[42rem]" />
|
||||
</div>
|
||||
|
||||
<div class="px-3 border-t border-border mt-auto pt-3 space-y-1 pb-3">
|
||||
<div class="border-t border-border pt-3 space-y-1">
|
||||
<button
|
||||
onClick={props.onToggleAIHelp}
|
||||
class={cn(
|
||||
"flex w-full items-start gap-3 px-3 py-2.5 rounded-lg transition-all duration-200 group text-sm text-left",
|
||||
props.isAIHelpOpen
|
||||
? "bg-primary text-primary-foreground shadow-sm"
|
||||
: "text-muted-foreground hover:bg-muted hover:text-foreground"
|
||||
)}
|
||||
>
|
||||
<MessageCircle size={16} class={cn("transition-transform group-hover:scale-110 mt-0.5 shrink-0")} />
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="font-medium">Ask Questions</div>
|
||||
<div
|
||||
class={cn(
|
||||
"text-[0.625rem] leading-snug transition-opacity duration-700",
|
||||
props.aiHelpTip ? "opacity-50" : "opacity-0"
|
||||
)}
|
||||
>
|
||||
{props.aiHelpTip || " "}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => props.setView("help")}
|
||||
class={cn(
|
||||
@@ -400,7 +431,7 @@ export const Sidebar: Component<{
|
||||
)}
|
||||
>
|
||||
<HelpCircle size={16} class={cn("transition-transform group-hover:scale-110")} />
|
||||
<span class="font-medium">Help Guide</span>
|
||||
<span class="font-medium">Help</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => props.setView("settings")}
|
||||
@@ -414,6 +445,7 @@ export const Sidebar: Component<{
|
||||
<Settings size={16} class={cn("transition-transform group-hover:scale-110")} />
|
||||
<span class="font-medium">Settings</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,324 @@
|
||||
import { FIREWORKS_API_KEY, FIREWORKS_API_URL, FIREWORKS_HELP_MODEL } from "@/lib/app-config";
|
||||
import helpGuide from "../../docs/HELP_GUIDE.md?raw";
|
||||
|
||||
type ChatRequestMessage = {
|
||||
role: "system" | "user" | "assistant";
|
||||
content: string;
|
||||
};
|
||||
|
||||
const HELP_SYSTEM_PROMPT = `You answer questions about TasGrid using the reference guide below.
|
||||
|
||||
You must follow this instruction exactly: "use only this guide for all information and never assume you know something that isn't mentioned in the guide"
|
||||
|
||||
If the guide does not contain the answer, say that the guide does not mention it.
|
||||
Do not use outside product knowledge.
|
||||
Do not invent buttons, workflows, settings, or behaviors.
|
||||
Answer in the most direct and concise way possible initially.
|
||||
After the main answer, end with a short follow-up line like "If you would like, I can explain the intricacies in more detail."
|
||||
When helpful, mention the relevant section name from the guide.
|
||||
|
||||
TASGRID REFERENCE GUIDE
|
||||
${helpGuide}`;
|
||||
|
||||
const buildSectionPrompt = (sectionTitle: string, sectionContent: string) => `You answer questions about TasGrid using only the guide section below.
|
||||
|
||||
You must follow this instruction exactly: "use only this guide for all information and never assume you know something that isn't mentioned in the guide"
|
||||
|
||||
If the section does not contain the answer, say that the guide section does not mention it.
|
||||
Do not use outside product knowledge.
|
||||
Do not invent buttons, workflows, settings, or behaviors.
|
||||
Prefer concise, practical answers.
|
||||
|
||||
TASGRID GUIDE SECTION
|
||||
## ${sectionTitle}
|
||||
${sectionContent}`;
|
||||
|
||||
const normalizeLine = (value: string) =>
|
||||
value
|
||||
.trim()
|
||||
.replace(/^tip:\s*/i, "")
|
||||
.replace(/^["'\s]+|["'\s]+$/g, "")
|
||||
.replace(/\s+/g, " ")
|
||||
.toLowerCase();
|
||||
|
||||
const keepSingleSentence = (value: string) => {
|
||||
const trimmed = value.trim();
|
||||
const sentenceMatch = trimmed.match(/.+?[.!?](?=\s|$)/);
|
||||
return (sentenceMatch?.[0] || trimmed).trim();
|
||||
};
|
||||
|
||||
const isReasoningLikeType = (value: unknown) =>
|
||||
typeof value === "string" && /reasoning|thought/i.test(value);
|
||||
|
||||
export const hasFireworksApiKey = () => FIREWORKS_API_KEY.trim().length > 0;
|
||||
|
||||
const extractGuideSections = (markdown: string) => {
|
||||
const lines = markdown.split(/\r?\n/);
|
||||
const sections: Array<{ title: string; content: string }> = [];
|
||||
const excludedTopLevelTitles = new Set([
|
||||
"Overview",
|
||||
"Reference Notes",
|
||||
"Troubleshooting Checklist"
|
||||
]);
|
||||
const excludedSubsectionTitles = new Set([
|
||||
"Explicit Exclusions",
|
||||
"Summary for Future Help-Agent Use",
|
||||
"Help Access",
|
||||
"Main App Modes",
|
||||
"Account Header"
|
||||
]);
|
||||
let currentTopLevelTitle: string | null = null;
|
||||
let currentSection: { title: string; lines: string[] } | null = null;
|
||||
|
||||
const pushCurrentSection = () => {
|
||||
if (!currentSection) return;
|
||||
const content = currentSection.lines.join("\n").trim();
|
||||
if (!content) return;
|
||||
if (excludedSubsectionTitles.has(currentSection.title)) return;
|
||||
sections.push({
|
||||
title: currentSection.title,
|
||||
content
|
||||
});
|
||||
};
|
||||
|
||||
for (const line of lines) {
|
||||
const topLevelHeadingMatch = line.match(/^##\s+(.+?)\s*$/);
|
||||
if (topLevelHeadingMatch) {
|
||||
pushCurrentSection();
|
||||
currentTopLevelTitle = topLevelHeadingMatch[1].trim();
|
||||
currentSection = null;
|
||||
continue;
|
||||
}
|
||||
|
||||
const subsectionHeadingMatch = line.match(/^###\s+(.+?)\s*$/);
|
||||
if (subsectionHeadingMatch) {
|
||||
pushCurrentSection();
|
||||
if (!currentTopLevelTitle || excludedTopLevelTitles.has(currentTopLevelTitle)) {
|
||||
currentSection = null;
|
||||
continue;
|
||||
}
|
||||
currentSection = {
|
||||
title: `${currentTopLevelTitle}: ${subsectionHeadingMatch[1].trim()}`,
|
||||
lines: []
|
||||
};
|
||||
continue;
|
||||
}
|
||||
|
||||
if (currentSection) {
|
||||
currentSection.lines.push(line);
|
||||
}
|
||||
}
|
||||
|
||||
pushCurrentSection();
|
||||
|
||||
return sections;
|
||||
};
|
||||
|
||||
const HELP_GUIDE_SECTIONS = extractGuideSections(helpGuide);
|
||||
|
||||
export const getAIHelpTipSectionDebug = (sectionIndex: number) => {
|
||||
if (HELP_GUIDE_SECTIONS.length === 0) {
|
||||
return { requestedIndex: sectionIndex, resolvedIndex: -1, title: null as string | null };
|
||||
}
|
||||
|
||||
const resolvedIndex = ((sectionIndex % HELP_GUIDE_SECTIONS.length) + HELP_GUIDE_SECTIONS.length) % HELP_GUIDE_SECTIONS.length;
|
||||
return {
|
||||
requestedIndex: sectionIndex,
|
||||
resolvedIndex,
|
||||
title: HELP_GUIDE_SECTIONS[resolvedIndex]?.title || null
|
||||
};
|
||||
};
|
||||
|
||||
const cleanRawCompletion = (value: string) =>
|
||||
value
|
||||
.replace(/<think>[\s\S]*?<\/think>/gi, " ")
|
||||
.replace(/<reasoning>[\s\S]*?<\/reasoning>/gi, " ")
|
||||
.replace(/<\|[^>]+?\|>/g, " ")
|
||||
.replace(/<\/?s>/gi, " ")
|
||||
.replace(/\s+/g, " ")
|
||||
.trim();
|
||||
|
||||
const extractTextFromContent = (content: unknown): string => {
|
||||
if (typeof content === "string") {
|
||||
return content.trim();
|
||||
}
|
||||
|
||||
if (Array.isArray(content)) {
|
||||
return content
|
||||
.map(part => {
|
||||
if (typeof part === "string") return part;
|
||||
if (part && typeof part === "object") {
|
||||
if (isReasoningLikeType((part as any).type)) {
|
||||
return "";
|
||||
}
|
||||
if ("text" in part && typeof (part as any).text === "string") {
|
||||
return (part as any).text;
|
||||
}
|
||||
if ("content" in part && typeof (part as any).content === "string") {
|
||||
return (part as any).content;
|
||||
}
|
||||
}
|
||||
return "";
|
||||
})
|
||||
.filter(Boolean)
|
||||
.join("\n")
|
||||
.trim();
|
||||
}
|
||||
|
||||
if (content && typeof content === "object") {
|
||||
if (isReasoningLikeType((content as any).type)) {
|
||||
return "";
|
||||
}
|
||||
if ("text" in content && typeof (content as any).text === "string") {
|
||||
return (content as any).text.trim();
|
||||
}
|
||||
if ("content" in content) {
|
||||
return extractTextFromContent((content as any).content);
|
||||
}
|
||||
}
|
||||
|
||||
return "";
|
||||
};
|
||||
|
||||
const extractTextFromOutputArray = (output: unknown): string => {
|
||||
if (!Array.isArray(output)) return "";
|
||||
|
||||
return output
|
||||
.map(item => {
|
||||
if (!item || typeof item !== "object") return "";
|
||||
if ("content" in item) {
|
||||
return extractTextFromContent((item as any).content);
|
||||
}
|
||||
if ("text" in item && typeof (item as any).text === "string") {
|
||||
return (item as any).text;
|
||||
}
|
||||
return "";
|
||||
})
|
||||
.filter(Boolean)
|
||||
.join("\n")
|
||||
.trim();
|
||||
};
|
||||
|
||||
const requestFireworks = async (messages: ChatRequestMessage[], options?: { maxTokens?: number; temperature?: number; reasoningEffort?: "none" | "low" | "medium" | "high" }) => {
|
||||
const response = await fetch(FIREWORKS_API_URL, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"Authorization": `Bearer ${FIREWORKS_API_KEY}`
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model: FIREWORKS_HELP_MODEL,
|
||||
reasoning_effort: options?.reasoningEffort ?? "low",
|
||||
temperature: options?.temperature ?? 0.2,
|
||||
max_tokens: options?.maxTokens ?? 700,
|
||||
raw_output: true,
|
||||
messages
|
||||
})
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const bodyText = await response.text();
|
||||
throw new Error(bodyText || `Request failed with status ${response.status}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
const choice = data?.choices?.[0];
|
||||
const content = extractTextFromContent(choice?.message?.content)
|
||||
|| extractTextFromContent(choice?.delta?.content)
|
||||
|| extractTextFromContent(choice?.text)
|
||||
|| extractTextFromContent(data?.output_text);
|
||||
const rawCompletion = cleanRawCompletion(
|
||||
extractTextFromContent(choice?.raw_output?.completion)
|
||||
|| extractTextFromContent(data?.raw_output?.completion)
|
||||
);
|
||||
const outputArrayText = extractTextFromOutputArray(data?.output);
|
||||
const answer = content || outputArrayText || rawCompletion;
|
||||
|
||||
if (!answer) {
|
||||
const finishReason = choice?.finish_reason ? ` finish_reason=${String(choice.finish_reason)}` : "";
|
||||
throw new Error(`The help assistant did not return any text.${finishReason}`);
|
||||
}
|
||||
|
||||
return answer;
|
||||
};
|
||||
|
||||
const looksLikePromptEcho = (value: string) => {
|
||||
const normalized = normalizeLine(value);
|
||||
return [
|
||||
"we need to produce",
|
||||
"create one short tasgrid desktop tip",
|
||||
"maximum one sentence",
|
||||
"return plain text only",
|
||||
"do not repeat or closely paraphrase",
|
||||
"previous tips to avoid",
|
||||
"use only this guide",
|
||||
"the guide does not mention",
|
||||
"guide section does not mention",
|
||||
"section does not mention",
|
||||
"specific desktop tip",
|
||||
"tasgrid desktop tip",
|
||||
"based only on the guide"
|
||||
].some(fragment => normalized.includes(fragment));
|
||||
};
|
||||
|
||||
export const askAIHelp = async (messages: Array<{ role: "user" | "assistant"; content: string }>) =>
|
||||
requestFireworks(
|
||||
[
|
||||
{ role: "system", content: HELP_SYSTEM_PROMPT },
|
||||
...messages
|
||||
],
|
||||
{ maxTokens: 700, temperature: 0.2, reasoningEffort: "low" }
|
||||
);
|
||||
|
||||
export const generateAIHelpTip = async (sectionIndex: number) => {
|
||||
const sectionDebug = getAIHelpTipSectionDebug(sectionIndex);
|
||||
const section = sectionDebug.resolvedIndex >= 0
|
||||
? HELP_GUIDE_SECTIONS[sectionDebug.resolvedIndex]
|
||||
: null;
|
||||
|
||||
const sectionLabel = section?.title || "TasGrid";
|
||||
const tipPrompt = `Write one short practical TasGrid tip for a person already using the app.
|
||||
|
||||
Rules:
|
||||
- Maximum one sentence.
|
||||
- Return plain text only.
|
||||
- No title, no bullet, no quotes.
|
||||
- Keep it subtle and practical.
|
||||
- State a real TasGrid behavior or workflow the user can use.
|
||||
- Do not talk about the guide, the section, the prompt, or what is not mentioned.
|
||||
- Do not explain your limitations.
|
||||
- Use only this guide for all information and never assume you know something that isn't mentioned in the guide.
|
||||
- Start the sentence with "Tip: ".
|
||||
- Base the tip on this topic: ${sectionLabel}.
|
||||
`;
|
||||
|
||||
for (let attempt = 0; attempt < 2; attempt += 1) {
|
||||
const response = await requestFireworks(
|
||||
[
|
||||
{
|
||||
role: "system",
|
||||
content: section
|
||||
? buildSectionPrompt(section.title, section.content)
|
||||
: HELP_SYSTEM_PROMPT
|
||||
},
|
||||
{ role: "user", content: tipPrompt }
|
||||
],
|
||||
{ maxTokens: 192, temperature: 0.5, reasoningEffort: "low" }
|
||||
);
|
||||
|
||||
const firstLine = response.split("\n")[0]?.trim() || "";
|
||||
const cleaned = keepSingleSentence(
|
||||
firstLine
|
||||
.replace(/^[\-\*\u2022\d.\s]+/, "")
|
||||
.trim()
|
||||
);
|
||||
const normalized = normalizeLine(cleaned);
|
||||
|
||||
if (!cleaned || !normalized) continue;
|
||||
if (looksLikePromptEcho(cleaned)) continue;
|
||||
|
||||
return cleaned.startsWith("Tip:") ? cleaned : `Tip: ${cleaned.replace(/^tip:\s*/i, "")}`;
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
@@ -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";
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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);
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,183 @@
|
||||
import { pb } from "@/lib/pocketbase";
|
||||
import { toast } from "solid-sonner";
|
||||
import {
|
||||
createDefaultFilter,
|
||||
type Filter,
|
||||
type FilterTemplate,
|
||||
readScopedPrefs,
|
||||
setStore,
|
||||
store,
|
||||
writeScopedPrefs
|
||||
} from "./state";
|
||||
|
||||
export const syncPreferences = async () => {
|
||||
const userId = pb.authStore.model?.id;
|
||||
if (!userId) return;
|
||||
|
||||
try {
|
||||
const user = await pb.collection("users").getOne(userId, { requestKey: null });
|
||||
const rawPrefs = user.Taskgrid_pref || {};
|
||||
const prefs = {
|
||||
pWeight: store.pWeight,
|
||||
uWeight: store.uWeight,
|
||||
matrixScaleDays: store.matrixScaleDays,
|
||||
tagDefinitions: store.tagDefinitions.filter(def => !def.name.startsWith("@")),
|
||||
filterTemplates: store.filterTemplates,
|
||||
quickloadTasks: store.quickloadTasks,
|
||||
dismissedTaskUpdateIndicators: store.dismissedTaskUpdateIndicators,
|
||||
collapsedUntilUpdatedTasks: store.collapsedUntilUpdatedTasks,
|
||||
subscribedBuckets: store.subscribedBuckets,
|
||||
supervisorUserIds: store.personalContextSupervisorIds,
|
||||
aiHelpTipSectionIndex: store.aiHelpTipSectionIndex,
|
||||
lastCompletedTaskCleanupDate: store.lastCompletedTaskCleanupDate
|
||||
};
|
||||
|
||||
await pb.collection("users").update(userId, {
|
||||
Taskgrid_pref: writeScopedPrefs(rawPrefs, {
|
||||
...readScopedPrefs(rawPrefs),
|
||||
...prefs
|
||||
})
|
||||
}, { requestKey: null });
|
||||
} catch (e: any) {
|
||||
if (e.isAbort) return;
|
||||
console.error("Failed to sync preferences", e);
|
||||
}
|
||||
};
|
||||
|
||||
export const setFilter = (update: Partial<Filter>) => {
|
||||
setStore("filter", f => ({ ...f, ...update }));
|
||||
};
|
||||
|
||||
export const loadFilterTemplates = async () => {
|
||||
if (!pb.authStore.isValid) return;
|
||||
const userId = pb.authStore.model?.id;
|
||||
if (!userId) return;
|
||||
const user = await pb.collection("users").getOne(userId, { requestKey: null });
|
||||
const prefs = readScopedPrefs(user.Taskgrid_pref || {});
|
||||
setStore("filterTemplates", prefs.filterTemplates || []);
|
||||
};
|
||||
|
||||
export const saveFilterTemplate = async (name: string) => {
|
||||
if (!pb.authStore.isValid) return;
|
||||
try {
|
||||
const newTemplate: FilterTemplate = {
|
||||
id: `filter-${Date.now()}`,
|
||||
name,
|
||||
filter: store.filter
|
||||
};
|
||||
|
||||
setStore("filterTemplates", prev => [...prev, newTemplate]);
|
||||
await syncPreferences();
|
||||
toast.success(`Filter template "${name}" saved`);
|
||||
} catch (err) {
|
||||
console.error("Failed to save filter template:", err);
|
||||
toast.error("Failed to save filter template.");
|
||||
}
|
||||
};
|
||||
|
||||
export const applyFilterTemplate = (id: string) => {
|
||||
const template = store.filterTemplates.find(t => t.id === id);
|
||||
if (template) {
|
||||
setFilter(template.filter);
|
||||
toast.success(`Applied filter: ${template.name}`);
|
||||
}
|
||||
};
|
||||
|
||||
export const removeFilterTemplate = async (id: string) => {
|
||||
if (!pb.authStore.isValid) return;
|
||||
try {
|
||||
setStore("filterTemplates", prev => prev.filter(t => t.id !== id));
|
||||
await syncPreferences();
|
||||
toast.success("Filter template deleted");
|
||||
} catch (err) {
|
||||
console.error("Failed to delete filter template:", err);
|
||||
toast.error("Failed to delete filter template.");
|
||||
}
|
||||
};
|
||||
|
||||
export const clearFilter = () => {
|
||||
setStore("filter", createDefaultFilter());
|
||||
};
|
||||
|
||||
export const setNoteFilter = (update: Partial<Filter>) => {
|
||||
setStore("noteFilter", f => ({ ...f, ...update }));
|
||||
};
|
||||
|
||||
export const loadNoteFilterTemplates = async () => {
|
||||
if (!pb.authStore.isValid) return;
|
||||
const userId = pb.authStore.model?.id;
|
||||
if (!userId) return;
|
||||
const user = await pb.collection("users").getOne(userId, { requestKey: null });
|
||||
const prefs = readScopedPrefs(user.Taskgrid_pref || {});
|
||||
setStore("filterTemplates", prefs.noteFilterTemplates || []);
|
||||
};
|
||||
|
||||
export const saveNoteFilterTemplate = async (name: string) => {
|
||||
if (!pb.authStore.isValid) return;
|
||||
try {
|
||||
const newTemplate: FilterTemplate = {
|
||||
id: `note-filter-${Date.now()}`,
|
||||
name,
|
||||
filter: store.noteFilter
|
||||
};
|
||||
|
||||
setStore("filterTemplates", prev => [...prev, newTemplate]);
|
||||
const userId = pb.authStore.model?.id;
|
||||
if (userId) {
|
||||
const user = await pb.collection("users").getOne(userId, { requestKey: null });
|
||||
const rawPrefs = user.Taskgrid_pref || {};
|
||||
const prefs = readScopedPrefs(rawPrefs);
|
||||
await pb.collection("users").update(userId, {
|
||||
Taskgrid_pref: writeScopedPrefs(rawPrefs, {
|
||||
...prefs,
|
||||
noteFilterTemplates: [...(prefs.noteFilterTemplates || []), newTemplate]
|
||||
})
|
||||
}, { requestKey: null });
|
||||
}
|
||||
toast.success(`Note filter template "${name}" saved`);
|
||||
} catch (err) {
|
||||
console.error("Failed to save note filter template:", err);
|
||||
toast.error("Failed to save note filter template.");
|
||||
}
|
||||
};
|
||||
|
||||
export const applyNoteFilterTemplate = (id: string) => {
|
||||
const template = store.filterTemplates.find(t => t.id === id);
|
||||
if (template) {
|
||||
setNoteFilter(template.filter);
|
||||
toast.success(`Applied note filter: ${template.name}`);
|
||||
}
|
||||
};
|
||||
|
||||
export const removeNoteFilterTemplate = async (id: string) => {
|
||||
if (!pb.authStore.isValid) return;
|
||||
try {
|
||||
setStore("filterTemplates", prev => prev.filter(t => t.id !== id));
|
||||
const userId = pb.authStore.model?.id;
|
||||
if (userId) {
|
||||
const user = await pb.collection("users").getOne(userId, { requestKey: null });
|
||||
const rawPrefs = user.Taskgrid_pref || {};
|
||||
const prefs = readScopedPrefs(rawPrefs);
|
||||
await pb.collection("users").update(userId, {
|
||||
Taskgrid_pref: writeScopedPrefs(rawPrefs, {
|
||||
...prefs,
|
||||
noteFilterTemplates: (prefs.noteFilterTemplates || []).filter((template: FilterTemplate) => template.id !== id)
|
||||
})
|
||||
}, { requestKey: null });
|
||||
}
|
||||
toast.success("Note filter template deleted");
|
||||
} catch (err) {
|
||||
console.error("Failed to delete note filter template:", err);
|
||||
toast.error("Failed to delete note filter template.");
|
||||
}
|
||||
};
|
||||
|
||||
export const clearNoteFilter = () => {
|
||||
setStore("noteFilter", createDefaultFilter());
|
||||
};
|
||||
|
||||
export const advanceAIHelpTipSectionIndex = async () => {
|
||||
setStore("aiHelpTipSectionIndex", prev => prev + 1);
|
||||
|
||||
await syncPreferences();
|
||||
};
|
||||
@@ -0,0 +1,356 @@
|
||||
import { createSignal } from "solid-js";
|
||||
import { createStore } from "solid-js/store";
|
||||
import { pb } from "@/lib/pocketbase";
|
||||
import { STORAGE_KEY_PREFIX, TASGRID_DATA_MODE } from "@/lib/app-config";
|
||||
|
||||
// Helper to get the current user's personalized favorite tag
|
||||
export const getFavoriteTag = () => {
|
||||
const userId = pb.authStore.model?.id;
|
||||
return userId ? `__${userId}_favorite__` : "__favorite__";
|
||||
};
|
||||
|
||||
export const [now, setNow] = createSignal(Date.now());
|
||||
|
||||
// Context: 'mine' is a temporary bootstrap alias for the current user's personal context bucket.
|
||||
export type TaskContext = "mine" | { userId: string, name: string } | { bucketId: string, name: string, isPersonal?: boolean };
|
||||
export const [activeTaskId, setActiveTaskId] = createSignal<string | null>(null);
|
||||
export const [activeNoteId, setActiveNoteId] = createSignal<string | null>(null);
|
||||
export const [currentTaskContext, setCurrentTaskContext] = createSignal<TaskContext>("mine");
|
||||
|
||||
export type UrgencyLevel = number;
|
||||
|
||||
export interface TaskShareRef {
|
||||
contextId: string;
|
||||
key: string;
|
||||
kind: "user" | "bucket" | "note";
|
||||
policy?: "collaborative" | "handoff";
|
||||
}
|
||||
|
||||
export interface NoteRef {
|
||||
noteId: string;
|
||||
key: string;
|
||||
}
|
||||
|
||||
export interface Task {
|
||||
id: string;
|
||||
title: string;
|
||||
startDate: string;
|
||||
dueDate: string;
|
||||
priority: number;
|
||||
status: number;
|
||||
completed: boolean;
|
||||
tags: string[];
|
||||
labelTags: string[];
|
||||
shareRefs: TaskShareRef[];
|
||||
noteRefs: NoteRef[];
|
||||
ownerId: string | null;
|
||||
createdBy: string | null;
|
||||
updatedBy?: string | null;
|
||||
content?: string;
|
||||
deletedAt?: number | null;
|
||||
created: string;
|
||||
updated: string;
|
||||
recurrence?: {
|
||||
type: "daily" | "weekly" | "monthly";
|
||||
days?: number[];
|
||||
dayOfMonth?: number;
|
||||
lastUncompleted?: string;
|
||||
};
|
||||
size?: number;
|
||||
sharedWith?: Array<{ userId: string; access: "view" | "edit" }>;
|
||||
attachments?: string[];
|
||||
}
|
||||
|
||||
export interface TaskTemplate {
|
||||
id: string;
|
||||
name: string;
|
||||
title: string;
|
||||
priority: number;
|
||||
urgency: number;
|
||||
tags: string[];
|
||||
content: string;
|
||||
}
|
||||
|
||||
export interface TagDefinition {
|
||||
id: string;
|
||||
name: string;
|
||||
value: number;
|
||||
color?: string;
|
||||
theme?: "light" | "dark";
|
||||
isUser?: boolean;
|
||||
isBucket?: boolean;
|
||||
}
|
||||
|
||||
export interface Note {
|
||||
id: string;
|
||||
title: string;
|
||||
key: string;
|
||||
content?: string;
|
||||
tags: string[];
|
||||
isPrivate: boolean;
|
||||
user: string;
|
||||
tasks: string[];
|
||||
created: string;
|
||||
updated: string;
|
||||
deletedAt?: number | null;
|
||||
}
|
||||
|
||||
export interface FilterTag {
|
||||
name: string;
|
||||
excluded: boolean;
|
||||
}
|
||||
|
||||
export interface Filter {
|
||||
query: string;
|
||||
tags: FilterTag[];
|
||||
priorityMin: number;
|
||||
priorityMax: number;
|
||||
urgencyMin: number;
|
||||
urgencyMax: number;
|
||||
editedToday: boolean;
|
||||
ownedByMe: boolean;
|
||||
starred: boolean;
|
||||
jobStatus: FilterTag[];
|
||||
jobDivision: FilterTag[];
|
||||
}
|
||||
|
||||
export interface FilterTemplate {
|
||||
id: string;
|
||||
name: string;
|
||||
filter: Filter;
|
||||
}
|
||||
|
||||
export interface ShareContext {
|
||||
id: string;
|
||||
key: string;
|
||||
displayName: string;
|
||||
kind: "user" | "bucket" | "note";
|
||||
policy: "collaborative" | "handoff";
|
||||
targetUserId?: string | null;
|
||||
supervisorUserIds?: string[];
|
||||
color?: string;
|
||||
description?: string;
|
||||
deletedAt?: number | null;
|
||||
deletedBy?: string | null;
|
||||
}
|
||||
|
||||
export interface ShareRule {
|
||||
id: string;
|
||||
ownerId: string;
|
||||
targetUserId: string;
|
||||
type: "tag" | "all";
|
||||
tagName?: string;
|
||||
share_mode?: "ADD_USER" | "SEND_TASK";
|
||||
}
|
||||
|
||||
export interface SupervisedContextAccess {
|
||||
ownerUserId: string;
|
||||
ownerName: string;
|
||||
contextId: string;
|
||||
contextName: string;
|
||||
}
|
||||
|
||||
export interface Bucket {
|
||||
id: string;
|
||||
name: string;
|
||||
color: string;
|
||||
description?: string;
|
||||
policy?: "collaborative" | "handoff";
|
||||
key?: string;
|
||||
deletedAt?: number | null;
|
||||
}
|
||||
|
||||
export interface TaskStore {
|
||||
tasks: Task[];
|
||||
isInitializing: boolean;
|
||||
quickloadFadeTaskIds: string[];
|
||||
dismissedTaskUpdateIndicators: Record<string, string>;
|
||||
collapsedUntilUpdatedTasks: Record<string, string>;
|
||||
isNotesLoading: boolean;
|
||||
hasLoadedNotesMetadata: boolean;
|
||||
loadingNoteContentIds: string[];
|
||||
pWeight: number;
|
||||
uWeight: number;
|
||||
matrixScaleDays: number;
|
||||
prefId?: string;
|
||||
tagDefinitions: TagDefinition[];
|
||||
templates: TaskTemplate[];
|
||||
filter: Filter;
|
||||
shareRules: ShareRule[];
|
||||
contexts: ShareContext[];
|
||||
supervisedContexts: SupervisedContextAccess[];
|
||||
filterTemplates: FilterTemplate[];
|
||||
buckets: Bucket[];
|
||||
subscribedBuckets: string[];
|
||||
personalContextSupervisorIds: string[];
|
||||
aiHelpTipSectionIndex: number;
|
||||
notes: Note[];
|
||||
isNotepadMode: boolean;
|
||||
quickloadTasks: string[];
|
||||
noteFilter: Filter;
|
||||
lastCompletedTaskCleanupDate: string;
|
||||
}
|
||||
|
||||
export interface ScopedTaskgridPrefs {
|
||||
pWeight?: number;
|
||||
uWeight?: number;
|
||||
matrixScaleDays?: number;
|
||||
tagDefinitions?: TagDefinition[];
|
||||
filter?: Filter;
|
||||
filterTemplates?: FilterTemplate[];
|
||||
noteFilterTemplates?: FilterTemplate[];
|
||||
quickloadTasks?: string[];
|
||||
noteFilter?: Filter;
|
||||
subscribedBuckets?: string[];
|
||||
supervisorUserIds?: string[];
|
||||
aiHelpTipSectionIndex?: number;
|
||||
dismissedTaskUpdateIndicators?: Record<string, string>;
|
||||
collapsedUntilUpdatedTasks?: Record<string, string>;
|
||||
migratedStructuredTagsV2?: boolean;
|
||||
migratedPersonalContextRefsV1?: boolean;
|
||||
migratedContextOwnershipV2?: boolean;
|
||||
lastCompletedTaskCleanupDate?: string;
|
||||
}
|
||||
|
||||
export const createDefaultFilter = (): Filter => ({
|
||||
query: "",
|
||||
tags: [],
|
||||
priorityMin: 1,
|
||||
priorityMax: 10,
|
||||
urgencyMin: 1,
|
||||
urgencyMax: 10,
|
||||
editedToday: false,
|
||||
ownedByMe: false,
|
||||
starred: false,
|
||||
jobStatus: [],
|
||||
jobDivision: []
|
||||
});
|
||||
|
||||
export const [store, setStore] = createStore<TaskStore>({
|
||||
tasks: [],
|
||||
isInitializing: false,
|
||||
quickloadFadeTaskIds: [],
|
||||
dismissedTaskUpdateIndicators: {},
|
||||
collapsedUntilUpdatedTasks: {},
|
||||
isNotesLoading: false,
|
||||
hasLoadedNotesMetadata: false,
|
||||
loadingNoteContentIds: [],
|
||||
pWeight: 1.0,
|
||||
uWeight: 1.0,
|
||||
matrixScaleDays: 30,
|
||||
tagDefinitions: [],
|
||||
templates: [],
|
||||
filter: createDefaultFilter(),
|
||||
shareRules: [],
|
||||
contexts: [],
|
||||
supervisedContexts: [],
|
||||
filterTemplates: [],
|
||||
buckets: [],
|
||||
subscribedBuckets: [],
|
||||
personalContextSupervisorIds: [],
|
||||
aiHelpTipSectionIndex: 0,
|
||||
notes: [],
|
||||
isNotepadMode: false,
|
||||
quickloadTasks: [],
|
||||
lastCompletedTaskCleanupDate: "",
|
||||
noteFilter: createDefaultFilter()
|
||||
});
|
||||
|
||||
const PREF_ENVIRONMENTS_KEY = "environments";
|
||||
|
||||
export const getStorageKey = () => {
|
||||
const userId = pb.authStore.model?.id;
|
||||
return userId ? `${STORAGE_KEY_PREFIX}_data_${userId}` : null;
|
||||
};
|
||||
|
||||
export const getPersistedStoreSnapshot = () => {
|
||||
const { isInitializing, quickloadFadeTaskIds, isNotesLoading, hasLoadedNotesMetadata, loadingNoteContentIds, ...persisted } = store;
|
||||
return persisted;
|
||||
};
|
||||
|
||||
export const readScopedPrefs = (rawPrefs: any): ScopedTaskgridPrefs => {
|
||||
const scoped = rawPrefs?.[PREF_ENVIRONMENTS_KEY]?.[TASGRID_DATA_MODE];
|
||||
return (scoped || rawPrefs || {}) as ScopedTaskgridPrefs;
|
||||
};
|
||||
|
||||
export const writeScopedPrefs = (rawPrefs: any, scopedPrefs: ScopedTaskgridPrefs) => ({
|
||||
...(rawPrefs || {}),
|
||||
[PREF_ENVIRONMENTS_KEY]: {
|
||||
...(rawPrefs?.[PREF_ENVIRONMENTS_KEY] || {}),
|
||||
[TASGRID_DATA_MODE]: scopedPrefs
|
||||
}
|
||||
});
|
||||
|
||||
export const applyScopedPrefsToStore = (prefs: ScopedTaskgridPrefs) => {
|
||||
const currentUserId = pb.authStore.model?.id;
|
||||
const personalContext = currentUserId
|
||||
? store.contexts.find(context =>
|
||||
!context.deletedAt &&
|
||||
context.kind === "user" &&
|
||||
context.targetUserId === currentUserId
|
||||
) || null
|
||||
: null;
|
||||
|
||||
setStore({
|
||||
pWeight: prefs.pWeight || 1.0,
|
||||
uWeight: prefs.uWeight || 1.0,
|
||||
matrixScaleDays: prefs.matrixScaleDays || 30,
|
||||
prefId: currentUserId,
|
||||
subscribedBuckets: prefs.subscribedBuckets || [],
|
||||
personalContextSupervisorIds: personalContext?.supervisorUserIds || prefs.supervisorUserIds || [],
|
||||
aiHelpTipSectionIndex: typeof prefs.aiHelpTipSectionIndex === "number" ? prefs.aiHelpTipSectionIndex : 0,
|
||||
tagDefinitions: (prefs.tagDefinitions || []).filter((def: TagDefinition) => !def.name.startsWith("@")),
|
||||
filterTemplates: prefs.filterTemplates || [],
|
||||
quickloadTasks: prefs.quickloadTasks || [],
|
||||
dismissedTaskUpdateIndicators: prefs.dismissedTaskUpdateIndicators || {},
|
||||
collapsedUntilUpdatedTasks: prefs.collapsedUntilUpdatedTasks || {},
|
||||
noteFilter: store.noteFilter,
|
||||
filter: store.filter
|
||||
});
|
||||
};
|
||||
|
||||
export const unwrapRelationId = (value: unknown): string | null => {
|
||||
if (typeof value === "string") {
|
||||
return value || null;
|
||||
}
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
const first = value[0];
|
||||
return typeof first === "string" ? first : null;
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
export const unwrapRelationIds = (value: unknown): string[] => {
|
||||
if (Array.isArray(value)) {
|
||||
return value
|
||||
.map(item => {
|
||||
if (typeof item === "string") return item;
|
||||
if (item && typeof item === "object" && "id" in item && typeof (item as any).id === "string") {
|
||||
return (item as any).id;
|
||||
}
|
||||
return null;
|
||||
})
|
||||
.filter((item): item is string => !!item);
|
||||
}
|
||||
|
||||
if (typeof value === "string") {
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) return [];
|
||||
try {
|
||||
const parsed = JSON.parse(trimmed);
|
||||
return unwrapRelationIds(parsed);
|
||||
} catch {
|
||||
return trimmed.includes(",")
|
||||
? trimmed.split(",").map(item => item.trim()).filter(Boolean)
|
||||
: [trimmed];
|
||||
}
|
||||
}
|
||||
|
||||
if (value && typeof value === "object" && "id" in value && typeof (value as any).id === "string") {
|
||||
return [(value as any).id];
|
||||
}
|
||||
|
||||
return [];
|
||||
};
|
||||
+1219
File diff suppressed because it is too large
Load Diff
@@ -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>
|
||||
);
|
||||
};
|
||||
+210
-269
@@ -1,277 +1,218 @@
|
||||
import { type Component, Show } from "solid-js";
|
||||
import { ArrowLeft, ExternalLink, HelpCircle, Zap, ShieldCheck, ListTodo, LayoutDashboard, Snowflake, Pickaxe, Clock, ArrowUpCircle, Search, Palette, Trash2, Smartphone, BarChart3 } from "lucide-solid";
|
||||
import { type Component, For, Show, onCleanup, onMount } from "solid-js";
|
||||
import { ArrowLeft, BookOpen, Boxes, ExternalLink, HelpCircle, LayoutDashboard, Link2, ListTodo, NotebookText, PencilLine, RefreshCcw, Search, Settings2, Share2, ShieldCheck, Sparkles, WandSparkles, Workflow } from "lucide-solid";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
interface HelpViewProps {
|
||||
onBack?: () => void;
|
||||
}
|
||||
interface HelpViewProps { onBack?: () => void; }
|
||||
interface AccessCard { title: string; content: string; detail: string; href?: string; }
|
||||
interface QuickAction { title: string; steps: string[]; target: string; }
|
||||
interface RelatedLink { label: string; target: string; }
|
||||
interface Section { id: string; title: string; icon: any; intro: string; body: string; steps: string[]; reminder: string; warning: string; related: RelatedLink[]; }
|
||||
interface DeepSection { id: string; title: string; icon: any; intro: string; bullets: string[]; why: string; related: RelatedLink[]; }
|
||||
|
||||
const accessCards: AccessCard[] = [
|
||||
{ title: "Website", content: "tasgrid.ccllc.pro", href: "https://tasgrid.ccllc.pro", detail: "Open TasGrid in your browser or install it as an app on mobile." },
|
||||
{ title: "Login", content: "Use your Cardoza Construction email", detail: "Your account uses the same email and password you use for Outlook." },
|
||||
{ title: "What This Guide Is For", content: "Refresh first, then go deeper", detail: "This guide supports the in-person training you already received." }
|
||||
];
|
||||
|
||||
const quickActions: QuickAction[] = [
|
||||
{ title: "Create a task", target: "create-tasks", steps: ["Open Quick Entry with + or Ctrl/Cmd+K.", "Enter Title, Priority, and Urgency.", "Create the task and let TasGrid keep the active context."] },
|
||||
{ title: "Edit a task", target: "manage-tasks", steps: ["Click the task to open it.", "Edit status, dates, tags, and notes.", "Use the status circle to update progress or completion."] },
|
||||
{ title: "Share a task", target: "share-tasks", steps: ["Open the task.", "Add an @person, @bucket, or #note tag.", "Choose Collaborative or Handoff if prompted."] },
|
||||
{ title: "Move work into a bucket", target: "buckets-supervision", steps: ["Add the bucket's @tag to the task.", "Or create the task while inside that bucket.", "Pin the bucket in Settings if you want it in the switcher."] },
|
||||
{ title: "Switch to notes", target: "notes", steps: ["Use the Tasks/Notes toggle.", "Open or create a note.", "Work inside it like a project document, not just a comment box."] },
|
||||
{ title: "Link a task to a note", target: "notes", steps: ["Open the note's Linked Tasks panel.", "Link an existing task or create a new one.", "TasGrid connects it through the note's #tag."] },
|
||||
{ title: "Find filtered work", target: "finding-work", steps: ["Use search and filters first.", "Switch views if you need a different ranking lens.", "Save the filter if you will reuse it."] },
|
||||
{ title: "Recover from trash", target: "recovery-trash", steps: ["Open Settings.", "Expand Trash.", "Recover the item before its 7-day window expires."] }
|
||||
];
|
||||
|
||||
const coreSections: Section[] = [
|
||||
{ id: "create-tasks", title: "Create Tasks", icon: ListTodo, intro: "Use this when you remember the concept but forgot the exact task flow.", body: "Quick Entry is the main capture flow. It keeps scheduling and context consistent for you.", steps: ["Open Quick Entry with the floating plus button or Ctrl/Cmd+K.", "Enter Title, Priority, and Urgency. Add Size, Due Date, tags, and rich details if needed.", "Create the task. TasGrid automatically applies the current context, like a bucket, supervised user, or active note."], reminder: "Urgency and Due Date stay linked, and new tasks inherit the place you are currently working from.", warning: "If you create work inside a bucket, supervised context, or note, TasGrid usually adds that context automatically.", related: [{ label: "How contexts and sharing work", target: "contexts-sharing" }, { label: "How to find work later", target: "finding-work" }] },
|
||||
{ id: "manage-tasks", title: "Edit and Manage Tasks", icon: PencilLine, intro: "Use this when you know the task exists but forgot where a control lives.", body: "The task detail sheet is the main editing surface for title, status, dates, tags, favorites, and task notes.", steps: ["Click a task to open the detail sheet.", "Edit title, status, priority, urgency, size, due date, tags, favorite state, and the rich text body.", "Use the status circle to move through progress states or mark the task complete."], reminder: "Task cards also support quick actions like status updates, duplicate, and favorite.", warning: "Deleting a task sends it to trash first, so check trash before assuming something is gone forever.", related: [{ label: "Recover from trash", target: "recovery-trash" }, { label: "Templates and recurrence", target: "time-savers" }] },
|
||||
{ id: "notes", title: "Notes", icon: NotebookText, intro: "Use this when you need to refresh how notes behave as workspaces.", body: "Notes are a full mode inside the app. They can hold rich content, linked tasks, favorites, and public or private visibility.", steps: ["Switch to Notes using the Tasks/Notes toggle.", "Create or open a note from the notes sidebar.", "Use Linked Tasks to connect existing work or create a task directly from the note context."], reminder: "Creating a task from a note usually links it automatically through the note's #tag.", warning: "If you rename a note, TasGrid also updates the matching #note tag definition so linked tasks stay aligned.", related: [{ label: "How notes become project workspaces", target: "notes-project-workspaces" }, { label: "How rich editing works", target: "editor-rich-content" }] },
|
||||
{ id: "share-tasks", title: "Share Tasks", icon: Share2, intro: "Use this if you remember that sharing exists, but not the current model.", body: "Task sharing is tag-driven. You share by attaching a user, bucket, or note context to the task.", steps: ["Open the task and edit its tags.", "Add an @person, @bucket, or #note context through the tag picker.", "Choose Collaborative or Handoff when TasGrid asks for a sharing mode."], reminder: "The old mental model of a separate share dialog is no longer the main workflow.", warning: "Handoff and Collaborative do very different things. Handoff moves responsibility; Collaborative keeps the task visible in both places.", related: [{ label: "How contexts and sharing work", target: "contexts-sharing" }, { label: "Buckets and supervision", target: "buckets-supervision" }] },
|
||||
{ id: "buckets-supervision", title: "Buckets and Supervision", icon: Boxes, intro: "Use this if you forgot how to move between your work, team buckets, and supervised contexts.", body: "Contexts control where work lives and who can see it. The same switcher handles My Bucket, pinned shared buckets, and supervised personal contexts.", steps: ["Use the context switcher to move between My Bucket, pinned buckets, and supervised contexts.", "Pin buckets in Settings if you want them in the switcher.", "Create tasks from the context you are already inside so TasGrid applies the right tag automatically."], reminder: "Your own personal context tag, the active bucket tag, and some note tags may stay hidden while still remaining active.", warning: "If a task seems to belong to a bucket or person even though you cannot see the tag, hidden context behavior is often the reason.", related: [{ label: "Share tasks", target: "share-tasks" }, { label: "Deep dive on context behavior", target: "contexts-sharing" }] },
|
||||
{ id: "finding-work", title: "Views and Finding Work", icon: Search, intro: "Use this when the task exists but you cannot remember how to surface it quickly.", body: "TasGrid gives you multiple sort views plus search and filters so you can find the right work without rebuilding lists by hand.", steps: ["Use search for text and the filter controls for tags, ranges, edited today, mine, or starred.", "Switch between Focus, Matrix, Snowball, Dig In, Urgency, Priority, or Progress depending on the lens you need.", "Save filters you reuse often so you do not have to rebuild them every time."], reminder: "Changing views is often faster than changing the task itself.", warning: "If a task seems missing, first check your current context and filters before assuming it was deleted or moved.", related: [{ label: "How task views think", target: "task-views-think" }, { label: "Templates and saved filters", target: "time-savers" }] },
|
||||
{ id: "recovery-trash", title: "Recovery and Trash", icon: RefreshCcw, intro: "Use this when you are trying to undo something or confirm whether an item is really gone.", body: "Settings contains separate trash sections for tasks and notes. Items stay there for 7 days before permanent deletion.", steps: ["Open Settings and expand Trash.", "Look in the Tasks or Notes list depending on what you lost.", "Recover the item or permanently delete it if you are sure."], reminder: "Deletion is usually reversible during the trash window, and the app also offers an immediate undo action.", warning: "Expired trash is permanently removed when the app loads, so do not rely on trash as long-term storage.", related: [{ label: "Manage tasks", target: "manage-tasks" }, { label: "How settings change behavior", target: "settings-behavior" }] }
|
||||
];
|
||||
|
||||
const deepSections: DeepSection[] = [
|
||||
{ id: "task-views-think", title: "How Task Views Think", icon: LayoutDashboard, intro: "Use this when you want to understand why the same task appears differently across views.", bullets: ["Focus, Urgency, Priority, Progress, Snowball, and Dig In all sort the same active task pool by different rules.", "Matrix places incomplete tasks on an urgency-versus-priority grid instead of a list.", "Matrix dots are clickable, size-aware, and show U, S, and P values.", "The Matrix time scale can be adjusted to 1, 7, 30, 60, or 90 days."], why: "If users understand the view's mental model, they stop fighting the sort order and start choosing the right view for the moment.", related: [{ label: "Find work refresher", target: "finding-work" }, { label: "Templates and saved filters", target: "time-savers" }] },
|
||||
{ id: "contexts-sharing", title: "How Contexts and Sharing Work", icon: Share2, intro: "Use this when the app's access behavior feels smarter or stranger than a normal tag list.", bullets: ["TasGrid turns @user, @bucket, and #note tags into structured relationships, not just labels.", "Each context can behave as Collaborative or Handoff.", "Per-user policies, bucket policies, and supervision access all change how work moves and who can see it.", "Some context tags stay hidden in the current view while still remaining active."], why: "Most sharing confusion comes from treating contexts like plain tags when they actually control access and ownership behavior.", related: [{ label: "Share tasks refresher", target: "share-tasks" }, { label: "Buckets and supervision", target: "buckets-supervision" }] },
|
||||
{ id: "notes-project-workspaces", title: "How Notes Become Project Workspaces", icon: NotebookText, intro: "Use this when you want to go beyond simple note editing and understand note structure.", bullets: ["Root notes can have child tabs that inherit the parent note's privacy.", "Desktop supports drag-and-drop tab ordering.", "Linked tasks live at the note workspace level, and root notes manage those relationships directly.", "Share links open note content through the embed route."], why: "Once users treat notes like a workspace with tabs and linked tasks, notes become much more useful than static text pages.", related: [{ label: "Notes refresher", target: "notes" }, { label: "How the editor handles rich content", target: "editor-rich-content" }] },
|
||||
{ id: "editor-rich-content", title: "How the Editor Handles Rich Content", icon: WandSparkles, intro: "Use this when someone remembers that the editor is powerful, but forgot what it can do.", bullets: ["Tasks, notes, and templates all use the same editor.", "Typing / opens slash commands for blocks, lists, checklists, uploads, job files, dividers, and tables.", "The editor supports underline, links, highlight, alignment, resizable tables, @user mentions, and #note mentions.", "Images, videos, and files are handled differently and can be pasted or dragged directly into the editor."], why: "Users trust the note and task body more when they know it can hold real working content, not just plain text.", related: [{ label: "Notes refresher", target: "notes" }, { label: "How notes become project workspaces", target: "notes-project-workspaces" }] },
|
||||
{ id: "time-savers", title: "How Templates, Filters, and Recurrence Save Time", icon: Workflow, intro: "Use this when the user understands the basics and wants to stop repeating setup work.", bullets: ["Task templates can store reusable titles, priority, urgency, tags, and rich descriptions.", "Task filters and note filters can be saved as reusable templates.", "Load All is useful when older history is not currently loaded into the visible list.", "Recurrence supports daily, weekly, and monthly schedules, and completed recurring tasks reopen automatically."], why: "These features remove repeated setup and reduce the amount of remembering users have to do from scratch.", related: [{ label: "Create tasks refresher", target: "create-tasks" }, { label: "Find work refresher", target: "finding-work" }] },
|
||||
{ id: "settings-behavior", title: "How Settings Change Behavior", icon: Settings2, intro: "Use this when a user needs to understand how the app can be configured, not just used.", bullets: ["Settings controls theme, tag values and colors, bucket creation and pinning, supervision access, import tools, trash recovery, app updates, and PWA reset.", "Shared Buckets defines which team spaces exist and whether they behave as Collaborative or Handoff.", "Tag Sharing defines how user contexts behave and who can supervise your personal context.", "Import Tasks helps convert pasted lists into structured work with bulk defaults."], why: "Most team-level confusion comes from not realizing that sharing, buckets, and maintenance behavior are defined in Settings.", related: [{ label: "Buckets and supervision", target: "buckets-supervision" }, { label: "How contexts and sharing work", target: "contexts-sharing" }] }
|
||||
];
|
||||
|
||||
const jumpLinks = [
|
||||
{ label: "Get Unstuck Fast", target: "quick-start" },
|
||||
{ label: "Create Tasks", target: "create-tasks" },
|
||||
{ label: "Share Tasks", target: "share-tasks" },
|
||||
{ label: "Notes", target: "notes" },
|
||||
{ label: "Find Work", target: "finding-work" },
|
||||
{ label: "Deep Dive", target: "task-views-think" }
|
||||
];
|
||||
|
||||
const RelatedTopics: Component<{ items: RelatedLink[]; onJump: (id: string) => void }> = (props) => (
|
||||
<div class="pt-3 border-t border-border/40 space-y-2">
|
||||
<p class="text-[0.625rem] font-black uppercase tracking-widest text-muted-foreground">Related topics</p>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<For each={props.items}>{(item) => <button class="rounded-full border border-border/60 bg-background px-3 py-1.5 text-[0.6875rem] font-medium text-muted-foreground hover:text-foreground hover:border-primary/40 hover:bg-primary/5 transition-colors" onClick={() => props.onJump(item.target)}>{item.label}</button>}</For>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
export const HelpView: Component<HelpViewProps> = (props) => {
|
||||
return (
|
||||
<div class="w-full max-w-3xl mx-auto py-4 sm:py-10 px-4 sm:px-0 animate-in fade-in slide-in-from-bottom-2 duration-500">
|
||||
<header class="flex items-center gap-4 mb-8">
|
||||
<Show when={props.onBack}>
|
||||
<Button variant="ghost" size="icon" onClick={() => props.onBack?.()} class="h-10 w-10 rounded-xl">
|
||||
<ArrowLeft size={20} />
|
||||
</Button>
|
||||
</Show>
|
||||
<div>
|
||||
<h1 class="text-2xl sm:text-4xl font-black tracking-tighter flex items-center gap-3">
|
||||
<HelpCircle size={32} class="text-primary hidden sm:block" />
|
||||
User Guide
|
||||
</h1>
|
||||
<p class="text-[0.625rem] sm:text-xs text-muted-foreground font-mono uppercase tracking-widest opacity-70">
|
||||
Everything you need to know about TaskGrid
|
||||
</p>
|
||||
</div>
|
||||
</header>
|
||||
const scrollToSection = (id?: string, behavior: ScrollBehavior = "smooth") => {
|
||||
if (!id) return;
|
||||
document.getElementById(`help-${id}`)?.scrollIntoView({ behavior, block: "start" });
|
||||
};
|
||||
const getSectionFromHash = () => {
|
||||
if (window.location.hash.startsWith("#/help/")) {
|
||||
return decodeURIComponent(window.location.hash.slice("#/help/".length));
|
||||
}
|
||||
if (window.location.hash.startsWith("#help/")) {
|
||||
return decodeURIComponent(window.location.hash.slice("#help/".length));
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
const jumpTo = (id: string) => {
|
||||
const nextHash = `#/help/${encodeURIComponent(id)}`;
|
||||
if (window.location.hash !== nextHash) window.location.hash = nextHash;
|
||||
scrollToSection(id);
|
||||
};
|
||||
|
||||
<div class="space-y-12 pb-20">
|
||||
{/* Welcome & Access */}
|
||||
<section class="space-y-4">
|
||||
<h2 class="text-xl font-bold border-b border-border pb-2 flex items-center gap-2">
|
||||
<ShieldCheck size={20} class="text-primary" />
|
||||
Welcome & Access
|
||||
</h2>
|
||||
<div class="grid gap-4 sm:grid-cols-2">
|
||||
<div class="p-4 rounded-2xl bg-muted/30 border border-border/50">
|
||||
<p class="text-[0.625rem] font-black uppercase tracking-widest text-muted-foreground mb-1">Website</p>
|
||||
<a href="https://tasgrid.ccllc.pro" target="_blank" class="text-sm font-bold text-primary flex items-center gap-1 hover:underline">
|
||||
tasgrid.ccllc.pro
|
||||
<ExternalLink size={12} />
|
||||
</a>
|
||||
</div>
|
||||
<div class="p-4 rounded-2xl bg-muted/30 border border-border/50">
|
||||
<p class="text-[0.625rem] font-black uppercase tracking-widest text-muted-foreground mb-1">Login</p>
|
||||
<p class="text-sm font-medium">Use your <strong>Cardoza Construction email</strong> (the same one you use for Outlook). Your password should match your email password as well.</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
onMount(() => {
|
||||
const syncToHash = () => window.setTimeout(() => scrollToSection(getSectionFromHash(), "auto"), 50);
|
||||
const handleHelpTarget = (event: Event) => {
|
||||
const detail = (event as CustomEvent).detail as { sectionId?: string } | undefined;
|
||||
window.setTimeout(() => scrollToSection(detail?.sectionId || getSectionFromHash()), 50);
|
||||
};
|
||||
syncToHash();
|
||||
window.addEventListener("hashchange", syncToHash);
|
||||
window.addEventListener("taskgrid:help-target", handleHelpTarget);
|
||||
onCleanup(() => {
|
||||
window.removeEventListener("hashchange", syncToHash);
|
||||
window.removeEventListener("taskgrid:help-target", handleHelpTarget);
|
||||
});
|
||||
});
|
||||
|
||||
{/* Basic Features */}
|
||||
<section class="space-y-6">
|
||||
<h2 class="text-xl font-bold border-b border-border pb-2 flex items-center gap-2">
|
||||
<Zap size={20} class="text-yellow-500" />
|
||||
Basic Features (Getting Started)
|
||||
</h2>
|
||||
|
||||
<div class="space-y-4">
|
||||
<h3 class="font-bold text-lg">Creating Your First Task</h3>
|
||||
<ol class="space-y-3 pl-1">
|
||||
{[
|
||||
<>Click the <strong>large plus (+) sign</strong> in the bottom right corner (or type <code>control + k</code>).</>,
|
||||
<><strong>Title</strong>: Enter a clear name for what needs to be done.</>,
|
||||
<><strong>Priority</strong>: Choose from 1 (trivial) to 10 (absolutely necessary).</>,
|
||||
<><strong>Urgency & Due Date</strong>: These are linked!
|
||||
<ul class="mt-2 space-y-1 ml-4 list-disc text-xs text-muted-foreground">
|
||||
<li>Pick an <strong>Urgency level</strong> (10 is today, 1 is 6 months from now), and the due date will be calculated for you.</li>
|
||||
<li>Alternatively, pick a <strong>Due Date</strong> using the calendar, and the urgency will adjust automatically.</li>
|
||||
</ul>
|
||||
</>,
|
||||
<><strong>Size</strong>: This defaults to <strong>5</strong>. Adjust it if the task is very small (15 mins) or very large (multiple days).</>,
|
||||
<><strong>Description</strong>: Add notes or details. Use the <strong>forward slash (/)</strong> to reveal formatting options like bold text, checklists, and even adding images.</>,
|
||||
<>Click <strong>Create Task</strong>.</>
|
||||
].map((item, i) => (
|
||||
<li class="flex gap-4 items-start">
|
||||
<span class="flex-shrink-0 w-6 h-6 rounded-full bg-primary/10 text-primary flex items-center justify-center text-xs font-bold">{i + 1}</span>
|
||||
<div class="text-sm leading-relaxed">{item}</div>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
</div>
|
||||
|
||||
<div class="space-y-4 pt-4">
|
||||
<h3 class="font-bold text-lg">Viewing Your Tasks</h3>
|
||||
<p class="text-sm text-muted-foreground mb-4">TaskGrid sorts your tasks so you don't have to. The primary views are:</p>
|
||||
<div class="grid gap-4">
|
||||
{[
|
||||
{ icon: ListTodo, label: "Focus (Default)", desc: "Your most important tasks, ranked by a combination of Urgency and Priority." },
|
||||
{
|
||||
icon: LayoutDashboard,
|
||||
label: "Strategy Matrix",
|
||||
desc: "A 4-quadrant grid mapping Urgency vs. Priority.",
|
||||
sub: [
|
||||
{ l: "Do First", d: "Urgent & Important" },
|
||||
{ l: "Schedule", d: "Important but not urgent yet" },
|
||||
{ l: "Delegate", d: "Urgent but not important" },
|
||||
{ l: "Eliminate", d: "Neither urgent nor important" }
|
||||
]
|
||||
},
|
||||
{ icon: Snowflake, label: "Snowball", desc: "Shows your smallest tasks first to help you build momentum." },
|
||||
{ icon: Pickaxe, label: "Dig In", desc: "Shows your largest tasks first for when you have time to focus." },
|
||||
{ icon: Clock, label: "Urgency", desc: "Sorts tasks purely by when they are due." },
|
||||
{ icon: ArrowUpCircle, label: "Priority", desc: "Sorts tasks purely by how important they are." }
|
||||
].map(view => (
|
||||
<div class="p-4 rounded-xl border border-border/50 bg-card/50 flex gap-4 items-start">
|
||||
<div class="mt-1 text-primary p-2 rounded-lg bg-primary/5">
|
||||
<view.icon size={18} />
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<p class="text-sm font-bold">{view.label}</p>
|
||||
<p class="text-sm text-muted-foreground leading-relaxed">{view.desc}</p>
|
||||
{view.sub && (
|
||||
<div class="grid grid-cols-2 gap-2 pt-2 border-t border-border/20">
|
||||
{view.sub.map(s => (
|
||||
<div>
|
||||
<p class="text-[0.625rem] font-black uppercase text-primary/70">{s.l}</p>
|
||||
<p class="text-[0.625rem] text-muted-foreground">{s.d}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-4 pt-4">
|
||||
<h3 class="font-bold text-lg">Other Info</h3>
|
||||
<div class="grid gap-3 sm:grid-cols-3">
|
||||
{[
|
||||
{ icon: Search, label: "Search & Filters", desc: "Use the bar at the top right to find specific tasks or filter by priority, urgency, or tasks edited today." },
|
||||
{ icon: Palette, label: "Themes", desc: "Switch between Light Mode and Dark Mode in the Settings." },
|
||||
{ icon: Trash2, label: "Trash", desc: "Deleted tasks are kept for 7 days before being permanently removed. You can recover them in Settings." }
|
||||
].map(info => (
|
||||
<div class="p-3 rounded-xl border border-border/50 bg-card/10">
|
||||
<div class="flex items-center gap-2 mb-2">
|
||||
<info.icon size={14} class="text-primary" />
|
||||
<p class="text-xs font-bold">{info.label}</p>
|
||||
</div>
|
||||
<p class="text-xs text-muted-foreground leading-relaxed">{info.desc}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-4 pt-4">
|
||||
<h3 class="font-bold text-lg">Mobile Installation</h3>
|
||||
<p class="text-sm text-muted-foreground mb-3">TaskGrid works like a dedicated app on your phone:</p>
|
||||
<div class="grid gap-4 sm:grid-cols-2">
|
||||
<div class="p-4 rounded-xl bg-primary/5 border border-primary/10">
|
||||
<p class="text-xs font-bold flex items-center gap-2 mb-2"><Smartphone size={14} /> Android</p>
|
||||
<p class="text-xs text-muted-foreground">Tap the three dots in Chrome, select "Add to Home Screen," then "Install."</p>
|
||||
</div>
|
||||
<div class="p-4 rounded-xl bg-primary/5 border border-primary/10">
|
||||
<p class="text-xs font-bold flex items-center gap-2 mb-2"><Smartphone size={14} /> iPhone</p>
|
||||
<p class="text-xs text-muted-foreground">Tap the Share icon in Safari and select "Add to Home Screen."</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Advanced Features */}
|
||||
<section class="space-y-6">
|
||||
<h2 class="text-xl font-bold border-b border-border pb-2 flex items-center gap-2">
|
||||
<Zap size={20} class="text-primary" />
|
||||
Advanced Features (Teams & Automation)
|
||||
</h2>
|
||||
|
||||
<div class="space-y-4">
|
||||
<h3 class="font-bold text-lg">Sharing & Collaboration</h3>
|
||||
<p class="text-sm text-muted-foreground mb-4">Collaboration in TaskGrid is flexible and transparent:</p>
|
||||
<ol class="space-y-4 text-sm leading-relaxed">
|
||||
<li>
|
||||
<strong>1. Manual Sharing</strong>: Open a task and use the <strong>Share</strong> menu to add a specific person.
|
||||
<ul class="ml-4 mt-1 list-disc text-muted-foreground">
|
||||
<li>You can also <strong>unshare and split</strong> tasks here. Unshare just removes it from their list and split gives each of you a unique copy of the task.</li>
|
||||
</ul>
|
||||
</li>
|
||||
<li>
|
||||
<strong>2. Tag-Based User Sharing</strong>: Add a tag that matches a coworker's name (e.g., "John Smith").
|
||||
<ul class="ml-4 mt-1 list-disc text-muted-foreground">
|
||||
<li>This automatically shares the task with them.</li>
|
||||
<li>Both of you can edit the task together (Collaborative mode).</li>
|
||||
</ul>
|
||||
</li>
|
||||
<li>
|
||||
<strong>3. Collaboration vs. Handoff</strong>:
|
||||
<ul class="ml-4 mt-1 list-disc text-muted-foreground">
|
||||
<li><strong>Collaborate</strong>: Both users can see and edit.</li>
|
||||
<li><strong>Handoff</strong>: The task is transferred to the other person, and they become the new owner.</li>
|
||||
</ul>
|
||||
</li>
|
||||
</ol>
|
||||
</div>
|
||||
|
||||
<div class="space-y-4 pt-4">
|
||||
<h3 class="font-bold text-lg">Team Spaces (Buckets)</h3>
|
||||
<p class="text-sm text-muted-foreground leading-relaxed italic mb-2">"Buckets" are shared areas for teams (like "Shop" or "Maintenance").</p>
|
||||
<ul class="space-y-2 text-sm leading-relaxed">
|
||||
<li><strong>Move to Bucket</strong>: Simply add a tag that matches the bucket name (e.g., tagging a task with "Shop").</li>
|
||||
<li><strong>Visibility</strong>: By default, bucketed tasks are hidden from your main view to keep your list clean. You can view them by selecting the bucket in the sidebar but they will only appear if you have pinned them.</li>
|
||||
<li><strong>Pinning</strong>: In order to work in a bucket, you must "Pin" it in Settings so it always shows up in your sidebar.</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="space-y-4 pt-4">
|
||||
<h3 class="font-bold text-lg flex items-center gap-2"><BarChart3 size={20} class="text-primary" /> Oversight (Share All)</h3>
|
||||
<p class="text-sm text-muted-foreground leading-relaxed">
|
||||
Managers and team leads can ask their teams to create a "share all" sharing rule in setting. This will add that user's bucket as an option in the sidebar just like shared buckets. Clicking a user's name in your sidebar allows you to view their entire task list, making it easy to track progress across the team.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="space-y-4 pt-4">
|
||||
<h3 class="font-bold text-lg">Automatic Tasks (Recurrence)</h3>
|
||||
<p class="text-sm text-muted-foreground leading-relaxed">
|
||||
For recurring work, you can set a task to repeat <strong>Daily</strong>, <strong>Weekly</strong> (on any specified days of the week), or <strong>Monthly</strong> (on a specified day of the month).
|
||||
</p>
|
||||
<ul class="space-y-1 ml-4 list-disc text-sm text-muted-foreground">
|
||||
<li>When you mark it complete, TaskGrid automatically "un-completes" it for you when the next cycle begins.</li>
|
||||
<li>You'll see status labels like "Complete until Monday" to let you know exactly when it will reappear.</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="space-y-4 pt-4">
|
||||
<h3 class="font-bold text-lg">Bulk Import (Filling a Bucket)</h3>
|
||||
<p class="text-sm text-muted-foreground leading-relaxed italic mb-2">Need to add a long list of tasks at once?</p>
|
||||
<ol class="space-y-2 text-sm leading-relaxed list-decimal ml-4">
|
||||
<li>Go to <strong>Settings > 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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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} />}
|
||||
|
||||
+384
-61
@@ -105,6 +105,80 @@
|
||||
h1 {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.sdk-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
|
||||
gap: 14px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.sdk-card {
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 10px;
|
||||
padding: 14px;
|
||||
background: #fafafa;
|
||||
}
|
||||
|
||||
.sdk-card h4 {
|
||||
margin: 0 0 10px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.sdk-card p {
|
||||
margin: 0 0 10px;
|
||||
font-size: 12px;
|
||||
color: #666;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.sdk-stack {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.sdk-stack input,
|
||||
.sdk-stack textarea,
|
||||
.sdk-stack select {
|
||||
width: 100%;
|
||||
padding: 8px 10px;
|
||||
border: 1px solid #d1d5db;
|
||||
border-radius: 6px;
|
||||
font: inherit;
|
||||
box-sizing: border-box;
|
||||
background: white;
|
||||
}
|
||||
|
||||
.sdk-stack textarea {
|
||||
min-height: 74px;
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
.sdk-inline {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.sdk-inline label {
|
||||
font-size: 12px;
|
||||
color: #444;
|
||||
}
|
||||
|
||||
.sdk-meta {
|
||||
margin-top: 10px;
|
||||
font-size: 12px;
|
||||
color: #555;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.sdk-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
margin-top: 8px;
|
||||
}
|
||||
</style>
|
||||
<script src="https://cdn.jsdelivr.net/npm/pocketbase/dist/pocketbase.umd.js"></script>
|
||||
</head>
|
||||
@@ -116,15 +190,11 @@
|
||||
<div id="env-settings" style="margin-bottom: 20px; padding-bottom: 20px; border-bottom: 1px solid #eee;">
|
||||
<p style="font-size: 13px; color: #666; margin-top: 0;"><strong>Target Environment:</strong></p>
|
||||
<label style="margin-right: 15px; cursor: pointer;">
|
||||
<input type="radio" name="envToggle" value="https://tasgrid.ccllc.pro" checked onchange="updateEnv()">
|
||||
<input type="radio" name="envToggle" value="https://tasgrid.ccllc.pro" onchange="updateEnv()">
|
||||
Production (tasgrid.ccllc.pro)
|
||||
</label>
|
||||
<label style="margin-right: 15px; cursor: pointer;">
|
||||
<input type="radio" name="envToggle" value="http://localhost:4000" onchange="updateEnv()">
|
||||
Localhost:4000
|
||||
</label>
|
||||
<label style="cursor: pointer;">
|
||||
<input type="radio" name="envToggle" value="http://localhost:4001" onchange="updateEnv()">
|
||||
<input type="radio" name="envToggle" value="http://localhost:4001" checked onchange="updateEnv()">
|
||||
Localhost:4001
|
||||
</label>
|
||||
</div>
|
||||
@@ -148,22 +218,93 @@
|
||||
<button onclick="loadSpecificNote()" style="padding: 8px 16px; cursor: pointer;">Load Note</button>
|
||||
</div>
|
||||
|
||||
<p style="font-size: 13px; color: #666; margin-top: 20px;"><strong>Test Headless SDK (Centralized
|
||||
Logic):</strong></p>
|
||||
<div style="display: flex; flex-direction: column; gap: 10px; margin-bottom: 10px;">
|
||||
<div style="display: flex; gap: 10px;">
|
||||
<input type="text" id="sdk-task-title" style="flex: 1; padding: 8px;"
|
||||
placeholder="New Task Title...">
|
||||
<button onclick="createTaskViaSDK()"
|
||||
style="padding: 8px 16px; cursor: pointer; background: #6f42c1; color: white; border: none; border-radius: 4px; font-weight: bold;">Create
|
||||
Task via SDK</button>
|
||||
<p style="font-size: 13px; color: #666; margin-top: 20px;"><strong>Test Headless SDK (Current
|
||||
Creation Model):</strong></p>
|
||||
<div class="sdk-grid">
|
||||
<div class="sdk-card">
|
||||
<h4>Task Creation</h4>
|
||||
<p>Creates a task with normalized tags, structured sharing refs, and optional `#note` linkage, then verifies the task is anchored by personal-context refs instead of the legacy direct user link.</p>
|
||||
<div class="sdk-stack">
|
||||
<input type="text" id="sdk-task-title" placeholder="Task title">
|
||||
<input type="text" id="sdk-task-tags"
|
||||
placeholder="Tags CSV, e.g. sdk_test,#sdk-workspace,@Shared Bucket">
|
||||
<textarea id="sdk-task-share-modes"
|
||||
placeholder="Optional share mode overrides, one per line. Example: #sdk-workspace=collaborative @shared bucket=handoff"></textarea>
|
||||
<div class="sdk-inline">
|
||||
<input type="number" id="sdk-task-priority" min="1" max="10" value="8"
|
||||
style="max-width: 90px;" title="Priority">
|
||||
<input type="number" id="sdk-task-urgency" min="1" max="10" value="4"
|
||||
style="max-width: 90px;" title="Urgency">
|
||||
<input type="number" id="sdk-task-size" min="0" max="10" value="3"
|
||||
style="max-width: 90px;" title="Size">
|
||||
</div>
|
||||
<textarea id="sdk-task-content"
|
||||
placeholder="Task content HTML or plain text">This task was created by a sister app using the current TasGrid SDK flow.</textarea>
|
||||
</div>
|
||||
<div class="sdk-actions">
|
||||
<button onclick="createTaskViaSDK()"
|
||||
style="padding: 8px 16px; cursor: pointer; background: #6f42c1; color: white; border: none; border-radius: 4px; font-weight: bold;">Create
|
||||
Task via SDK</button>
|
||||
</div>
|
||||
<div class="sdk-meta" id="sdk-task-meta">Last created task: none</div>
|
||||
</div>
|
||||
<div style="display: flex; gap: 10px;">
|
||||
<input type="text" id="sdk-note-title" style="flex: 1; padding: 8px;"
|
||||
placeholder="New Note Title...">
|
||||
<button onclick="createNoteViaSDK()"
|
||||
style="padding: 8px 16px; cursor: pointer; background: #fd7e14; color: white; border: none; border-radius: 4px; font-weight: bold;">Create
|
||||
Note via SDK</button>
|
||||
|
||||
<div class="sdk-card">
|
||||
<h4>Note Creation</h4>
|
||||
<p>Creates a canonical-key note and can fully link tasks into the note workspace the new way.</p>
|
||||
<div class="sdk-stack">
|
||||
<input type="text" id="sdk-note-title" placeholder="Note title">
|
||||
<input type="text" id="sdk-note-key" placeholder="Optional note key override">
|
||||
<input type="text" id="sdk-note-tags" placeholder="Tags CSV, e.g. sdk_test">
|
||||
<textarea id="sdk-note-content"
|
||||
placeholder="Note HTML content"><p>This note was created by the updated TasGrid SDK.</p></textarea>
|
||||
<input type="text" id="sdk-note-task-ids"
|
||||
placeholder="Task IDs CSV to fully link into this note workspace">
|
||||
<div class="sdk-inline">
|
||||
<label>Share mode
|
||||
<select id="sdk-note-share-mode">
|
||||
<option value="collaborative" selected>collaborative</option>
|
||||
<option value="handoff">handoff</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>Visibility
|
||||
<select id="sdk-note-private">
|
||||
<option value="false" selected>public</option>
|
||||
<option value="true">private</option>
|
||||
</select>
|
||||
</label>
|
||||
<label style="display: flex; align-items: center; gap: 6px;">
|
||||
<input type="checkbox" id="sdk-note-link-tasks" checked>
|
||||
Link tasks via SDK
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="sdk-actions">
|
||||
<button onclick="createNoteViaSDK()"
|
||||
style="padding: 8px 16px; cursor: pointer; background: #fd7e14; color: white; border: none; border-radius: 4px; font-weight: bold;">Create
|
||||
Note via SDK</button>
|
||||
<button onclick="createLinkedWorkspaceScenario()"
|
||||
style="padding: 8px 16px; cursor: pointer; background: #0f766e; color: white; border: none; border-radius: 4px; font-weight: bold;">Create
|
||||
Task + Linked Note</button>
|
||||
</div>
|
||||
<div class="sdk-meta" id="sdk-note-meta">Last created note: none</div>
|
||||
</div>
|
||||
|
||||
<div class="sdk-card">
|
||||
<h4>Link Existing Tasks</h4>
|
||||
<p>Calls the SDK's direct note-link helper so you can test current workspace linking without creating a new note.</p>
|
||||
<div class="sdk-stack">
|
||||
<input type="text" id="sdk-link-note-id" placeholder="Target note ID">
|
||||
<input type="text" id="sdk-link-task-ids" placeholder="Task IDs CSV to link">
|
||||
</div>
|
||||
<div class="sdk-actions">
|
||||
<button onclick="linkExistingTasksViaSDK()"
|
||||
style="padding: 8px 16px; cursor: pointer; background: #2563eb; color: white; border: none; border-radius: 4px; font-weight: bold;">Link
|
||||
Tasks to Note</button>
|
||||
<button onclick="loadLastCreatedNote()" style="padding: 8px 16px; cursor: pointer;">Load Last
|
||||
Note Embed</button>
|
||||
</div>
|
||||
<div class="sdk-meta" id="sdk-link-meta">Ready to link existing records.</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -186,7 +327,9 @@
|
||||
|
||||
<p><strong>2. Programmatic Creation (TasGrid SDK):</strong></p>
|
||||
<p>The recommended way to create entries is using the <code>tasgrid-sdk.js</code> module. This
|
||||
ensures all TasGrid-specific defaults and date logic (like urgency decay) are handled correctly.
|
||||
now applies TasGrid's current normalized creation rules, including canonical note keys,
|
||||
structured <code>labelTags</code>/<code>shareRefs</code>/<code>noteRefs</code>, owner-context
|
||||
tagging, and note-linked task metadata.
|
||||
</p>
|
||||
|
||||
<p><em>Setup:</em></p>
|
||||
@@ -205,8 +348,12 @@ const task = await createTasgridTask(pb, {
|
||||
urgency: 10, // Automated: 10=Today, 5=Next Week, 1=Six Months
|
||||
priority: 8, // 1-10
|
||||
size: 3, // Default: 3 (1 hour). Range 0-10.
|
||||
tags: ["external"],
|
||||
owner: "USER_ID" // Optional
|
||||
tags: ["external", "@Shared Bucket", "#project-brief"],
|
||||
shareModeByTag: {
|
||||
"@shared bucket": "handoff",
|
||||
"#project-brief": "collaborative"
|
||||
},
|
||||
owner: "USER_ID" // Optional: target personal-context owner, not a direct task.user link
|
||||
});</pre>
|
||||
</div>
|
||||
|
||||
@@ -220,8 +367,9 @@ const note = await createTasgridNote(pb, {
|
||||
title: "Project Brief",
|
||||
content: "<p>HTML content here...</p>",
|
||||
tags: ["documentation"],
|
||||
noteShareMode: "collaborative",
|
||||
isPrivate: false,
|
||||
tasks: [] // Optional: Array of Task IDs to link
|
||||
tasks: [] // Optional: Task IDs to store and fully link into the note workspace
|
||||
});</pre>
|
||||
</div>
|
||||
|
||||
@@ -277,7 +425,97 @@ function syncAuth(iframe) {
|
||||
<script>
|
||||
const pb = new PocketBase('https://pocketbase.ccllc.pro');
|
||||
const statusEl = document.getElementById('status');
|
||||
let ENV_URL = 'https://tasgrid.ccllc.pro';
|
||||
let ENV_URL = 'http://localhost:4001';
|
||||
let lastCreatedTaskId = '';
|
||||
let lastCreatedNoteId = '';
|
||||
|
||||
function setStatus(kind, message) {
|
||||
statusEl.className = kind ? `status ${kind}` : 'status';
|
||||
statusEl.textContent = message;
|
||||
statusEl.style.display = 'block';
|
||||
}
|
||||
|
||||
function parseCsvInput(id) {
|
||||
return (document.getElementById(id)?.value || '')
|
||||
.split(',')
|
||||
.map(value => value.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
function parseShareModeOverrides(id) {
|
||||
const lines = (document.getElementById(id)?.value || '')
|
||||
.split(/\r?\n/)
|
||||
.map(line => line.trim())
|
||||
.filter(Boolean);
|
||||
const result = {};
|
||||
|
||||
for (const line of lines) {
|
||||
const separatorIndex = line.indexOf('=');
|
||||
if (separatorIndex === -1) continue;
|
||||
const key = line.slice(0, separatorIndex).trim().toLowerCase();
|
||||
const value = line.slice(separatorIndex + 1).trim().toLowerCase();
|
||||
if (!key || (value !== 'collaborative' && value !== 'handoff')) continue;
|
||||
result[key] = value;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
function refreshSdkMeta() {
|
||||
document.getElementById('sdk-task-meta').textContent = lastCreatedTaskId
|
||||
? `Last created task: ${lastCreatedTaskId}`
|
||||
: 'Last created task: none';
|
||||
document.getElementById('sdk-note-meta').textContent = lastCreatedNoteId
|
||||
? `Last created note: ${lastCreatedNoteId}`
|
||||
: 'Last created note: none';
|
||||
document.getElementById('sdk-link-meta').textContent = [
|
||||
lastCreatedTaskId ? `task ${lastCreatedTaskId}` : null,
|
||||
lastCreatedNoteId ? `note ${lastCreatedNoteId}` : null
|
||||
].filter(Boolean).join(' | ') || 'Ready to link existing records.';
|
||||
}
|
||||
|
||||
async function loadSdkModule() {
|
||||
return await import(`${ENV_URL}/tasgrid-sdk.js`);
|
||||
}
|
||||
|
||||
function fillLinkingFields() {
|
||||
if (lastCreatedTaskId && !document.getElementById('sdk-note-task-ids').value.trim()) {
|
||||
document.getElementById('sdk-note-task-ids').value = lastCreatedTaskId;
|
||||
}
|
||||
if (lastCreatedTaskId && !document.getElementById('sdk-link-task-ids').value.trim()) {
|
||||
document.getElementById('sdk-link-task-ids').value = lastCreatedTaskId;
|
||||
}
|
||||
if (lastCreatedNoteId && !document.getElementById('sdk-link-note-id').value.trim()) {
|
||||
document.getElementById('sdk-link-note-id').value = lastCreatedNoteId;
|
||||
}
|
||||
}
|
||||
|
||||
function loadLastCreatedNote() {
|
||||
if (!lastCreatedNoteId) {
|
||||
alert('Create a note first.');
|
||||
return;
|
||||
}
|
||||
document.getElementById('note-id-input').value = lastCreatedNoteId;
|
||||
loadSpecificNote();
|
||||
}
|
||||
|
||||
async function inspectTaskContextState(taskId) {
|
||||
const record = await pb.collection('TasGrid').getOne(taskId, {
|
||||
fields: 'id,user,shareRefs,noteRefs,tags,labelTags',
|
||||
requestKey: null
|
||||
});
|
||||
|
||||
const legacyUser = record.user && (typeof record.user === 'string' ? record.user : record.user.id);
|
||||
const shareRefCount = Array.isArray(record.shareRefs) ? record.shareRefs.length : 0;
|
||||
const noteRefCount = Array.isArray(record.noteRefs) ? record.noteRefs.length : 0;
|
||||
|
||||
return {
|
||||
legacyUser: legacyUser || null,
|
||||
shareRefCount,
|
||||
noteRefCount,
|
||||
tags: Array.isArray(record.tags) ? record.tags : []
|
||||
};
|
||||
}
|
||||
|
||||
function updateEnv() {
|
||||
const selected = document.querySelector('input[name="envToggle"]:checked').value;
|
||||
@@ -305,7 +543,7 @@ function syncAuth(iframe) {
|
||||
console.log(`Iframe ${iframe.id} loaded, checking auth sync...`);
|
||||
if (pb.authStore.isValid) {
|
||||
console.log(`Syncing existing auth to ${iframe.id}`);
|
||||
sendAuthToIframes();
|
||||
sendAuth(pb.authStore.token, pb.authStore.model);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -326,26 +564,34 @@ function syncAuth(iframe) {
|
||||
return;
|
||||
}
|
||||
|
||||
statusEl.className = 'status';
|
||||
statusEl.textContent = 'Importing SDK and creating task...';
|
||||
statusEl.style.display = 'block';
|
||||
setStatus('', 'Importing SDK and creating task...');
|
||||
|
||||
try {
|
||||
const { createTasgridTask } = await import(`${ENV_URL}/tasgrid-sdk.js`);
|
||||
const { createTasgridTask, TASGRID_SDK_SIGNATURE } = await loadSdkModule();
|
||||
const priority = parseInt(document.getElementById('sdk-task-priority').value || '8', 10);
|
||||
const urgency = parseInt(document.getElementById('sdk-task-urgency').value || '4', 10);
|
||||
const size = parseInt(document.getElementById('sdk-task-size').value || '3', 10);
|
||||
const tags = parseCsvInput('sdk-task-tags');
|
||||
const shareModeByTag = parseShareModeOverrides('sdk-task-share-modes');
|
||||
|
||||
const record = await createTasgridTask(pb, {
|
||||
title: title,
|
||||
priority: 8,
|
||||
urgency: 4,
|
||||
tags: ["sdk_test"],
|
||||
content: "This task was created by a sister app using the imported Headless SDK!"
|
||||
priority: Number.isFinite(priority) ? priority : 8,
|
||||
urgency: Number.isFinite(urgency) ? urgency : 4,
|
||||
size: Number.isFinite(size) ? size : 3,
|
||||
tags: tags,
|
||||
shareModeByTag,
|
||||
content: document.getElementById('sdk-task-content').value || ""
|
||||
});
|
||||
|
||||
statusEl.className = 'status success';
|
||||
statusEl.textContent = `Task created successfully via SDK (ID: ${record.id})!`;
|
||||
lastCreatedTaskId = record.id;
|
||||
fillLinkingFields();
|
||||
refreshSdkMeta();
|
||||
const inspection = await inspectTaskContextState(record.id);
|
||||
const legacyText = inspection.legacyUser ? `legacy user mirror still present: ${inspection.legacyUser}` : 'no legacy task.user link';
|
||||
setStatus('success', `Task created successfully via SDK ${TASGRID_SDK_SIGNATURE || '(unknown signature)'} (ID: ${record.id}). shareRefs=${inspection.shareRefCount}, noteRefs=${inspection.noteRefCount}, ${legacyText}.`);
|
||||
} catch (err) {
|
||||
statusEl.className = 'status error';
|
||||
statusEl.textContent = 'Failed to create task via SDK: ' + err.message;
|
||||
setStatus('error', 'Failed to create task via SDK: ' + err.message);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -356,25 +602,107 @@ function syncAuth(iframe) {
|
||||
return;
|
||||
}
|
||||
|
||||
statusEl.className = 'status';
|
||||
statusEl.textContent = 'Importing SDK and creating note...';
|
||||
statusEl.style.display = 'block';
|
||||
setStatus('', 'Importing SDK and creating note...');
|
||||
|
||||
try {
|
||||
const { createTasgridNote } = await import(`${ENV_URL}/tasgrid-sdk.js`);
|
||||
const { createTasgridNote, TASGRID_SDK_SIGNATURE } = await loadSdkModule();
|
||||
const taskIds = parseCsvInput('sdk-note-task-ids');
|
||||
|
||||
const record = await createTasgridNote(pb, {
|
||||
title: title,
|
||||
content: "<p>This note was created headlessly via the SDK!</p>",
|
||||
tags: ["sdk_test"],
|
||||
isPrivate: false
|
||||
key: document.getElementById('sdk-note-key').value.trim() || undefined,
|
||||
content: document.getElementById('sdk-note-content').value || "",
|
||||
tags: parseCsvInput('sdk-note-tags'),
|
||||
noteShareMode: document.getElementById('sdk-note-share-mode').value,
|
||||
isPrivate: document.getElementById('sdk-note-private').value === 'true',
|
||||
tasks: taskIds,
|
||||
linkTasks: document.getElementById('sdk-note-link-tasks').checked
|
||||
});
|
||||
|
||||
statusEl.className = 'status success';
|
||||
statusEl.textContent = `Note created successfully via SDK (ID: ${record.id})!`;
|
||||
lastCreatedNoteId = record.id;
|
||||
document.getElementById('note-id-input').value = record.id;
|
||||
fillLinkingFields();
|
||||
refreshSdkMeta();
|
||||
loadSpecificNote();
|
||||
setStatus('success', `Note created successfully via SDK ${TASGRID_SDK_SIGNATURE || '(unknown signature)'} (ID: ${record.id}). The notes iframe was pointed at the new note.`);
|
||||
} catch (err) {
|
||||
statusEl.className = 'status error';
|
||||
statusEl.textContent = 'Failed to create note via SDK: ' + err.message;
|
||||
setStatus('error', 'Failed to create note via SDK: ' + err.message);
|
||||
}
|
||||
}
|
||||
|
||||
async function linkExistingTasksViaSDK() {
|
||||
const noteId = document.getElementById('sdk-link-note-id').value.trim();
|
||||
const taskIds = parseCsvInput('sdk-link-task-ids');
|
||||
if (!pb.authStore.isValid) {
|
||||
alert("Please log in first before linking tasks via SDK.");
|
||||
return;
|
||||
}
|
||||
if (!noteId || taskIds.length === 0) {
|
||||
alert("Provide both a target note ID and at least one task ID.");
|
||||
return;
|
||||
}
|
||||
|
||||
setStatus('', 'Importing SDK and linking tasks to note...');
|
||||
|
||||
try {
|
||||
const { linkTasgridTasksToNote, TASGRID_SDK_SIGNATURE } = await loadSdkModule();
|
||||
const records = await linkTasgridTasksToNote(pb, noteId, taskIds);
|
||||
lastCreatedNoteId = noteId;
|
||||
fillLinkingFields();
|
||||
refreshSdkMeta();
|
||||
const inspection = await inspectTaskContextState(taskIds[0]);
|
||||
setStatus('success', `Linked ${records.length} task(s) to note ${noteId} via SDK ${TASGRID_SDK_SIGNATURE || '(unknown signature)'}. First task now has shareRefs=${inspection.shareRefCount} and noteRefs=${inspection.noteRefCount}.`);
|
||||
} catch (err) {
|
||||
setStatus('error', 'Failed to link tasks via SDK: ' + err.message);
|
||||
}
|
||||
}
|
||||
|
||||
async function createLinkedWorkspaceScenario() {
|
||||
if (!pb.authStore.isValid) {
|
||||
alert("Please log in first before creating linked workspace data.");
|
||||
return;
|
||||
}
|
||||
|
||||
setStatus('', 'Creating a task and linked note scenario via the SDK...');
|
||||
|
||||
try {
|
||||
const { createTasgridTask, createTasgridNote, TASGRID_SDK_SIGNATURE } = await loadSdkModule();
|
||||
const timestamp = new Date().toISOString().replace(/[^\d]/g, '').slice(0, 12);
|
||||
const workspaceTag = `#sdk-workspace-${timestamp}`;
|
||||
const taskRecord = await createTasgridTask(pb, {
|
||||
title: `SDK Workspace Task ${timestamp}`,
|
||||
priority: 8,
|
||||
urgency: 4,
|
||||
size: 3,
|
||||
tags: ['sdk_test', workspaceTag],
|
||||
shareModeByTag: {
|
||||
[workspaceTag.toLowerCase()]: 'collaborative'
|
||||
},
|
||||
content: 'Workspace task created by the test simulator.'
|
||||
});
|
||||
|
||||
const noteRecord = await createTasgridNote(pb, {
|
||||
title: `SDK Workspace Note ${timestamp}`,
|
||||
tags: ['sdk_test'],
|
||||
content: `<p>Workspace note for task <code>${taskRecord.id}</code>.</p>`,
|
||||
noteShareMode: 'collaborative',
|
||||
isPrivate: false,
|
||||
tasks: [taskRecord.id],
|
||||
linkTasks: true
|
||||
});
|
||||
|
||||
lastCreatedTaskId = taskRecord.id;
|
||||
lastCreatedNoteId = noteRecord.id;
|
||||
document.getElementById('note-id-input').value = noteRecord.id;
|
||||
document.getElementById('sdk-note-task-ids').value = taskRecord.id;
|
||||
fillLinkingFields();
|
||||
refreshSdkMeta();
|
||||
loadSpecificNote();
|
||||
const inspection = await inspectTaskContextState(taskRecord.id);
|
||||
const legacyText = inspection.legacyUser ? `legacy user mirror still present: ${inspection.legacyUser}` : 'no legacy task.user link';
|
||||
setStatus('success', `Created linked workspace scenario via SDK ${TASGRID_SDK_SIGNATURE || '(unknown signature)'}. Task ${taskRecord.id} and note ${noteRecord.id} were created, loaded into the embed, and verified with shareRefs=${inspection.shareRefCount}, noteRefs=${inspection.noteRefCount}, ${legacyText}.`);
|
||||
} catch (err) {
|
||||
setStatus('error', 'Failed to create linked workspace scenario: ' + err.message);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -382,21 +710,17 @@ function syncAuth(iframe) {
|
||||
const email = document.getElementById('email').value;
|
||||
const password = document.getElementById('password').value;
|
||||
|
||||
statusEl.className = 'status';
|
||||
statusEl.textContent = 'Logging in...';
|
||||
statusEl.style.display = 'block';
|
||||
setStatus('', 'Logging in...');
|
||||
|
||||
try {
|
||||
const authData = await pb.collection('users').authWithPassword(email, password);
|
||||
|
||||
statusEl.className = 'status success';
|
||||
statusEl.textContent = `Logged in as ${authData.record.email}! Syncing iframes...`;
|
||||
setStatus('success', `Logged in as ${authData.record.email}! Syncing iframes...`);
|
||||
|
||||
// Automatically send auth to iframes
|
||||
sendAuth(pb.authStore.token, authData.record);
|
||||
} catch (err) {
|
||||
statusEl.className = 'status error';
|
||||
statusEl.textContent = 'Login failed: ' + err.message;
|
||||
setStatus('error', 'Login failed: ' + err.message);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -428,12 +752,11 @@ function syncAuth(iframe) {
|
||||
window.addEventListener('load', () => {
|
||||
if (pb.authStore.isValid && pb.authStore.model) {
|
||||
document.getElementById('email').value = pb.authStore.model.email || "";
|
||||
statusEl.className = 'status success';
|
||||
statusEl.textContent = 'Session restored. Click login or send manual token to sync.';
|
||||
statusEl.style.display = 'block';
|
||||
setStatus('success', 'Session restored. Click login or send manual token to sync.');
|
||||
}
|
||||
refreshSdkMeta();
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
</html>
|
||||
|
||||
Reference in New Issue
Block a user