Compare commits

...

8 Commits

Author SHA1 Message Date
tcardoza ec67fb89ca embed improved
CI / build (push) Has been skipped
CI / deploy (push) Successful in 1m8s
2026-03-26 11:59:50 -05:00
tcardoza 6900a7cee1 SDK updates
CI / build (push) Has been skipped
CI / deploy (push) Successful in 1m2s
2026-03-26 11:39:52 -05:00
tcardoza e0e8f4e7e8 routing added
CI / build (push) Has been skipped
CI / deploy (push) Successful in 1m12s
2026-03-26 11:06:40 -05:00
tcardoza 613a2c72f5 improved chat ui
CI / build (push) Has been skipped
CI / deploy (push) Successful in 1m34s
2026-03-26 10:46:26 -05:00
tcardoza e45bc9e2b6 tips working and optimized
CI / build (push) Has been skipped
CI / deploy (push) Successful in 1m19s
2026-03-25 19:37:30 -05:00
tcardoza 7653c6bf0e fix
CI / build (push) Has been skipped
CI / deploy (push) Successful in 1m24s
2026-03-25 18:48:37 -05:00
tcardoza f30b160fa5 tip fix
CI / build (push) Has been skipped
CI / deploy (push) Successful in 1m19s
2026-03-25 18:41:57 -05:00
tcardoza 82c5f07f7a improved chat ui
CI / build (push) Has been skipped
CI / deploy (push) Successful in 1m22s
2026-03-25 18:38:04 -05:00
12 changed files with 2143 additions and 315 deletions
+66 -20
View File
@@ -2,7 +2,7 @@
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.
## Purpose and Scope
## 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.
@@ -20,6 +20,9 @@ This document is a thorough reference for how TasGrid works today. It is intende
- `@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.
## Navigation and Access
@@ -40,21 +43,33 @@ This document is a thorough reference for how TasGrid works today. It is intende
- `Snowball`
- `Dig In`
- `Progress`
- The bottom of the sidebar includes `Help`, `Settings`, and the context switcher.
- 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.
### Mobile Navigation
- The mobile bottom nav exposes:
- `Focus`
- `Matrix`
- 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.
### Ask Questions Access
- `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
- Desktop Help is available from the left sidebar.
@@ -62,7 +77,7 @@ This document is a thorough reference for how TasGrid works today. It is intende
- Some higher-confusion areas include direct Help links that open the relevant help section.
- Help supports deep-link anchors in the form `#help/<section-id>`.
## Tasks
## Task Creation and Editing
### Task Creation
@@ -116,6 +131,7 @@ This document is a thorough reference for how TasGrid works today. It is intende
- 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.
### Task Card Actions
@@ -131,6 +147,8 @@ This document is a thorough reference for how TasGrid works today. It is intende
- Some collaboratively shared tasks show update indicators.
- There is also a `Collapse until updated` behavior for reducing clutter on ongoing shared work.
## Task Lifecycle and Scheduling
### Task Status and Progress
- Task status is numeric from `0` to `10`.
@@ -307,7 +325,7 @@ These behaviors are important because a user may feel a task has disappeared whe
- This is especially relevant for search and filters because a task cannot be found if it has not been loaded into memory yet.
- Users may interpret missing search results as deletion when the actual issue is partial history loading.
## Contexts, Sharing, Buckets, and Supervision
## Contexts and Sharing
### Context Types
@@ -331,9 +349,11 @@ These behaviors are important because a user may feel a task has disappeared whe
- `My Bucket`
- pinned shared buckets
- supervised personal contexts shared with the current user
- Desktop places this at the bottom of the sidebar.
- 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.
@@ -365,8 +385,22 @@ These behaviors are important because a user may feel a task has disappeared whe
- 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:
@@ -391,6 +425,7 @@ These behaviors are important because a user may feel a task has disappeared whe
- 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
@@ -404,7 +439,7 @@ These behaviors are important because a user may feel a task has disappeared whe
- trash
- whether the task belongs to an unpinned bucket
## Notes
## Notes Basics
### Notes Mode
@@ -466,6 +501,8 @@ These behaviors are important because a user may feel a task has disappeared whe
- matching `noteRefs`
- matching note-based `shareRefs`
## Note Sharing and Linked Work
### Public, Private, and Share Links
- Notes can be public or private.
@@ -478,6 +515,8 @@ These behaviors are important because a user may feel a task has disappeared whe
- 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
@@ -508,6 +547,7 @@ Important privacy caveat:
- 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:
@@ -537,7 +577,7 @@ Current behavior caveats:
- 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
## Rich Editor Basics
### Shared Editor Model
@@ -618,6 +658,8 @@ 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:
@@ -806,9 +848,11 @@ Background update behavior:
- Like many PWAs, TasGrid can continue showing older cached assets until a service worker update is applied.
- `Update App` and `Reset PWA` exist to help recover from stale-client issues.
## Important Constraints and User-Facing Caveats
## 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.
@@ -826,7 +870,9 @@ Background update behavior:
- Bucket archive exists, but a confirmed user-facing bucket restore flow was not identified.
- In the notes editor, note mention previews exist, but `Open Note` from that preview is not fully wired through the same way it is in task detail.
## Explicit Exclusions
## Reference Notes
### Explicit Exclusions
- SDK and API documentation
- integration-developer workflows
@@ -835,6 +881,16 @@ Background update behavior:
- 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:
@@ -847,13 +903,3 @@ When a user thinks something is missing or not working, check these in order:
6. Check whether the task belongs to an unpinned bucket or hidden system context.
7. For stale UI behavior, try `Update App`.
8. For persistent cache issues, use `Reset PWA`.
## Summary for Future Help-Agent Use
- The biggest mental models to preserve are:
- contexts are first-class behavior, not decorative tags
- notes are workspaces, not just text documents
- due date and urgency are linked
- hidden tags can still matter
- several views are ranking lenses over the same work pool
- retrieval issues are often context, filtering, or loading issues rather than actual data loss
+813 -57
View File
@@ -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);
+196 -22
View File
@@ -1,11 +1,12 @@
import { type Component, createSignal, onMount, onCleanup, createMemo, createEffect, 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, setStore } 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 })));
@@ -20,13 +21,93 @@ const AIHelpView = lazy(() => import('./views/AIHelpView').then(m => ({ default:
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 [lastNonAIView, setLastNonAIView] = 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();
@@ -39,21 +120,78 @@ const App: Component = () => {
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 mediaQuery = window.matchMedia("(min-width: 768px)");
const openHelpGuide = (sectionId?: string) => {
const nextHash = sectionId ? `#help/${sectionId}` : "#help";
if (window.location.hash !== nextHash) {
window.location.hash = nextHash;
}
setStore('isNotepadMode', false);
setCurrentView("help");
navigateToView("help", { sectionId });
window.dispatchEvent(new CustomEvent("taskgrid:help-target", { detail: { sectionId } }));
};
const openAIHelp = () => {
setStore('isNotepadMode', false);
setCurrentView("help_ai");
navigateToView("help_ai");
};
const handleMediaChange = (event: MediaQueryListEvent) => {
@@ -63,13 +201,39 @@ const App: Component = () => {
const handleLocChange = () => {
setLocation(window.location.href);
const hash = window.location.hash;
if (hash === "#help" || hash.startsWith("#help/")) {
setCurrentView("help");
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 = setCurrentView;
(window as any).setCurrentView = navigateToView;
(window as any).openTasGridHelp = openHelpGuide;
(window as any).openTasGridAIHelp = openAIHelp;
@@ -80,7 +244,15 @@ const App: Component = () => {
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();
@@ -129,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 = () => {
@@ -141,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;
};
@@ -152,9 +321,14 @@ const App: Component = () => {
<Show when={isAuthenticated()} fallback={<AuthCallback onSuccess={() => { }} />}>
<Layout
currentView={effectiveView()}
setView={setCurrentView}
setView={navigateToView}
selectedNoteId={selectedNoteId()}
setSelectedNoteId={setSelectedNoteId}
onOpenTasks={openTasks}
onOpenNotes={openNotes}
isAIHelpOpen={currentView() === "help_ai" && isDesktop()}
onToggleAIHelp={() => setCurrentView(currentView() === "help_ai" ? lastNonAIView() : "help_ai")}
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>}>
@@ -165,9 +339,9 @@ const App: Component = () => {
<Show when={effectiveView() === "snowball"}><SnowballView /></Show>
<Show when={effectiveView() === "dig_in"}><DigInView /></Show>
<Show when={effectiveView() === "progress"}><ProgressView /></Show>
<Show when={effectiveView() === "settings"}><SettingsView setView={setCurrentView} /></Show>
<Show when={effectiveView() === "settings"}><SettingsView setView={navigateToView} /></Show>
<Show when={effectiveView() === "help_ai"}><AIHelpView /></Show>
<Show when={effectiveView() === "help"}><HelpView onBack={() => setCurrentView("settings")} /></Show>
<Show when={effectiveView() === "help"}><HelpView onBack={() => navigateToView("settings")} /></Show>
</Suspense>
</Layout>
</Show>
+90 -114
View File
@@ -1,30 +1,16 @@
import { type Component, For, Show, createEffect, createMemo, createSignal } from "solid-js";
import { Bot, LoaderCircle, RefreshCw, Send } from "lucide-solid";
import { MessageCircle, LoaderCircle, RefreshCw, ArrowUp } from "lucide-solid";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
import { FIREWORKS_API_KEY, FIREWORKS_API_URL, FIREWORKS_HELP_MODEL } from "@/lib/app-config";
import helpGuide from "../../docs/HELP_GUIDE.md?raw";
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 SYSTEM_PROMPT = `You are the TasGrid AI Help assistant.
You answer questions about TasGrid using the reference guide below.
You must follow this instruction exactly: "use only this guide for all information and never assume you know something that isn't mentioned in the guide"
If the guide does not contain the answer, say that the guide does not mention it.
Do not use outside product knowledge.
Do not invent buttons, workflows, settings, or behaviors.
Prefer concise, practical answers.
When helpful, mention the relevant section name from the guide.
TASGRID REFERENCE GUIDE
${helpGuide}`;
const SUGGESTED_PROMPTS = [
"How does sharing work in TasGrid?",
"What is the difference between Handoff and Collaborative?",
@@ -33,7 +19,7 @@ const SUGGESTED_PROMPTS = [
"How do recurrence and templates behave?"
];
const INITIAL_MESSAGE = "Ask about TasGrid features, workflows, or edge cases. I only answer from the reference guide, so if the guide does not mention something, I will tell you.";
const INITIAL_MESSAGE = "Ask a question about how TasGrid works.";
export const AIHelpPanel: Component<{
variant?: "page" | "sidebar";
@@ -48,7 +34,10 @@ export const AIHelpPanel: Component<{
const [error, setError] = createSignal<string | null>(null);
let messagesViewport: HTMLDivElement | undefined;
const hasApiKey = createMemo(() => FIREWORKS_API_KEY.trim().length > 0);
const hasApiKey = createMemo(() => hasFireworksApiKey());
const showSuggestions = createMemo(() =>
hasApiKey() && messages().length === 1 && messages()[0]?.role === "assistant"
);
createEffect(() => {
messages();
@@ -79,7 +68,7 @@ export const AIHelpPanel: Component<{
...nextMessages,
{
role: "assistant",
content: "AI Help is not configured yet. Add `VITE_FIREWORKS_API_KEY` to the app environment to enable Fireworks responses."
content: "Questions are not configured yet. Add `VITE_FIREWORKS_API_KEY` to the app environment to enable responses."
}
]);
setIsLoading(false);
@@ -87,39 +76,7 @@ export const AIHelpPanel: Component<{
}
try {
const response = await fetch(FIREWORKS_API_URL, {
method: "POST",
headers: {
"Content-Type": "application/json",
"Authorization": `Bearer ${FIREWORKS_API_KEY}`
},
body: JSON.stringify({
model: FIREWORKS_HELP_MODEL,
reasoning_effort: "low",
temperature: 0.2,
max_tokens: 700,
messages: [
{ role: "system", content: SYSTEM_PROMPT },
...nextMessages.map(message => ({
role: message.role,
content: message.content
}))
]
})
});
if (!response.ok) {
const bodyText = await response.text();
throw new Error(bodyText || `Request failed with status ${response.status}`);
}
const data = await response.json();
const assistantText = data?.choices?.[0]?.message?.content?.trim();
if (!assistantText) {
throw new Error("The help assistant did not return any text.");
}
const assistantText = await askAIHelp(nextMessages);
setMessages(prev => [...prev, { role: "assistant", content: assistantText }]);
} catch (err) {
const message = err instanceof Error ? err.message : "Unknown error";
@@ -159,8 +116,8 @@ export const AIHelpPanel: Component<{
isSidebar() ? "px-3 py-3" : "px-4 py-4 sm:px-5"
)}>
<div class="flex items-center gap-2 min-w-0">
<Bot size={16} class="text-primary shrink-0" />
<h1 class={cn("font-semibold tracking-tight", isSidebar() ? "text-sm" : "text-lg")}>AI Help</h1>
<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"
@@ -177,62 +134,74 @@ export const AIHelpPanel: Component<{
"flex min-h-0 flex-1 flex-col",
isSidebar() ? "p-3 gap-3" : "p-4 gap-3 sm:p-5"
)}>
<Show
when={hasApiKey()}
fallback={
<div class={cn(
"rounded-2xl border border-amber-500/30 bg-amber-500/10",
isSidebar() ? "p-3 text-[0.75rem]" : "p-4 text-sm"
)}>
Add `VITE_FIREWORKS_API_KEY` to enable AI Help. The view is wired to Fireworks model `{FIREWORKS_HELP_MODEL}`.
</div>
}
>
<div class="flex flex-wrap gap-2">
<For each={SUGGESTED_PROMPTS}>
{(prompt) => (
<button
class={cn(
"rounded-full border border-border bg-background text-left font-medium text-foreground transition-colors hover:bg-muted",
isSidebar() ? "px-2.5 py-1.5 text-[0.6875rem]" : "px-3 py-2 text-xs sm:text-sm"
)}
onClick={() => void submitQuestion(prompt)}
disabled={isLoading()}
>
{prompt}
</button>
)}
</For>
<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 rounded-xl border border-border bg-background",
isSidebar() ? "p-2.5" : "p-3"
"min-h-0 flex-1 overflow-y-auto space-y-3",
isSidebar() ? "px-0.5" : "px-0.5"
)}
>
<For each={messages()}>
{(message) => (
<div class={cn("flex", message.role === "user" ? "justify-end" : "justify-start")}>
<div class="space-y-2">
<div
class={cn(
"rounded-2xl px-4 py-3 leading-relaxed whitespace-pre-wrap shadow-sm",
isSidebar() ? "max-w-[92%] text-[0.75rem]" : "max-w-[90%] sm:max-w-[80%] text-sm",
"flex",
message.role === "user"
? "bg-primary text-primary-foreground"
: "bg-card border border-border text-foreground"
? "justify-center pt-8 pb-0"
: "justify-start"
)}
>
<div class="mb-1 flex items-center gap-2 text-[0.625rem] font-black uppercase tracking-[0.18em] opacity-70">
<Show when={message.role === "assistant"} fallback={<span>You</span>}>
<Bot size={12} />
<span>Guide Assistant</span>
<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>{message.content}</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>
@@ -241,15 +210,15 @@ export const AIHelpPanel: Component<{
<div class="flex justify-start">
<div class={cn(
"rounded-2xl px-4 py-3 leading-relaxed shadow-sm bg-card border border-border text-foreground",
isSidebar() ? "max-w-[92%] text-[0.75rem]" : "max-w-[80%] text-sm"
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">
<Bot size={12} />
<span>Guide Assistant</span>
<MessageCircle size={12} />
<span>TasGrid Help</span>
</div>
<div class="flex items-center gap-2 text-muted-foreground">
<LoaderCircle size={14} class="animate-spin" />
Looking through the guide...
Finding an answer...
</div>
</div>
</div>
@@ -276,26 +245,33 @@ export const AIHelpPanel: Component<{
void submitQuestion();
}}
>
<textarea
value={input()}
onInput={(e) => setInput(e.currentTarget.value)}
placeholder="Ask how a TasGrid feature works, what a setting does, or why something may behave a certain way..."
class={cn(
"flex w-full rounded-xl border border-input bg-background transition-colors placeholder:text-muted-foreground/60 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring resize-none leading-relaxed",
isSidebar() ? "min-h-[88px] px-3 py-2.5 text-[0.75rem]" : "min-h-[96px] px-3 py-2.5 text-sm"
)}
/>
<div class={cn(
"flex gap-2",
isSidebar() ? "flex-col" : "justify-end"
)}>
<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"
class={cn("gap-2 rounded-xl", isSidebar() && "w-full")}
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"
>
<Send size={14} />
Ask AI Help
<ArrowUp size={15} />
</Button>
</div>
</form>
+51 -15
View File
@@ -1,6 +1,6 @@
import { type Component, type JSX, createSignal, createEffect, Show, lazy, Suspense, onMount, onCleanup } from "solid-js";
import { Sidebar, BottomNav, ContextSwitcher } from "./Navigation";
import { store, setStore, activeTaskId, setActiveTaskId, 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,8 +15,13 @@ 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;
}
@@ -24,21 +29,25 @@ 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);
@@ -64,12 +73,38 @@ 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 (
@@ -94,7 +129,7 @@ export const Layout: Component<LayoutProps> = (props) => {
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() ? `${sidebarWidthClass()} translate-x-0` : `${sidebarWidthClass()} -translate-x-full`,
!isSidebarLocked() && (isSidebarPeeking() || (store.isNotepadMode && !selectedNoteId())) && "translate-x-0 shadow-2xl ring-1 ring-border"
!isSidebarLocked() && (isSidebarPeeking() || (store.isNotepadMode && !props.selectedNoteId)) && "translate-x-0 shadow-2xl ring-1 ring-border"
)}
onMouseEnter={() => !isSidebarLocked() && setIsSidebarPeeking(true)}
onMouseLeave={() => {
@@ -116,6 +151,7 @@ export const Layout: Component<LayoutProps> = (props) => {
setView={props.setView}
isAIHelpOpen={!!props.isAIHelpOpen}
onToggleAIHelp={() => props.onToggleAIHelp?.()}
aiHelpTip={props.aiHelpTip}
isLocked={isSidebarLocked()}
setIsLocked={setIsSidebarLocked}
isPeeking={isSidebarPeeking()}
@@ -156,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>
@@ -206,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>
@@ -215,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>
@@ -251,7 +287,7 @@ export const Layout: Component<LayoutProps> = (props) => {
</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>
+165
View File
@@ -0,0 +1,165 @@
import { type Component, createMemo } from "solid-js";
import { cn } from "@/lib/utils";
const escapeHtml = (value: string) =>
value
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;");
const applyInlineMarkdown = (value: string) => {
let text = escapeHtml(value);
text = text.replace(/`([^`]+)`/g, "<code>$1</code>");
text = text.replace(/\[([^\]]+)\]\((https?:\/\/[^\s)]+)\)/g, '<a href="$2" target="_blank" rel="noreferrer">$1</a>');
text = text.replace(/\*\*([^*]+)\*\*/g, "<strong>$1</strong>");
text = text.replace(/(^|[\s(])\*([^*]+)\*(?=[\s).,!?:;]|$)/g, '$1<em>$2</em>');
return text;
};
const splitTableRow = (line: string) => {
const trimmed = line.trim().replace(/^\|/, "").replace(/\|$/, "");
return trimmed.split("|").map(cell => applyInlineMarkdown(cell.trim()));
};
const isTableSeparator = (line: string) => {
const trimmed = line.trim().replace(/^\|/, "").replace(/\|$/, "");
if (!trimmed) return false;
return trimmed
.split("|")
.every(cell => /^:?-{3,}:?$/.test(cell.trim()));
};
const isTableRow = (line: string) => {
const trimmed = line.trim();
return trimmed.includes("|") && splitTableRow(trimmed).length > 1;
};
const renderMarkdown = (markdown: string) => {
const lines = markdown.replace(/\r\n/g, "\n").split("\n");
const html: string[] = [];
let i = 0;
while (i < lines.length) {
const line = lines[i];
const trimmed = line.trim();
if (!trimmed) {
i += 1;
continue;
}
if (trimmed.startsWith("```")) {
const codeLines: string[] = [];
i += 1;
while (i < lines.length && !lines[i].trim().startsWith("```")) {
codeLines.push(lines[i]);
i += 1;
}
if (i < lines.length) i += 1;
html.push(`<pre><code>${escapeHtml(codeLines.join("\n"))}</code></pre>`);
continue;
}
const headingMatch = trimmed.match(/^(#{1,6})\s+(.+)$/);
if (headingMatch) {
const level = headingMatch[1].length;
const text = applyInlineMarkdown(headingMatch[2].trim());
html.push(`<h${level}>${text}</h${level}>`);
i += 1;
continue;
}
if (
i + 1 < lines.length &&
isTableRow(lines[i]) &&
isTableSeparator(lines[i + 1])
) {
const headerCells = splitTableRow(lines[i]);
const bodyRows: string[] = [];
i += 2;
while (i < lines.length && lines[i].trim() && isTableRow(lines[i])) {
const cells = splitTableRow(lines[i]).map(cell => `<td>${cell}</td>`);
bodyRows.push(`<tr>${cells.join("")}</tr>`);
i += 1;
}
html.push(
`<div class="table-wrap"><table><thead><tr>${headerCells.map(cell => `<th>${cell}</th>`).join("")}</tr></thead>${bodyRows.length > 0 ? `<tbody>${bodyRows.join("")}</tbody>` : ""}</table></div>`
);
continue;
}
if (/^[-*]\s+/.test(trimmed)) {
const items: string[] = [];
while (i < lines.length && /^[-*]\s+/.test(lines[i].trim())) {
items.push(`<li>${applyInlineMarkdown(lines[i].trim().replace(/^[-*]\s+/, ""))}</li>`);
i += 1;
}
html.push(`<ul>${items.join("")}</ul>`);
continue;
}
if (/^\d+\.\s+/.test(trimmed)) {
const items: string[] = [];
while (i < lines.length && /^\d+\.\s+/.test(lines[i].trim())) {
items.push(`<li>${applyInlineMarkdown(lines[i].trim().replace(/^\d+\.\s+/, ""))}</li>`);
i += 1;
}
html.push(`<ol>${items.join("")}</ol>`);
continue;
}
if (/^>\s?/.test(trimmed)) {
const quoteLines: string[] = [];
while (i < lines.length && /^>\s?/.test(lines[i].trim())) {
quoteLines.push(applyInlineMarkdown(lines[i].trim().replace(/^>\s?/, "")));
i += 1;
}
html.push(`<blockquote><p>${quoteLines.join("<br />")}</p></blockquote>`);
continue;
}
const paragraphLines: string[] = [];
while (i < lines.length && lines[i].trim() && !lines[i].trim().startsWith("```") && !/^[-*]\s+/.test(lines[i].trim()) && !/^\d+\.\s+/.test(lines[i].trim()) && !/^>\s?/.test(lines[i].trim())) {
paragraphLines.push(applyInlineMarkdown(lines[i].trim()));
i += 1;
}
html.push(`<p>${paragraphLines.join("<br />")}</p>`);
}
return html.join("");
};
export const MarkdownMessage: Component<{
content: string;
class?: string;
}> = (props) => {
const html = createMemo(() => renderMarkdown(props.content));
return (
<div
class={cn(
"prose prose-sm dark:prose-invert max-w-none",
"prose-headings:mt-4 prose-headings:mb-2 prose-headings:font-semibold prose-headings:tracking-tight",
"prose-h1:text-xl prose-h2:text-lg prose-h3:text-base prose-h4:text-sm",
"prose-p:my-2 prose-ul:my-2 prose-ol:my-2 prose-li:my-0",
"prose-pre:my-3 prose-pre:rounded-lg prose-pre:bg-muted prose-pre:p-3",
"prose-code:rounded prose-code:bg-muted prose-code:px-1 prose-code:py-0.5 prose-code:text-[0.9em]",
"prose-code:before:content-none prose-code:after:content-none",
"prose-a:text-primary prose-a:underline prose-a:underline-offset-4",
"prose-blockquote:border-l-4 prose-blockquote:border-primary prose-blockquote:pl-4 prose-blockquote:italic",
"prose-table:my-3 prose-table:w-full prose-table:border-collapse prose-th:border prose-th:border-border prose-th:bg-muted prose-th:px-2 prose-th:py-1 prose-th:text-left prose-th:font-semibold",
"prose-td:border prose-td:border-border prose-td:px-2 prose-td:py-1 align-top",
"[&_div.table-wrap]:my-3 [&_div.table-wrap]:overflow-x-auto",
props.class
)}
innerHTML={html()}
/>
);
};
+29 -24
View File
@@ -1,5 +1,5 @@
import { type Component, For, Show } from "solid-js";
import { LayoutDashboard, ListTodo, Settings, Clock, ArrowUpCircle, PanelLeftClose, Snowflake, Pickaxe, ChevronDown, Gauge, TrendingUp, Users, Box, BarChart3, HelpCircle, Bot } 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";
@@ -42,7 +42,6 @@ const mobileNavGroups: MobileNavGroup[] = [
{ icon: LayoutDashboard, label: "Matrix", view: "matrix" },
]
},
{ icon: Bot, label: "AI Help", view: "help_ai" },
{
icon: Gauge, label: "Flow", items: [
{ icon: Snowflake, label: "Snowball", view: "snowball" },
@@ -50,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" },
];
@@ -306,6 +306,7 @@ export const Sidebar: Component<{
setView: (v: string) => void;
isAIHelpOpen: boolean;
onToggleAIHelp: () => void;
aiHelpTip?: string | null;
isLocked: boolean;
setIsLocked: (v: boolean) => void;
isPeeking: boolean;
@@ -356,7 +357,17 @@ 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 min-h-0">
<For each={desktopNavItems}>
@@ -381,40 +392,34 @@ export const Sidebar: Component<{
<div
class={cn(
"transition-all duration-300 ease-out overflow-hidden",
props.isAIHelpOpen ? "max-h-[34rem] opacity-100" : "max-h-0 opacity-0 pointer-events-none"
props.isAIHelpOpen ? "max-h-[44rem] opacity-100" : "max-h-0 opacity-0 pointer-events-none"
)}
>
<AIHelpPanel variant="sidebar" class="h-[32rem]" />
</div>
{/* Context Switcher - Only visible if there are oversight contexts */}
<div class={cn(
"transition-all duration-200",
(!props.isLocked && !props.isPeeking) ? "opacity-0 pointer-events-none" : "opacity-100 delay-100"
)}>
<ContextSwitcher
isLocked={props.isLocked}
isPeeking={props.isPeeking}
setIsPeeking={props.setIsPeeking}
onOpenChange={(open) => {
setSwitcherOpen(open);
props.onDropdownOpenChange?.(open);
}}
/>
<AIHelpPanel variant="sidebar" class="h-[42rem]" />
</div>
<div class="border-t border-border pt-3 space-y-1">
<button
onClick={props.onToggleAIHelp}
class={cn(
"flex items-center space-x-3 w-full px-3 py-2.5 rounded-lg transition-all duration-200 group text-sm",
"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"
)}
>
<Bot size={16} class={cn("transition-transform group-hover:scale-110")} />
<span class="font-medium">AI Help</span>
<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")}
+324
View File
@@ -0,0 +1,324 @@
import { FIREWORKS_API_KEY, FIREWORKS_API_URL, FIREWORKS_HELP_MODEL } from "@/lib/app-config";
import helpGuide from "../../docs/HELP_GUIDE.md?raw";
type ChatRequestMessage = {
role: "system" | "user" | "assistant";
content: string;
};
const HELP_SYSTEM_PROMPT = `You answer questions about TasGrid using the reference guide below.
You must follow this instruction exactly: "use only this guide for all information and never assume you know something that isn't mentioned in the guide"
If the guide does not contain the answer, say that the guide does not mention it.
Do not use outside product knowledge.
Do not invent buttons, workflows, settings, or behaviors.
Answer in the most direct and concise way possible initially.
After the main answer, end with a short follow-up line like "If you would like, I can explain the intricacies in more detail."
When helpful, mention the relevant section name from the guide.
TASGRID REFERENCE GUIDE
${helpGuide}`;
const buildSectionPrompt = (sectionTitle: string, sectionContent: string) => `You answer questions about TasGrid using only the guide section below.
You must follow this instruction exactly: "use only this guide for all information and never assume you know something that isn't mentioned in the guide"
If the section does not contain the answer, say that the guide section does not mention it.
Do not use outside product knowledge.
Do not invent buttons, workflows, settings, or behaviors.
Prefer concise, practical answers.
TASGRID GUIDE SECTION
## ${sectionTitle}
${sectionContent}`;
const normalizeLine = (value: string) =>
value
.trim()
.replace(/^tip:\s*/i, "")
.replace(/^["'\s]+|["'\s]+$/g, "")
.replace(/\s+/g, " ")
.toLowerCase();
const keepSingleSentence = (value: string) => {
const trimmed = value.trim();
const sentenceMatch = trimmed.match(/.+?[.!?](?=\s|$)/);
return (sentenceMatch?.[0] || trimmed).trim();
};
const isReasoningLikeType = (value: unknown) =>
typeof value === "string" && /reasoning|thought/i.test(value);
export const hasFireworksApiKey = () => FIREWORKS_API_KEY.trim().length > 0;
const extractGuideSections = (markdown: string) => {
const lines = markdown.split(/\r?\n/);
const sections: Array<{ title: string; content: string }> = [];
const excludedTopLevelTitles = new Set([
"Overview",
"Reference Notes",
"Troubleshooting Checklist"
]);
const excludedSubsectionTitles = new Set([
"Explicit Exclusions",
"Summary for Future Help-Agent Use",
"Help Access",
"Main App Modes",
"Account Header"
]);
let currentTopLevelTitle: string | null = null;
let currentSection: { title: string; lines: string[] } | null = null;
const pushCurrentSection = () => {
if (!currentSection) return;
const content = currentSection.lines.join("\n").trim();
if (!content) return;
if (excludedSubsectionTitles.has(currentSection.title)) return;
sections.push({
title: currentSection.title,
content
});
};
for (const line of lines) {
const topLevelHeadingMatch = line.match(/^##\s+(.+?)\s*$/);
if (topLevelHeadingMatch) {
pushCurrentSection();
currentTopLevelTitle = topLevelHeadingMatch[1].trim();
currentSection = null;
continue;
}
const subsectionHeadingMatch = line.match(/^###\s+(.+?)\s*$/);
if (subsectionHeadingMatch) {
pushCurrentSection();
if (!currentTopLevelTitle || excludedTopLevelTitles.has(currentTopLevelTitle)) {
currentSection = null;
continue;
}
currentSection = {
title: `${currentTopLevelTitle}: ${subsectionHeadingMatch[1].trim()}`,
lines: []
};
continue;
}
if (currentSection) {
currentSection.lines.push(line);
}
}
pushCurrentSection();
return sections;
};
const HELP_GUIDE_SECTIONS = extractGuideSections(helpGuide);
export const getAIHelpTipSectionDebug = (sectionIndex: number) => {
if (HELP_GUIDE_SECTIONS.length === 0) {
return { requestedIndex: sectionIndex, resolvedIndex: -1, title: null as string | null };
}
const resolvedIndex = ((sectionIndex % HELP_GUIDE_SECTIONS.length) + HELP_GUIDE_SECTIONS.length) % HELP_GUIDE_SECTIONS.length;
return {
requestedIndex: sectionIndex,
resolvedIndex,
title: HELP_GUIDE_SECTIONS[resolvedIndex]?.title || null
};
};
const cleanRawCompletion = (value: string) =>
value
.replace(/<think>[\s\S]*?<\/think>/gi, " ")
.replace(/<reasoning>[\s\S]*?<\/reasoning>/gi, " ")
.replace(/<\|[^>]+?\|>/g, " ")
.replace(/<\/?s>/gi, " ")
.replace(/\s+/g, " ")
.trim();
const extractTextFromContent = (content: unknown): string => {
if (typeof content === "string") {
return content.trim();
}
if (Array.isArray(content)) {
return content
.map(part => {
if (typeof part === "string") return part;
if (part && typeof part === "object") {
if (isReasoningLikeType((part as any).type)) {
return "";
}
if ("text" in part && typeof (part as any).text === "string") {
return (part as any).text;
}
if ("content" in part && typeof (part as any).content === "string") {
return (part as any).content;
}
}
return "";
})
.filter(Boolean)
.join("\n")
.trim();
}
if (content && typeof content === "object") {
if (isReasoningLikeType((content as any).type)) {
return "";
}
if ("text" in content && typeof (content as any).text === "string") {
return (content as any).text.trim();
}
if ("content" in content) {
return extractTextFromContent((content as any).content);
}
}
return "";
};
const extractTextFromOutputArray = (output: unknown): string => {
if (!Array.isArray(output)) return "";
return output
.map(item => {
if (!item || typeof item !== "object") return "";
if ("content" in item) {
return extractTextFromContent((item as any).content);
}
if ("text" in item && typeof (item as any).text === "string") {
return (item as any).text;
}
return "";
})
.filter(Boolean)
.join("\n")
.trim();
};
const requestFireworks = async (messages: ChatRequestMessage[], options?: { maxTokens?: number; temperature?: number; reasoningEffort?: "none" | "low" | "medium" | "high" }) => {
const response = await fetch(FIREWORKS_API_URL, {
method: "POST",
headers: {
"Content-Type": "application/json",
"Authorization": `Bearer ${FIREWORKS_API_KEY}`
},
body: JSON.stringify({
model: FIREWORKS_HELP_MODEL,
reasoning_effort: options?.reasoningEffort ?? "low",
temperature: options?.temperature ?? 0.2,
max_tokens: options?.maxTokens ?? 700,
raw_output: true,
messages
})
});
if (!response.ok) {
const bodyText = await response.text();
throw new Error(bodyText || `Request failed with status ${response.status}`);
}
const data = await response.json();
const choice = data?.choices?.[0];
const content = extractTextFromContent(choice?.message?.content)
|| extractTextFromContent(choice?.delta?.content)
|| extractTextFromContent(choice?.text)
|| extractTextFromContent(data?.output_text);
const rawCompletion = cleanRawCompletion(
extractTextFromContent(choice?.raw_output?.completion)
|| extractTextFromContent(data?.raw_output?.completion)
);
const outputArrayText = extractTextFromOutputArray(data?.output);
const answer = content || outputArrayText || rawCompletion;
if (!answer) {
const finishReason = choice?.finish_reason ? ` finish_reason=${String(choice.finish_reason)}` : "";
throw new Error(`The help assistant did not return any text.${finishReason}`);
}
return answer;
};
const looksLikePromptEcho = (value: string) => {
const normalized = normalizeLine(value);
return [
"we need to produce",
"create one short tasgrid desktop tip",
"maximum one sentence",
"return plain text only",
"do not repeat or closely paraphrase",
"previous tips to avoid",
"use only this guide",
"the guide does not mention",
"guide section does not mention",
"section does not mention",
"specific desktop tip",
"tasgrid desktop tip",
"based only on the guide"
].some(fragment => normalized.includes(fragment));
};
export const askAIHelp = async (messages: Array<{ role: "user" | "assistant"; content: string }>) =>
requestFireworks(
[
{ role: "system", content: HELP_SYSTEM_PROMPT },
...messages
],
{ maxTokens: 700, temperature: 0.2, reasoningEffort: "low" }
);
export const generateAIHelpTip = async (sectionIndex: number) => {
const sectionDebug = getAIHelpTipSectionDebug(sectionIndex);
const section = sectionDebug.resolvedIndex >= 0
? HELP_GUIDE_SECTIONS[sectionDebug.resolvedIndex]
: null;
const sectionLabel = section?.title || "TasGrid";
const tipPrompt = `Write one short practical TasGrid tip for a person already using the app.
Rules:
- Maximum one sentence.
- Return plain text only.
- No title, no bullet, no quotes.
- Keep it subtle and practical.
- State a real TasGrid behavior or workflow the user can use.
- Do not talk about the guide, the section, the prompt, or what is not mentioned.
- Do not explain your limitations.
- Use only this guide for all information and never assume you know something that isn't mentioned in the guide.
- Start the sentence with "Tip: ".
- Base the tip on this topic: ${sectionLabel}.
`;
for (let attempt = 0; attempt < 2; attempt += 1) {
const response = await requestFireworks(
[
{
role: "system",
content: section
? buildSectionPrompt(section.title, section.content)
: HELP_SYSTEM_PROMPT
},
{ role: "user", content: tipPrompt }
],
{ maxTokens: 192, temperature: 0.5, reasoningEffort: "low" }
);
const firstLine = response.split("\n")[0]?.trim() || "";
const cleaned = keepSingleSentence(
firstLine
.replace(/^[\-\*\u2022\d.\s]+/, "")
.trim()
);
const normalized = normalizeLine(cleaned);
if (!cleaned || !normalized) continue;
if (looksLikePromptEcho(cleaned)) continue;
return cleaned.startsWith("Tip:") ? cleaned : `Tip: ${cleaned.replace(/^tip:\s*/i, "")}`;
}
return null;
};
+7
View File
@@ -28,6 +28,7 @@ export const syncPreferences = async () => {
collapsedUntilUpdatedTasks: store.collapsedUntilUpdatedTasks,
subscribedBuckets: store.subscribedBuckets,
supervisorUserIds: store.personalContextSupervisorIds,
aiHelpTipSectionIndex: store.aiHelpTipSectionIndex,
lastCompletedTaskCleanupDate: store.lastCompletedTaskCleanupDate
};
@@ -174,3 +175,9 @@ export const removeNoteFilterTemplate = async (id: string) => {
export const clearNoteFilter = () => {
setStore("noteFilter", createDefaultFilter());
};
export const advanceAIHelpTipSectionIndex = async () => {
setStore("aiHelpTipSectionIndex", prev => prev + 1);
await syncPreferences();
};
+4
View File
@@ -183,6 +183,7 @@ export interface TaskStore {
buckets: Bucket[];
subscribedBuckets: string[];
personalContextSupervisorIds: string[];
aiHelpTipSectionIndex: number;
notes: Note[];
isNotepadMode: boolean;
quickloadTasks: string[];
@@ -202,6 +203,7 @@ export interface ScopedTaskgridPrefs {
noteFilter?: Filter;
subscribedBuckets?: string[];
supervisorUserIds?: string[];
aiHelpTipSectionIndex?: number;
dismissedTaskUpdateIndicators?: Record<string, string>;
collapsedUntilUpdatedTasks?: Record<string, string>;
migratedStructuredTagsV2?: boolean;
@@ -246,6 +248,7 @@ export const [store, setStore] = createStore<TaskStore>({
buckets: [],
subscribedBuckets: [],
personalContextSupervisorIds: [],
aiHelpTipSectionIndex: 0,
notes: [],
isNotepadMode: false,
quickloadTasks: [],
@@ -295,6 +298,7 @@ export const applyScopedPrefsToStore = (prefs: ScopedTaskgridPrefs) => {
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 || [],
+14 -2
View File
@@ -68,8 +68,20 @@ export const HelpView: Component<HelpViewProps> = (props) => {
if (!id) return;
document.getElementById(`help-${id}`)?.scrollIntoView({ behavior, block: "start" });
};
const getSectionFromHash = () => window.location.hash.startsWith("#help/") ? decodeURIComponent(window.location.hash.slice("#help/".length)) : undefined;
const jumpTo = (id: string) => { if (window.location.hash !== `#help/${id}`) window.location.hash = `#help/${id}`; scrollToSection(id); };
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);
};
onMount(() => {
const syncToHash = () => window.setTimeout(() => scrollToSection(getSectionFromHash(), "auto"), 50);
+384 -61
View File
@@ -105,6 +105,80 @@
h1 {
margin-top: 0;
}
.sdk-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
gap: 14px;
margin-bottom: 10px;
}
.sdk-card {
border: 1px solid #e5e7eb;
border-radius: 10px;
padding: 14px;
background: #fafafa;
}
.sdk-card h4 {
margin: 0 0 10px;
font-size: 14px;
}
.sdk-card p {
margin: 0 0 10px;
font-size: 12px;
color: #666;
line-height: 1.5;
}
.sdk-stack {
display: grid;
gap: 8px;
}
.sdk-stack input,
.sdk-stack textarea,
.sdk-stack select {
width: 100%;
padding: 8px 10px;
border: 1px solid #d1d5db;
border-radius: 6px;
font: inherit;
box-sizing: border-box;
background: white;
}
.sdk-stack textarea {
min-height: 74px;
resize: vertical;
}
.sdk-inline {
display: flex;
gap: 8px;
align-items: center;
flex-wrap: wrap;
}
.sdk-inline label {
font-size: 12px;
color: #444;
}
.sdk-meta {
margin-top: 10px;
font-size: 12px;
color: #555;
line-height: 1.5;
}
.sdk-actions {
display: flex;
gap: 8px;
flex-wrap: wrap;
margin-top: 8px;
}
</style>
<script src="https://cdn.jsdelivr.net/npm/pocketbase/dist/pocketbase.umd.js"></script>
</head>
@@ -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:&#10;#sdk-workspace=collaborative&#10;@shared bucket=handoff"></textarea>
<div class="sdk-inline">
<input type="number" id="sdk-task-priority" min="1" max="10" value="8"
style="max-width: 90px;" title="Priority">
<input type="number" id="sdk-task-urgency" min="1" max="10" value="4"
style="max-width: 90px;" title="Urgency">
<input type="number" id="sdk-task-size" min="0" max="10" value="3"
style="max-width: 90px;" title="Size">
</div>
<textarea id="sdk-task-content"
placeholder="Task content HTML or plain text">This task was created by a sister app using the current TasGrid SDK flow.</textarea>
</div>
<div class="sdk-actions">
<button onclick="createTaskViaSDK()"
style="padding: 8px 16px; cursor: pointer; background: #6f42c1; color: white; border: none; border-radius: 4px; font-weight: bold;">Create
Task via SDK</button>
</div>
<div class="sdk-meta" id="sdk-task-meta">Last created task: none</div>
</div>
<div 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">&lt;p&gt;This note was created by the updated TasGrid SDK.&lt;/p&gt;</textarea>
<input type="text" id="sdk-note-task-ids"
placeholder="Task IDs CSV to fully link into this note workspace">
<div class="sdk-inline">
<label>Share mode
<select id="sdk-note-share-mode">
<option value="collaborative" selected>collaborative</option>
<option value="handoff">handoff</option>
</select>
</label>
<label>Visibility
<select id="sdk-note-private">
<option value="false" selected>public</option>
<option value="true">private</option>
</select>
</label>
<label style="display: flex; align-items: center; gap: 6px;">
<input type="checkbox" id="sdk-note-link-tasks" checked>
Link tasks via SDK
</label>
</div>
</div>
<div class="sdk-actions">
<button onclick="createNoteViaSDK()"
style="padding: 8px 16px; cursor: pointer; background: #fd7e14; color: white; border: none; border-radius: 4px; font-weight: bold;">Create
Note via SDK</button>
<button onclick="createLinkedWorkspaceScenario()"
style="padding: 8px 16px; cursor: pointer; background: #0f766e; color: white; border: none; border-radius: 4px; font-weight: bold;">Create
Task + Linked Note</button>
</div>
<div class="sdk-meta" id="sdk-note-meta">Last created note: none</div>
</div>
<div class="sdk-card">
<h4>Link Existing Tasks</h4>
<p>Calls the SDK's direct note-link helper so you can test current workspace linking without creating a new note.</p>
<div class="sdk-stack">
<input type="text" id="sdk-link-note-id" placeholder="Target note ID">
<input type="text" id="sdk-link-task-ids" placeholder="Task IDs CSV to link">
</div>
<div class="sdk-actions">
<button onclick="linkExistingTasksViaSDK()"
style="padding: 8px 16px; cursor: pointer; background: #2563eb; color: white; border: none; border-radius: 4px; font-weight: bold;">Link
Tasks to Note</button>
<button onclick="loadLastCreatedNote()" style="padding: 8px 16px; cursor: pointer;">Load Last
Note Embed</button>
</div>
<div class="sdk-meta" id="sdk-link-meta">Ready to link existing records.</div>
</div>
</div>
@@ -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: "&lt;p&gt;HTML content here...&lt;/p&gt;",
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>