897 lines
43 KiB
TypeScript
897 lines
43 KiB
TypeScript
import { create } from 'zustand'
|
|
import { persist } from 'zustand/middleware'
|
|
|
|
export type TaskType = 'idea' | 'task' | 'bug' | 'research' | 'plan'
|
|
export type TaskStatus = 'open' | 'todo' | 'blocked' | 'in-progress' | 'review' | 'validate' | 'archived' | 'canceled' | 'done'
|
|
export type Priority = 'low' | 'medium' | 'high' | 'urgent'
|
|
export type SprintStatus = 'planning' | 'active' | 'completed'
|
|
|
|
export interface Sprint {
|
|
id: string
|
|
name: string
|
|
goal?: string
|
|
startDate: string
|
|
endDate: string
|
|
status: SprintStatus
|
|
createdAt: string
|
|
}
|
|
|
|
export interface Comment {
|
|
id: string
|
|
text: string
|
|
createdAt: string
|
|
commentAuthorId: string
|
|
replies?: Comment[]
|
|
}
|
|
|
|
export interface UserProfile {
|
|
id: string
|
|
name: string
|
|
email?: string
|
|
avatarUrl?: string
|
|
}
|
|
|
|
export interface TaskAttachment {
|
|
id: string
|
|
name: string
|
|
type: string
|
|
size: number
|
|
dataUrl: string
|
|
uploadedAt: string
|
|
}
|
|
|
|
export interface Task {
|
|
id: string
|
|
title: string
|
|
description?: string
|
|
type: TaskType
|
|
status: TaskStatus
|
|
priority: Priority
|
|
projectId: string
|
|
sprintId?: string
|
|
createdAt: string
|
|
updatedAt: string
|
|
createdById?: string
|
|
createdByName?: string
|
|
createdByAvatarUrl?: string
|
|
updatedById?: string
|
|
updatedByName?: string
|
|
updatedByAvatarUrl?: string
|
|
assigneeId?: string
|
|
assigneeName?: string
|
|
assigneeEmail?: string
|
|
assigneeAvatarUrl?: string
|
|
dueDate?: string
|
|
comments: Comment[]
|
|
tags: string[]
|
|
attachments?: TaskAttachment[]
|
|
}
|
|
|
|
export interface Project {
|
|
id: string
|
|
name: string
|
|
description?: string
|
|
color: string
|
|
createdAt: string
|
|
}
|
|
|
|
interface TaskStore {
|
|
projects: Project[]
|
|
tasks: Task[]
|
|
sprints: Sprint[]
|
|
selectedProjectId: string | null
|
|
selectedTaskId: string | null
|
|
selectedSprintId: string | null
|
|
currentUser: UserProfile
|
|
isLoading: boolean
|
|
lastSynced: number | null
|
|
|
|
// Sync actions
|
|
syncFromServer: () => Promise<void>
|
|
syncToServer: () => Promise<void>
|
|
setCurrentUser: (user: Partial<UserProfile>) => void
|
|
|
|
// Project actions
|
|
addProject: (name: string, description?: string) => void
|
|
updateProject: (id: string, updates: Partial<Project>) => void
|
|
deleteProject: (id: string) => void
|
|
selectProject: (id: string | null) => void
|
|
|
|
// Task actions
|
|
addTask: (task: Omit<Task, 'id' | 'createdAt' | 'updatedAt' | 'comments'>) => void
|
|
updateTask: (id: string, updates: Partial<Task>) => void
|
|
deleteTask: (id: string) => void
|
|
selectTask: (id: string | null) => void
|
|
|
|
// Sprint actions
|
|
addSprint: (sprint: Omit<Sprint, 'id' | 'createdAt'>) => void
|
|
updateSprint: (id: string, updates: Partial<Sprint>) => void
|
|
deleteSprint: (id: string) => void
|
|
selectSprint: (id: string | null) => void
|
|
getTasksBySprint: (sprintId: string) => Task[]
|
|
|
|
// Comment actions
|
|
addComment: (taskId: string, text: string, commentAuthorId: string) => void
|
|
deleteComment: (taskId: string, commentId: string) => void
|
|
|
|
// Filters
|
|
getTasksByProject: (projectId: string) => Task[]
|
|
getTasksByStatus: (status: TaskStatus) => Task[]
|
|
getTaskById: (id: string) => Task | undefined
|
|
}
|
|
|
|
// Sprint 1: Mon Feb 16 - Sun Feb 22, 2026 (current week)
|
|
const sprint1Start = new Date('2026-02-16T00:00:00.000Z')
|
|
const sprint1End = new Date('2026-02-22T23:59:59.999Z')
|
|
|
|
const defaultSprints: Sprint[] = [
|
|
{
|
|
id: 'sprint-1',
|
|
name: 'Sprint 1',
|
|
goal: 'Foundation and core features',
|
|
startDate: sprint1Start.toISOString(),
|
|
endDate: sprint1End.toISOString(),
|
|
status: 'active',
|
|
createdAt: new Date().toISOString(),
|
|
},
|
|
]
|
|
|
|
const defaultProjects: Project[] = [
|
|
{ id: '1', name: 'OpenClaw iOS', description: 'Main iOS app development', color: '#8b5cf6', createdAt: new Date().toISOString() },
|
|
{ id: '2', name: 'Web Projects', description: 'Web tools and dashboards', color: '#3b82f6', createdAt: new Date().toISOString() },
|
|
{ id: '3', name: 'Research', description: 'Experiments and learning', color: '#10b981', createdAt: new Date().toISOString() },
|
|
]
|
|
|
|
const defaultTasks: Task[] = [
|
|
{
|
|
id: '1',
|
|
title: 'Redesign Gantt Board',
|
|
description: 'Make it actually work with proper notes system',
|
|
type: 'task',
|
|
status: 'in-progress',
|
|
priority: 'high',
|
|
projectId: '2',
|
|
sprintId: 'sprint-1',
|
|
createdAt: new Date().toISOString(),
|
|
updatedAt: new Date().toISOString(),
|
|
comments: [
|
|
{ id: 'c1', text: 'Need 1-to-many notes, not one big text field', createdAt: new Date().toISOString(), commentAuthorId: 'user' },
|
|
{ id: 'c2', text: 'Agreed - will rebuild with proper comment threads', createdAt: new Date().toISOString(), commentAuthorId: 'assistant' },
|
|
],
|
|
tags: ['ui', 'rewrite']
|
|
},
|
|
{
|
|
id: '2',
|
|
title: 'MoodWeave App Idea',
|
|
description: 'Social mood tracking with woven visualizations',
|
|
type: 'idea',
|
|
status: 'open',
|
|
priority: 'medium',
|
|
projectId: '1',
|
|
sprintId: 'sprint-1',
|
|
createdAt: new Date().toISOString(),
|
|
updatedAt: new Date().toISOString(),
|
|
comments: [],
|
|
tags: ['ios', 'social']
|
|
},
|
|
{
|
|
id: '3',
|
|
title: 'Set up Gitea integration for code pushes',
|
|
description: 'Create bot account on Gitea (192.168.1.128:3000) and configure git remotes for all OpenClaw projects. Decide on account name, permissions, and auth method (SSH vs token). User prefers dedicated bot account over using their personal account for audit trail.',
|
|
type: 'task',
|
|
status: 'done',
|
|
priority: 'medium',
|
|
projectId: '2',
|
|
createdAt: new Date().toISOString(),
|
|
updatedAt: new Date().toISOString(),
|
|
comments: [
|
|
{ id: 'c3', text: 'User has local Gitea at http://192.168.1.128:3000', createdAt: new Date().toISOString(), commentAuthorId: 'assistant' },
|
|
{ id: 'c4', text: 'Options: 1) Create dedicated bot account (recommended), 2) Use existing account', createdAt: new Date().toISOString(), commentAuthorId: 'assistant' },
|
|
{ id: 'c5', text: 'Account created: mbruce@topdoglabs.com / !7883Gitea (username: ai-agent)', createdAt: new Date().toISOString(), commentAuthorId: 'user' },
|
|
{ id: 'c6', text: 'Git configured for all 3 projects. Gitea remotes added.', createdAt: new Date().toISOString(), commentAuthorId: 'assistant' },
|
|
{ id: 'c7', text: '✅ All 3 repos created and pushed to Gitea: gantt-board, blog-backup, heartbeat-monitor', createdAt: new Date().toISOString(), commentAuthorId: 'assistant' }
|
|
],
|
|
tags: ['gitea', 'git', 'automation', 'infrastructure']
|
|
},
|
|
{
|
|
id: '4',
|
|
title: 'Redesign Heartbeat Monitor to match UptimeRobot',
|
|
description: 'Completely redesign the Heartbeat Monitor website to be a competitor to https://uptimerobot.com. Study their design, layout, color scheme, typography, and functionality. Match their look, feel, and style as closely as possible. Include: modern dashboard, status pages, uptime charts, incident history, public status pages.',
|
|
type: 'task',
|
|
status: 'done',
|
|
priority: 'high',
|
|
projectId: '2',
|
|
sprintId: 'sprint-1',
|
|
createdAt: new Date().toISOString(),
|
|
updatedAt: new Date().toISOString(),
|
|
comments: [
|
|
{ id: 'c8', text: 'Reference: https://uptimerobot.com - study their homepage, dashboard, and status page designs', createdAt: new Date().toISOString(), commentAuthorId: 'user' },
|
|
{ id: 'c9', text: 'Focus on: clean modern UI, blue/green color scheme, card-based layouts, uptime percentage displays, incident timelines', createdAt: new Date().toISOString(), commentAuthorId: 'user' },
|
|
{ id: 'c29', text: 'COMPLETED: Full rebuild with Next.js + shadcn/ui + Framer Motion. Dark OLED theme, glass-morphism cards, animated status indicators, sparkline visualizations, grid/list views, tooltips, progress bars. Production-grade at http://localhost:3005', createdAt: new Date().toISOString(), commentAuthorId: 'assistant' }
|
|
],
|
|
tags: ['ui', 'ux', 'redesign', 'dashboard', 'monitoring']
|
|
},
|
|
{
|
|
id: '8',
|
|
title: 'Fix Kanban board - dynamic sync without hard refresh',
|
|
description: 'Current board uses localStorage persistence which requires hard refresh (Cmd+Shift+R) to see task updates from code changes. Need to add: server-side storage (API + database/file), or sync mechanism that checks for updates on regular refresh, or real-time updates via WebSocket/polling. User should see updates on normal page refresh without clearing cache.',
|
|
type: 'task',
|
|
status: 'in-progress',
|
|
priority: 'medium',
|
|
projectId: '2',
|
|
sprintId: 'sprint-1',
|
|
createdAt: new Date().toISOString(),
|
|
updatedAt: new Date().toISOString(),
|
|
comments: [
|
|
{ id: 'c21', text: 'Issue: Task status changes require hard refresh to see', createdAt: new Date().toISOString(), commentAuthorId: 'user' },
|
|
{ id: 'c22', text: 'Current: localStorage persistence via Zustand', createdAt: new Date().toISOString(), commentAuthorId: 'user' },
|
|
{ id: 'c23', text: 'Need: Server-side storage or sync on regular refresh', createdAt: new Date().toISOString(), commentAuthorId: 'assistant' },
|
|
{ id: 'c44', text: 'COMPLETED: Added /api/tasks endpoint with file-based storage', createdAt: new Date().toISOString(), commentAuthorId: 'assistant' },
|
|
{ id: 'c45', text: 'COMPLETED: Store now syncs from server on load and auto-syncs changes', createdAt: new Date().toISOString(), commentAuthorId: 'assistant' },
|
|
{ id: 'c46', text: 'COMPLETED: Falls back to localStorage if server unavailable', createdAt: new Date().toISOString(), commentAuthorId: 'assistant' }
|
|
],
|
|
tags: ['ui', 'sync', 'localstorage', 'real-time']
|
|
},
|
|
{
|
|
id: '5',
|
|
title: 'Fix Blog Backup links to be clickable',
|
|
description: 'Make links in the Daily Digest clickable in the blog backup UI. Currently links are just text that require copy-paste. Need to render markdown links properly so users can click directly. Consider different formatting for Telegram vs Blog - Telegram gets plain text summary with "Read more at [link]", Blog gets full formatted content with clickable links.',
|
|
type: 'task',
|
|
status: 'done',
|
|
priority: 'medium',
|
|
projectId: '2',
|
|
sprintId: 'sprint-1',
|
|
createdAt: new Date().toISOString(),
|
|
updatedAt: new Date().toISOString(),
|
|
comments: [
|
|
{ id: 'c10', text: 'Blog should show: [headline](url) as clickable markdown links', createdAt: new Date().toISOString(), commentAuthorId: 'user' },
|
|
{ id: 'c11', text: 'Telegram gets summary + "Full digest at: http://localhost:3003"', createdAt: new Date().toISOString(), commentAuthorId: 'user' },
|
|
{ id: 'c41', text: 'COMPLETED: Fixed parseDigest to extract URLs from markdown links [Title](url) in title lines', createdAt: new Date().toISOString(), commentAuthorId: 'assistant' },
|
|
{ id: 'c42', text: 'COMPLETED: Title is now the clickable link with external link icon on hover', createdAt: new Date().toISOString(), commentAuthorId: 'assistant' },
|
|
{ id: 'c43', text: 'COMPLETED: Better hover states - title turns blue, external link icon appears', createdAt: new Date().toISOString(), commentAuthorId: 'assistant' }
|
|
],
|
|
tags: ['blog', 'ui', 'markdown', 'links']
|
|
},
|
|
{
|
|
id: '6',
|
|
title: 'Fix monitoring schedule - 2 of 3 sites are down',
|
|
description: 'The cron job running every 10 minutes to check heartbeat website is failing. Currently 2 of 3 websites are down and not being auto-restarted. Debug and fix the monitoring schedule to ensure all 3 sites (gantt-board, blog-backup, heartbeat-monitor) are checked and auto-restarted properly.',
|
|
type: 'bug',
|
|
status: 'done',
|
|
priority: 'urgent',
|
|
projectId: '2',
|
|
sprintId: 'sprint-1',
|
|
createdAt: new Date().toISOString(),
|
|
updatedAt: new Date().toISOString(),
|
|
comments: [
|
|
{ id: 'c12', text: 'Issue: Cron job exists but sites are still going down without restart', createdAt: new Date().toISOString(), commentAuthorId: 'user' },
|
|
{ id: 'c13', text: 'Need to verify: cron is running, checks all 3 ports, restart logic works, permissions correct', createdAt: new Date().toISOString(), commentAuthorId: 'user' },
|
|
{ id: 'c14', text: 'ALL SITES BACK UP - manually restarted at 14:19. Now investigating why auto-restart failed.', createdAt: new Date().toISOString(), commentAuthorId: 'assistant' },
|
|
{ id: 'c15', text: 'Problem: Port 3005 was still in use (EADDRINUSE), need better process cleanup in restart logic', createdAt: new Date().toISOString(), commentAuthorId: 'assistant' },
|
|
{ id: 'c16', text: 'FIXED: Updated cron job with pkill cleanup before restart + 2s delay. Created backup script: monitor-restart.sh', createdAt: new Date().toISOString(), commentAuthorId: 'assistant' },
|
|
{ id: 'c17', text: 'All 3 sites stable. Cron job now properly kills old processes before restarting to avoid port conflicts.', createdAt: new Date().toISOString(), commentAuthorId: 'assistant' }
|
|
],
|
|
tags: ['monitoring', 'cron', 'bug', 'infrastructure', 'urgent']
|
|
},
|
|
{
|
|
id: '7',
|
|
title: 'Investigate root cause - why are websites dying?',
|
|
description: 'Currently monitoring only treats the symptom (restart when down). Need to investigate what is actually killing the Next.js dev servers. Check: system logs, memory usage, file watcher limits, zombie processes, macOS power management, SSH timeout, OOM killer. Set up logging to capture what happens right before crashes.',
|
|
type: 'research',
|
|
status: 'done',
|
|
priority: 'high',
|
|
projectId: '2',
|
|
sprintId: 'sprint-1',
|
|
createdAt: new Date().toISOString(),
|
|
updatedAt: new Date().toISOString(),
|
|
comments: [
|
|
{ id: 'c18', text: 'Problem: Sites go down randomly - what is killing them?', createdAt: new Date().toISOString(), commentAuthorId: 'user' },
|
|
{ id: 'c19', text: 'Suspects: Memory leaks, file watcher hitting limits, SSH session timeout, macOS power nap, OOM killer', createdAt: new Date().toISOString(), commentAuthorId: 'user' },
|
|
{ id: 'c20', text: 'Need to add logging/capture to see what kills processes', createdAt: new Date().toISOString(), commentAuthorId: 'user' },
|
|
{ id: 'c27', text: 'COMPLETED: Root cause analysis done. Primary suspect: Next.js dev server memory leaks. Secondary: SSH timeout, OOM killer, power mgmt. Full report: root-cause-analysis.md. Monitoring script deployed.', createdAt: new Date().toISOString(), commentAuthorId: 'assistant' }
|
|
],
|
|
tags: ['debugging', 'research', 'infrastructure', 'root-cause']
|
|
},
|
|
{
|
|
id: '9',
|
|
title: 'Add ability to edit task priority in Kanban board',
|
|
description: 'Currently users cannot change task priority (Low/Medium/High/Urgent) from the UI. Need to add priority editing capability to the task detail view or task card. Should be a dropdown selector allowing users to re-prioritize tasks on the fly without editing code.',
|
|
type: 'task',
|
|
status: 'done',
|
|
priority: 'high',
|
|
projectId: '2',
|
|
sprintId: 'sprint-1',
|
|
createdAt: new Date().toISOString(),
|
|
updatedAt: new Date().toISOString(),
|
|
comments: [
|
|
{ id: 'c24', text: 'User cannot currently change priority from Medium to High in UI', createdAt: new Date().toISOString(), commentAuthorId: 'user' },
|
|
{ id: 'c25', text: 'Need priority dropdown/editor in task detail view', createdAt: new Date().toISOString(), commentAuthorId: 'assistant' },
|
|
{ id: 'c28', text: 'COMPLETED: Added priority buttons to task detail dialog. Click any task to see Low/Medium/High/Urgent buttons with color coding. Changes apply immediately.', createdAt: new Date().toISOString(), commentAuthorId: 'assistant' }
|
|
],
|
|
tags: ['ui', 'kanban', 'feature', 'priority']
|
|
},
|
|
{
|
|
id: '10',
|
|
title: 'RESEARCH: Find viable screenshot solution for OpenClaw on macOS',
|
|
description: 'INVESTIGATION NEEDED: Find a reliable, persistent way for OpenClaw AI to capture screenshots of local websites running on macOS. Current browser tool requires Chrome extension which is not connected. Puppeteer workaround is temporary. Need to research and document ALL possible options including: macOS native screenshot tools (screencapture, automator), alternative browser automation tools, canvas/headless options, or any other method that works on macOS without requiring Chrome extension.',
|
|
type: 'research',
|
|
status: 'done',
|
|
priority: 'high',
|
|
projectId: '2',
|
|
sprintId: 'sprint-1',
|
|
createdAt: new Date().toISOString(),
|
|
updatedAt: new Date().toISOString(),
|
|
comments: [
|
|
{ id: 'c29', text: 'PROBLEM: User needs to share screenshots of local websites with friends who cannot access home network. Browser tool unavailable (Chrome extension not connected).', createdAt: new Date().toISOString(), commentAuthorId: 'user' },
|
|
{ id: 'c30', text: 'INVESTIGATE: macOS native screenshot capabilities - screencapture CLI, Automator workflows, AppleScript', createdAt: new Date().toISOString(), commentAuthorId: 'assistant' },
|
|
{ id: 'c31', text: 'INVESTIGATE: Alternative browser automation - Playwright, Selenium, WebDriver without Chrome extension requirement', createdAt: new Date().toISOString(), commentAuthorId: 'assistant' },
|
|
{ id: 'c32', text: 'INVESTIGATE: OpenClaw Gateway configuration - browser profiles, node setup, gateway settings', createdAt: new Date().toISOString(), commentAuthorId: 'assistant' },
|
|
{ id: 'c33', text: 'INVESTIGATE: Third-party screenshot APIs or services that could work locally', createdAt: new Date().toISOString(), commentAuthorId: 'assistant' },
|
|
{ id: 'c34', text: 'DELIVERABLE: Document ALL options found with pros/cons, setup requirements, and recommendation for best solution', createdAt: new Date().toISOString(), commentAuthorId: 'user' },
|
|
{ id: 'c35', text: 'FINDING: /usr/sbin/screencapture exists but requires interactive mode or captures full screen - cannot target specific URL', createdAt: new Date().toISOString(), commentAuthorId: 'assistant' },
|
|
{ id: 'c36', text: 'FINDING: Google Chrome is installed at /Applications/Google Chrome.app - Playwright can use this for headless screenshots', createdAt: new Date().toISOString(), commentAuthorId: 'assistant' },
|
|
{ id: 'c37', text: 'FINDING: Playwright v1.58.2 is already available via npx - can automate Chrome without extension', createdAt: new Date().toISOString(), commentAuthorId: 'assistant' },
|
|
{ id: 'c38', text: 'FINDING: OpenClaw Gateway is running on port 18789 with browser profile "chrome" but extension not attached to any tab', createdAt: new Date().toISOString(), commentAuthorId: 'assistant' },
|
|
{ id: 'c39', text: 'SUCCESS: Playwright + Google Chrome works! Successfully captured screenshot of http://localhost:3005 using headless Chrome', createdAt: new Date().toISOString(), commentAuthorId: 'assistant' },
|
|
{ id: 'c40', text: 'RECOMMENDATION: Install Playwright globally or in workspace so screenshots work without temporary installs. Command: npm install -g playwright', createdAt: new Date().toISOString(), commentAuthorId: 'assistant' },
|
|
{ id: 'c47', text: 'COMPLETED: Playwright installed globally. Screenshots now work anytime without setup.', createdAt: new Date().toISOString(), commentAuthorId: 'assistant' }
|
|
],
|
|
tags: ['research', 'screenshot', 'macos', 'openclaw', 'investigation']
|
|
},
|
|
{
|
|
id: '11',
|
|
title: 'RESEARCH: Find iOS side projects with MRR potential',
|
|
description: 'Research and identify iOS app ideas that have strong Monthly Recurring Revenue (MRR) opportunities. Focus on apps that are well-designed, multi-screen experiences (not single-screen utilities), and have viral potential. Look at current App Store trends, successful indie apps, and underserved niches. Consider: subscription models, freemium tiers, in-app purchases. Target ideas that leverage iOS-specific features (widgets, Live Activities, Siri, CoreML, etc.) and could generate $1K-$10K+ MRR.',
|
|
type: 'research',
|
|
status: 'done',
|
|
priority: 'low',
|
|
projectId: '3',
|
|
sprintId: 'sprint-1',
|
|
createdAt: new Date().toISOString(),
|
|
updatedAt: new Date().toISOString(),
|
|
comments: [
|
|
{ id: 'c48', text: 'Focus: iOS apps with MRR potential (subscriptions, recurring revenue)', createdAt: new Date().toISOString(), commentAuthorId: 'user' },
|
|
{ id: 'c49', text: 'Requirements: Well-thought-out, multi-screen, viral potential', createdAt: new Date().toISOString(), commentAuthorId: 'user' },
|
|
{ id: 'c50', text: 'Target: $1K-$10K+ MRR opportunities', createdAt: new Date().toISOString(), commentAuthorId: 'user' },
|
|
{ id: 'c51', text: 'Research areas: App Store trends, indie success stories, underserved niches', createdAt: new Date().toISOString(), commentAuthorId: 'assistant' },
|
|
{ id: 'c52', text: 'COMPLETED: Full research report saved to memory/ios-mrr-opportunities.md', createdAt: new Date().toISOString(), commentAuthorId: 'assistant' },
|
|
{ id: 'c53', text: 'TOP 10 IDEAS: (1) AI Translator Keyboard - $15K/mo potential, (2) Finance Widget Suite, (3) Focus Timer with Live Activities - RECOMMENDED, (4) AI Photo Enhancer, (5) Habit Tracker with Social, (6) Local Business Review Widget, (7) Audio Journal with Voice-to-Text, (8) Plant Care Tracker, (9) Sleep Sounds with HomeKit, (10) Family Password Manager', createdAt: new Date().toISOString(), commentAuthorId: 'assistant' },
|
|
{ id: 'c54', text: 'RECOMMENDATION: Focus Timer with Live Activities (#3) - Best first project. Technically achievable, proven market, high viral potential through focus streak sharing, leverages iOS 16+ features.', createdAt: new Date().toISOString(), commentAuthorId: 'assistant' }
|
|
],
|
|
tags: ['ios', 'mrr', 'research', 'side-project', 'entrepreneurship', 'app-ideas']
|
|
},
|
|
{
|
|
id: '12',
|
|
title: 'Add markdown rendering to Blog Backup',
|
|
description: 'The blog backup page currently shows raw markdown text instead of rendered HTML. This means links appear as [text](url) instead of clickable links. Need to install a markdown renderer (like react-markdown) and update the page component to properly render markdown content as HTML with clickable links, formatted headers, lists, etc.',
|
|
type: 'task',
|
|
status: 'done',
|
|
priority: 'high',
|
|
projectId: '2',
|
|
sprintId: 'sprint-1',
|
|
createdAt: new Date().toISOString(),
|
|
updatedAt: new Date().toISOString(),
|
|
comments: [
|
|
{ id: 'c55', text: 'Issue: Blog shows raw markdown [text](url) instead of clickable links', createdAt: new Date().toISOString(), commentAuthorId: 'user' },
|
|
{ id: 'c56', text: 'Solution: Install react-markdown and render content as HTML', createdAt: new Date().toISOString(), commentAuthorId: 'assistant' },
|
|
{ id: 'c57', text: 'Expected: Properly formatted markdown with clickable links, headers, lists', createdAt: new Date().toISOString(), commentAuthorId: 'user' },
|
|
{ id: 'c58', text: 'COMPLETED: Installed react-markdown and remark-gfm', createdAt: new Date().toISOString(), commentAuthorId: 'assistant' },
|
|
{ id: 'c59', text: 'COMPLETED: Installed @tailwindcss/typography for prose styling', createdAt: new Date().toISOString(), commentAuthorId: 'assistant' },
|
|
{ id: 'c60', text: 'COMPLETED: Updated page.tsx to render markdown as HTML with clickable links', createdAt: new Date().toISOString(), commentAuthorId: 'assistant' },
|
|
{ id: 'c61', text: 'COMPLETED: Links now open in new tab with blue styling and hover effects', createdAt: new Date().toISOString(), commentAuthorId: 'assistant' }
|
|
],
|
|
tags: ['blog', 'ui', 'markdown', 'frontend']
|
|
},
|
|
{
|
|
id: '13',
|
|
title: 'Research TTS options for Daily Digest podcast',
|
|
description: 'Research free text-to-speech (TTS) tools to convert the daily digest blog posts into an audio podcast format. Matt wants to listen to the digest during his morning dog walks with Tully and Remy. Look into: free TTS APIs (ElevenLabs free tier, Google TTS, AWS Polly), open-source solutions (Piper, Coqui TTS), browser-based options, RSS feed generation for podcast apps, and file hosting options. The solution should be cost-effective or free since budget is a concern.',
|
|
type: 'research',
|
|
status: 'open',
|
|
priority: 'medium',
|
|
projectId: '2',
|
|
sprintId: 'sprint-1',
|
|
createdAt: new Date().toISOString(),
|
|
updatedAt: new Date().toISOString(),
|
|
comments: [
|
|
{ id: 'c62', text: 'Goal: Convert daily digest text to audio for dog walks', createdAt: new Date().toISOString(), commentAuthorId: 'user' },
|
|
{ id: 'c63', text: 'Requirement: Free or very low cost solution', createdAt: new Date().toISOString(), commentAuthorId: 'user' },
|
|
{ id: 'c64', text: 'Look into: ElevenLabs free tier, Google TTS, AWS Polly, Piper, Coqui TTS', createdAt: new Date().toISOString(), commentAuthorId: 'assistant' },
|
|
{ id: 'c65', text: 'Also research: RSS feed generation, podcast hosting options', createdAt: new Date().toISOString(), commentAuthorId: 'assistant' }
|
|
],
|
|
tags: ['research', 'tts', 'podcast', 'audio', 'digest', 'accessibility']
|
|
}
|
|
]
|
|
|
|
const createLocalUserProfile = (): UserProfile => ({
|
|
id: `user-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
|
|
name: 'Local User',
|
|
})
|
|
|
|
const defaultCurrentUser = createLocalUserProfile()
|
|
|
|
const normalizeUserProfile = (value: unknown, fallback: UserProfile = defaultCurrentUser): UserProfile => {
|
|
if (!value || typeof value !== 'object') return fallback
|
|
const candidate = value as Partial<UserProfile>
|
|
const id = typeof candidate.id === 'string' && candidate.id.trim().length > 0 ? candidate.id : fallback.id
|
|
const name = typeof candidate.name === 'string' && candidate.name.trim().length > 0 ? candidate.name.trim() : fallback.name
|
|
const email = typeof candidate.email === 'string' && candidate.email.trim().length > 0 ? candidate.email.trim() : undefined
|
|
const avatarUrl = typeof candidate.avatarUrl === 'string' && candidate.avatarUrl.trim().length > 0 ? candidate.avatarUrl : undefined
|
|
return { id, name, email, avatarUrl }
|
|
}
|
|
|
|
const profileToCommentAuthor = (profile: UserProfile) => ({
|
|
id: profile.id,
|
|
name: profile.name,
|
|
email: profile.email,
|
|
avatarUrl: profile.avatarUrl,
|
|
})
|
|
|
|
const normalizeCommentAuthorId = (value: unknown): string | null =>
|
|
typeof value === 'string' && value.trim().length > 0 ? value.trim() : null
|
|
|
|
const normalizeComments = (value: unknown): Comment[] => {
|
|
if (!Array.isArray(value)) return []
|
|
|
|
const comments: Comment[] = []
|
|
|
|
for (const entry of value) {
|
|
if (!entry || typeof entry !== 'object') continue
|
|
const candidate = entry as Partial<Comment>
|
|
if (typeof candidate.id !== 'string' || typeof candidate.text !== 'string' || typeof candidate.createdAt !== 'string') continue
|
|
const commentAuthorId = normalizeCommentAuthorId(candidate.commentAuthorId)
|
|
if (!commentAuthorId) continue
|
|
|
|
comments.push({
|
|
id: candidate.id,
|
|
text: candidate.text,
|
|
createdAt: candidate.createdAt,
|
|
commentAuthorId,
|
|
replies: normalizeComments(candidate.replies),
|
|
})
|
|
}
|
|
|
|
return comments
|
|
}
|
|
|
|
const normalizeAttachments = (value: unknown): TaskAttachment[] => {
|
|
if (!Array.isArray(value)) return []
|
|
|
|
return value
|
|
.map((entry) => {
|
|
if (!entry || typeof entry !== 'object') return null
|
|
const candidate = entry as Partial<TaskAttachment>
|
|
if (typeof candidate.id !== 'string' || typeof candidate.name !== 'string' || typeof candidate.dataUrl !== 'string') {
|
|
return null
|
|
}
|
|
|
|
return {
|
|
id: candidate.id,
|
|
name: candidate.name,
|
|
type: typeof candidate.type === 'string' ? candidate.type : 'application/octet-stream',
|
|
size: typeof candidate.size === 'number' ? candidate.size : 0,
|
|
dataUrl: candidate.dataUrl,
|
|
uploadedAt: typeof candidate.uploadedAt === 'string' ? candidate.uploadedAt : new Date().toISOString(),
|
|
}
|
|
})
|
|
.filter((attachment): attachment is TaskAttachment => attachment !== null)
|
|
}
|
|
|
|
const UNSPRINTED_SENTINELS = new Set(['null', 'none', 'backlog', 'unassigned', 'no-sprint', 'no_sprint'])
|
|
|
|
const normalizeSprintId = (value: unknown): string | undefined => {
|
|
if (typeof value !== 'string') return undefined
|
|
const sprintId = value.trim()
|
|
if (sprintId.length === 0) return undefined
|
|
return UNSPRINTED_SENTINELS.has(sprintId.toLowerCase()) ? undefined : sprintId
|
|
}
|
|
|
|
const normalizeTask = (task: Task): Task => ({
|
|
...task,
|
|
sprintId: normalizeSprintId(task.sprintId),
|
|
comments: normalizeComments(task.comments),
|
|
tags: Array.isArray(task.tags) ? task.tags.filter((tag): tag is string => typeof tag === 'string' && tag.trim().length > 0) : [],
|
|
attachments: normalizeAttachments(task.attachments),
|
|
createdById: typeof task.createdById === 'string' && task.createdById.trim().length > 0 ? task.createdById : undefined,
|
|
createdByName: typeof task.createdByName === 'string' && task.createdByName.trim().length > 0 ? task.createdByName : undefined,
|
|
createdByAvatarUrl: typeof task.createdByAvatarUrl === 'string' && task.createdByAvatarUrl.trim().length > 0 ? task.createdByAvatarUrl : undefined,
|
|
updatedById: typeof task.updatedById === 'string' && task.updatedById.trim().length > 0 ? task.updatedById : undefined,
|
|
updatedByName: typeof task.updatedByName === 'string' && task.updatedByName.trim().length > 0 ? task.updatedByName : undefined,
|
|
updatedByAvatarUrl: typeof task.updatedByAvatarUrl === 'string' && task.updatedByAvatarUrl.trim().length > 0 ? task.updatedByAvatarUrl : undefined,
|
|
assigneeId: typeof task.assigneeId === 'string' && task.assigneeId.trim().length > 0 ? task.assigneeId : undefined,
|
|
assigneeName: typeof task.assigneeName === 'string' && task.assigneeName.trim().length > 0 ? task.assigneeName : undefined,
|
|
assigneeEmail: typeof task.assigneeEmail === 'string' && task.assigneeEmail.trim().length > 0 ? task.assigneeEmail : undefined,
|
|
assigneeAvatarUrl: typeof task.assigneeAvatarUrl === 'string' && task.assigneeAvatarUrl.trim().length > 0 ? task.assigneeAvatarUrl : undefined,
|
|
})
|
|
|
|
const isSprintStatus = (value: unknown): value is SprintStatus =>
|
|
value === 'planning' || value === 'active' || value === 'completed'
|
|
|
|
const normalizeSprints = (value: unknown): Sprint[] => {
|
|
if (!Array.isArray(value)) return []
|
|
|
|
const normalized: Sprint[] = []
|
|
|
|
for (const entry of value) {
|
|
if (!entry || typeof entry !== 'object') continue
|
|
const candidate = entry as Partial<Sprint>
|
|
|
|
if (typeof candidate.id !== 'string' || candidate.id.trim().length === 0) continue
|
|
if (typeof candidate.name !== 'string' || candidate.name.trim().length === 0) continue
|
|
if (typeof candidate.startDate !== 'string' || candidate.startDate.trim().length === 0) continue
|
|
if (typeof candidate.endDate !== 'string' || candidate.endDate.trim().length === 0) continue
|
|
if (!isSprintStatus(candidate.status)) continue
|
|
|
|
const sprint: Sprint = {
|
|
id: candidate.id,
|
|
name: candidate.name,
|
|
startDate: candidate.startDate,
|
|
endDate: candidate.endDate,
|
|
status: candidate.status,
|
|
createdAt: typeof candidate.createdAt === 'string' ? candidate.createdAt : new Date().toISOString(),
|
|
}
|
|
|
|
if (typeof candidate.goal === 'string') {
|
|
sprint.goal = candidate.goal
|
|
}
|
|
|
|
normalized.push(sprint)
|
|
}
|
|
|
|
return normalized
|
|
}
|
|
|
|
const removeCommentFromThread = (comments: Comment[], targetId: string): Comment[] =>
|
|
comments
|
|
.filter((comment) => comment.id !== targetId)
|
|
.map((comment) => ({
|
|
...comment,
|
|
replies: removeCommentFromThread(normalizeComments(comment.replies), targetId),
|
|
}))
|
|
|
|
// Helper to sync to server
|
|
async function syncToServer(projects: Project[], tasks: Task[], sprints: Sprint[]) {
|
|
console.log('>>> syncToServer: saving', tasks.length, 'tasks,', projects.length, 'projects,', sprints.length, 'sprints')
|
|
const t2 = tasks.find(t => t.id === '2')
|
|
if (t2) console.log('>>> syncToServer: task 2 sprintId:', t2.sprintId)
|
|
try {
|
|
const body = JSON.stringify({ projects, tasks, sprints })
|
|
console.log('>>> syncToServer: sending body with keys:', Object.keys(JSON.parse(body)))
|
|
const res = await fetch('/api/tasks', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: body,
|
|
})
|
|
if (res.ok) {
|
|
const responseData = await res.json()
|
|
console.log('>>> syncToServer: saved successfully, server now has', responseData.data?.tasks?.length, 'tasks')
|
|
} else {
|
|
console.error('>>> syncToServer: failed with status', res.status)
|
|
}
|
|
} catch (error) {
|
|
console.error('>>> syncToServer: Failed to sync to server:', error)
|
|
}
|
|
}
|
|
|
|
export const useTaskStore = create<TaskStore>()(
|
|
persist(
|
|
(set, get) => ({
|
|
projects: defaultProjects,
|
|
tasks: defaultTasks,
|
|
sprints: defaultSprints,
|
|
selectedProjectId: '1',
|
|
selectedTaskId: null,
|
|
selectedSprintId: null,
|
|
currentUser: defaultCurrentUser,
|
|
isLoading: false,
|
|
lastSynced: null,
|
|
|
|
syncFromServer: async () => {
|
|
console.log('>>> syncFromServer START')
|
|
set({ isLoading: true })
|
|
try {
|
|
const res = await fetch('/api/tasks')
|
|
console.log('>>> syncFromServer: API response status:', res.status)
|
|
if (res.ok) {
|
|
const data = await res.json()
|
|
console.log('>>> syncFromServer: fetched data:', {
|
|
projectsCount: data.projects?.length,
|
|
tasksCount: data.tasks?.length,
|
|
sprintsCount: data.sprints?.length,
|
|
firstTaskTitle: data.tasks?.[0]?.title,
|
|
lastUpdated: data.lastUpdated,
|
|
})
|
|
console.log('>>> syncFromServer: current store tasks count BEFORE set:', get().tasks.length)
|
|
// ALWAYS use server data if API returns successfully
|
|
set({
|
|
projects: data.projects || [],
|
|
tasks: (data.tasks || []).map((task: Task) => normalizeTask(task)),
|
|
sprints: normalizeSprints(data.sprints),
|
|
lastSynced: Date.now(),
|
|
})
|
|
console.log('>>> syncFromServer: store tasks count AFTER set:', get().tasks.length)
|
|
} else {
|
|
console.error('>>> syncFromServer: API returned error status:', res.status)
|
|
}
|
|
} catch (error) {
|
|
console.error('>>> syncFromServer: Failed to sync from server:', error)
|
|
// Keep local data if server fails
|
|
} finally {
|
|
set({ isLoading: false })
|
|
console.log('>>> syncFromServer END')
|
|
}
|
|
},
|
|
|
|
syncToServer: async () => {
|
|
const { projects, tasks, sprints } = get()
|
|
await syncToServer(projects, tasks, sprints)
|
|
set({ lastSynced: Date.now() })
|
|
},
|
|
|
|
setCurrentUser: (user) => {
|
|
set((state) => ({ currentUser: normalizeUserProfile(user, state.currentUser) }))
|
|
},
|
|
|
|
addProject: (name, description) => {
|
|
const colors = ['#8b5cf6', '#3b82f6', '#10b981', '#f59e0b', '#ef4444', '#ec4899', '#06b6d4']
|
|
const newProject: Project = {
|
|
id: Date.now().toString(),
|
|
name,
|
|
description,
|
|
color: colors[Math.floor(Math.random() * colors.length)],
|
|
createdAt: new Date().toISOString(),
|
|
}
|
|
set((state) => {
|
|
const newState = { projects: [...state.projects, newProject] }
|
|
// Sync to server
|
|
syncToServer(newState.projects, state.tasks, state.sprints)
|
|
return newState
|
|
})
|
|
},
|
|
|
|
updateProject: (id, updates) => {
|
|
set((state) => {
|
|
const newProjects = state.projects.map((p) => (p.id === id ? { ...p, ...updates } : p))
|
|
syncToServer(newProjects, state.tasks, state.sprints)
|
|
return { projects: newProjects }
|
|
})
|
|
},
|
|
|
|
deleteProject: (id) => {
|
|
set((state) => {
|
|
const newProjects = state.projects.filter((p) => p.id !== id)
|
|
const newTasks = state.tasks.filter((t) => t.projectId !== id)
|
|
syncToServer(newProjects, newTasks, state.sprints)
|
|
return {
|
|
projects: newProjects,
|
|
tasks: newTasks,
|
|
selectedProjectId: state.selectedProjectId === id ? null : state.selectedProjectId,
|
|
}
|
|
})
|
|
},
|
|
|
|
selectProject: (id) => set({ selectedProjectId: id, selectedTaskId: null }),
|
|
|
|
addTask: (task) => {
|
|
const actor = profileToCommentAuthor(get().currentUser)
|
|
const newTask: Task = {
|
|
...task,
|
|
id: Date.now().toString(),
|
|
createdAt: new Date().toISOString(),
|
|
updatedAt: new Date().toISOString(),
|
|
createdById: actor.id,
|
|
createdByName: actor.name,
|
|
createdByAvatarUrl: actor.avatarUrl,
|
|
updatedById: actor.id,
|
|
updatedByName: actor.name,
|
|
updatedByAvatarUrl: actor.avatarUrl,
|
|
assigneeId: task.assigneeId || actor.id,
|
|
assigneeName: task.assigneeName || actor.name,
|
|
assigneeEmail: task.assigneeEmail || actor.email,
|
|
assigneeAvatarUrl: task.assigneeAvatarUrl || actor.avatarUrl,
|
|
comments: normalizeComments([]),
|
|
attachments: normalizeAttachments(task.attachments),
|
|
}
|
|
set((state) => {
|
|
const newTasks = [...state.tasks, newTask]
|
|
syncToServer(state.projects, newTasks, state.sprints)
|
|
return { tasks: newTasks }
|
|
})
|
|
},
|
|
|
|
updateTask: (id, updates) => {
|
|
console.log('updateTask called:', id, updates)
|
|
set((state) => {
|
|
const actor = profileToCommentAuthor(state.currentUser)
|
|
const newTasks = state.tasks.map((t) =>
|
|
t.id === id
|
|
? normalizeTask({
|
|
...t,
|
|
...updates,
|
|
comments: updates.comments !== undefined ? normalizeComments(updates.comments) : t.comments,
|
|
attachments: updates.attachments !== undefined ? normalizeAttachments(updates.attachments) : t.attachments,
|
|
updatedAt: new Date().toISOString(),
|
|
updatedById: actor.id,
|
|
updatedByName: actor.name,
|
|
updatedByAvatarUrl: actor.avatarUrl,
|
|
} as Task)
|
|
: t
|
|
)
|
|
const updatedTask = newTasks.find(t => t.id === id)
|
|
console.log('updateTask: updated task:', updatedTask)
|
|
syncToServer(state.projects, newTasks, state.sprints)
|
|
return { tasks: newTasks }
|
|
})
|
|
},
|
|
|
|
deleteTask: (id) => {
|
|
set((state) => {
|
|
const newTasks = state.tasks.filter((t) => t.id !== id)
|
|
syncToServer(state.projects, newTasks, state.sprints)
|
|
return {
|
|
tasks: newTasks,
|
|
selectedTaskId: state.selectedTaskId === id ? null : state.selectedTaskId,
|
|
}
|
|
})
|
|
},
|
|
|
|
selectTask: (id) => set({ selectedTaskId: id }),
|
|
|
|
// Sprint actions
|
|
addSprint: (sprint) => {
|
|
const newSprint: Sprint = {
|
|
...sprint,
|
|
id: Date.now().toString(),
|
|
createdAt: new Date().toISOString(),
|
|
}
|
|
set((state) => {
|
|
const newSprints = [...state.sprints, newSprint]
|
|
syncToServer(state.projects, state.tasks, newSprints)
|
|
return { sprints: newSprints }
|
|
})
|
|
},
|
|
|
|
updateSprint: (id, updates) => {
|
|
set((state) => {
|
|
const newSprints = state.sprints.map((s) =>
|
|
s.id === id ? { ...s, ...updates } : s
|
|
)
|
|
syncToServer(state.projects, state.tasks, newSprints)
|
|
return { sprints: newSprints }
|
|
})
|
|
},
|
|
|
|
deleteSprint: (id) => {
|
|
set((state) => {
|
|
const newSprints = state.sprints.filter((s) => s.id !== id)
|
|
const newTasks = state.tasks.map((t) =>
|
|
t.sprintId === id ? { ...t, sprintId: undefined } : t
|
|
)
|
|
syncToServer(state.projects, newTasks, newSprints)
|
|
return {
|
|
sprints: newSprints,
|
|
tasks: newTasks,
|
|
selectedSprintId: state.selectedSprintId === id ? null : state.selectedSprintId,
|
|
}
|
|
})
|
|
},
|
|
|
|
selectSprint: (id) => set({ selectedSprintId: id }),
|
|
|
|
getTasksBySprint: (sprintId) => {
|
|
return get().tasks.filter((t) => t.sprintId === sprintId)
|
|
},
|
|
|
|
addComment: (taskId, text, commentAuthorId) => {
|
|
const normalizedCommentAuthorId = normalizeCommentAuthorId(commentAuthorId)
|
|
if (!normalizedCommentAuthorId) return
|
|
const newComment: Comment = {
|
|
id: Date.now().toString(),
|
|
text,
|
|
createdAt: new Date().toISOString(),
|
|
commentAuthorId: normalizedCommentAuthorId,
|
|
replies: [],
|
|
}
|
|
set((state) => {
|
|
const updater = profileToCommentAuthor(state.currentUser)
|
|
const newTasks = state.tasks.map((t) =>
|
|
t.id === taskId
|
|
? {
|
|
...t,
|
|
comments: [...normalizeComments(t.comments), newComment],
|
|
updatedAt: new Date().toISOString(),
|
|
updatedById: updater.id,
|
|
updatedByName: updater.name,
|
|
updatedByAvatarUrl: updater.avatarUrl,
|
|
}
|
|
: t
|
|
)
|
|
syncToServer(state.projects, newTasks, state.sprints)
|
|
return { tasks: newTasks }
|
|
})
|
|
},
|
|
|
|
deleteComment: (taskId, commentId) => {
|
|
set((state) => {
|
|
const updater = profileToCommentAuthor(state.currentUser)
|
|
const newTasks = state.tasks.map((t) =>
|
|
t.id === taskId
|
|
? {
|
|
...t,
|
|
comments: removeCommentFromThread(normalizeComments(t.comments), commentId),
|
|
updatedAt: new Date().toISOString(),
|
|
updatedById: updater.id,
|
|
updatedByName: updater.name,
|
|
updatedByAvatarUrl: updater.avatarUrl,
|
|
}
|
|
: t
|
|
)
|
|
syncToServer(state.projects, newTasks, state.sprints)
|
|
return { tasks: newTasks }
|
|
})
|
|
},
|
|
|
|
getTasksByProject: (projectId) => {
|
|
return get().tasks.filter((t) => t.projectId === projectId)
|
|
},
|
|
|
|
getTasksByStatus: (status) => {
|
|
return get().tasks.filter((t) => t.status === status)
|
|
},
|
|
|
|
getTaskById: (id) => {
|
|
return get().tasks.find((t) => t.id === id)
|
|
},
|
|
}),
|
|
{
|
|
name: 'task-store',
|
|
version: 2,
|
|
migrate: (persistedState) => {
|
|
if (!persistedState || typeof persistedState !== 'object') {
|
|
return {}
|
|
}
|
|
|
|
const state = persistedState as {
|
|
currentUser?: unknown
|
|
selectedProjectId?: unknown
|
|
selectedTaskId?: unknown
|
|
selectedSprintId?: unknown
|
|
}
|
|
|
|
const normalizedCurrentUser = normalizeUserProfile(state.currentUser, defaultCurrentUser)
|
|
const shouldReplaceLegacyUnknown =
|
|
normalizedCurrentUser.name.trim().toLowerCase() === 'unknown user' || normalizedCurrentUser.id.trim().length === 0
|
|
|
|
return {
|
|
currentUser: shouldReplaceLegacyUnknown ? createLocalUserProfile() : normalizedCurrentUser,
|
|
selectedProjectId: typeof state.selectedProjectId === 'string' ? state.selectedProjectId : null,
|
|
selectedTaskId: typeof state.selectedTaskId === 'string' ? state.selectedTaskId : null,
|
|
selectedSprintId: typeof state.selectedSprintId === 'string' ? state.selectedSprintId : null,
|
|
}
|
|
},
|
|
partialize: (state) => ({
|
|
// Persist user identity and UI state, not task data
|
|
currentUser: state.currentUser,
|
|
selectedProjectId: state.selectedProjectId,
|
|
selectedTaskId: state.selectedTaskId,
|
|
selectedSprintId: state.selectedSprintId,
|
|
}),
|
|
onRehydrateStorage: () => {
|
|
console.log('>>> PERSIST: onRehydrateStorage called')
|
|
return (state, error) => {
|
|
if (error) {
|
|
console.log('>>> PERSIST: Rehydration error:', error)
|
|
} else {
|
|
console.log('>>> PERSIST: Rehydrated state:', {
|
|
currentUser: state?.currentUser?.name,
|
|
selectedProjectId: state?.selectedProjectId,
|
|
selectedTaskId: state?.selectedTaskId,
|
|
tasksCount: state?.tasks?.length,
|
|
})
|
|
}
|
|
}
|
|
},
|
|
}
|
|
)
|
|
)
|