Compare commits

..

3 Commits

Author SHA1 Message Date
Nikolai Giman 9b08c82548 feat: inbox 2026-06-29 00:53:53 +02:00
Nikolai Giman 8eb94d0d2b chore: readme 2026-06-28 16:09:08 +02:00
Nikolai Giman 78c910f789 Merge pull request #67 from Gimanh/feat/ui-refactor
feat: ui improvements
2026-06-28 15:07:16 +02:00
40 changed files with 481 additions and 44 deletions
+13
View File
@@ -0,0 +1,13 @@
{
"name": "taskview",
"owner": {
"name": "Nikolai Giman"
},
"plugins": [
{
"name": "taskview",
"source": "./taskview-plugin",
"description": "Manage TaskView projects and tasks from Claude Code. Bundles the TaskView MCP server, skills, and slash commands."
}
]
}
+2
View File
@@ -5,6 +5,8 @@ ReleasesBuilds/*
iconcreator
*.prod.*
.DS_Store
.cursor
.claude
node_modules
__APP_BUILD__
ssl-create
+42 -3
View File
@@ -1,7 +1,7 @@
# TaskView™
<p align="center">
<img src="./assets/taskview/kanban-dark.png" alt="TaskView logo" width="320">
<img src="./assets/taskview/kanban-dark.png" alt="TaskView — Kanban board" width="1440" style="max-width: 100%;">
</p>
<p align="center">
@@ -14,7 +14,7 @@
</p>
<details>
<summary><strong>View more screenshots</strong></summary>
<summary style="font-size: 24px"><strong>View more screenshots</strong></summary>
<br>
@@ -60,6 +60,15 @@
<a href="./LICENSE">
<img src="https://img.shields.io/badge/license-Source--Available-blue" alt="Source-Available License">
</a>
<a href="https://github.com/Gimanh/taskview-community/releases">
<img src="https://img.shields.io/github/v/release/Gimanh/taskview-community?label=release&color=brightgreen" alt="Latest release">
</a>
<a href="https://www.npmjs.com/package/taskview-mcp">
<img src="https://img.shields.io/npm/v/taskview-mcp?label=taskview-mcp&logo=npm&color=CB3837" alt="taskview-mcp on npm">
</a>
<a href="https://www.npmjs.com/package/taskview-api">
<img src="https://img.shields.io/npm/v/taskview-api?label=taskview-api&logo=npm&color=CB3837" alt="taskview-api on npm">
</a>
<img src="https://img.shields.io/badge/status-active-brightgreen" alt="Active development">
<img src="https://img.shields.io/badge/self--hosted-Docker-2496ED" alt="Docker self-hosted">
</p>
@@ -169,7 +178,37 @@ Depending on the permissions assigned to an API token, an AI assistant can:
MCP access can be restricted by permission and by selected projects.
See the [TaskView MCP documentation](https://taskview.tech/docs/integrations/mcp) for configuration examples.
### Connecting an MCP client
The MCP server is published as [`taskview-mcp`](https://www.npmjs.com/package/taskview-mcp) and runs over stdio via `npx` — no install required. You only need a TaskView API token (`tvk_...`) — generate one in your account settings (see [API tokens](https://taskview.tech/docs/features/api-tokens)). Scope the token to the minimum permissions and projects the assistant should reach.
**Claude Code** — add to `.claude/settings.json` (project) or `~/.claude.json` (global):
```json
{
"mcpServers": {
"taskview": {
"command": "npx",
"args": ["-y", "taskview-mcp"],
"env": {
"TASKVIEW_URL": "https://api.taskview.tech",
"TASKVIEW_TOKEN": "tvk_your_token_here"
}
}
}
}
```
**Claude Desktop** — add the same `mcpServers` block to `claude_desktop_config.json`.
**Other MCP clients** (Cursor, Windsurf, etc.) — use the same stdio command `npx -y taskview-mcp` with the `TASKVIEW_URL` and `TASKVIEW_TOKEN` environment variables in that client's MCP configuration.
| Variable | Required | Description |
|---|---|---|
| `TASKVIEW_URL` | yes | TaskView API server URL (e.g. `https://api.taskview.tech`, or your self-hosted instance) |
| `TASKVIEW_TOKEN` | yes | API token with the `tvk_` prefix |
See the [TaskView MCP documentation](https://taskview.tech/docs/integrations/mcp) and the [`taskview-mcp` package README](taskview-packages/taskview-mcp/README.md) for the full tool list and more options.
## Quick start
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "taskview-ce-api-server",
"version": "1.48.4",
"version": "1.48.7",
"scripts": {
"dev": "bun run --watch ./server.ts",
"start": "NODE_ENV=production node ./dist/taskview-server.js",
@@ -83,4 +83,4 @@
"engines": {
"node": ">=24 <25"
}
}
}
+15
View File
@@ -640,5 +640,20 @@
"description": [
"Added tasks.recurrence_rules.has_time — explicit flag for whether a series is anchored to a wall-clock time or is date-only. Previously the code inferred 'no time' from a midnight dtstart, which silently collapsed an explicit 00:00 series into date-only. Backfill (has_time = dtstart::time <> '00:00:00') reproduces the old inference so existing series keep their behavior; new series carry the flag through from the origin task's start_time (null = date-only, set = timed, including midnight)."
]
},
"50": {
"version": "1.55.0",
"name": "Release 1.55.0",
"releaseDate": "20260628",
"scripts": [
"/1.55.0/0.alter-goals-add-is-inbox.sql",
"/1.55.0/1.backfill-inbox-goals.sql",
"/1.55.0/2.unique-inbox-per-org.sql"
],
"description": [
"Added tasks.goals.is_inbox (BOOLEAN NOT NULL DEFAULT FALSE) — flags a project as the user's personal Inbox. One Inbox per personal organization, auto-created at signup and guarded against deletion and archival in GoalsManager.",
"Backfill: for every personal organization without an Inbox, inserts one tasks.goals row (name='Inbox', is_inbox=true, owner=the org owner, organization_id=the org). The existing AFTER INSERT triggers fully provision it like any project — default kanban statuses, the owner added to collaboration, and the default editor/executor roles with their permissions. Idempotent — skips orgs that already have an Inbox.",
"Partial unique index goals_one_inbox_per_org_uidx ON tasks.goals (organization_id) WHERE is_inbox — enforces at most one Inbox per organization at the DB level, hardening the check-then-insert against concurrent races."
]
}
}
@@ -0,0 +1,4 @@
ALTER TABLE tasks.goals
ADD COLUMN IF NOT EXISTS is_inbox BOOLEAN NOT NULL DEFAULT FALSE;
-- Flags a project as the user's personal Inbox. One Inbox per personal organization;
-- drives auto-create at signup, the backfill below, and the delete/archive guard.
@@ -0,0 +1,17 @@
-- For every personal organization that has no Inbox yet, create one.
-- The AFTER INSERT triggers on tasks.goals fully provision the goal, exactly like
-- a normal project: default kanban statuses (kanban_add_default_columns), the owner
-- added to collaboration (add_self_to_collaboration), and the default editor/executor
-- roles with their permissions (add_roles_after_insert). The owner also has full
-- permissions implicitly.
-- Idempotent: orgs that already have an Inbox are skipped.
INSERT INTO tasks.goals (name, owner, organization_id, is_inbox)
SELECT 'Inbox', o.owner_id, o.id, TRUE
FROM tv_auth.organizations o
WHERE o.is_personal = 1
AND NOT EXISTS (
SELECT 1
FROM tasks.goals g
WHERE g.organization_id = o.id
AND g.is_inbox = TRUE
);
@@ -0,0 +1,7 @@
-- Hard guarantee of at most one Inbox per organization. Defends the
-- check-then-insert in GoalsRepository.createInboxGoal against concurrent
-- signups/calls that could both pass the findInboxGoal precheck and insert.
-- Safe to create here: it runs after the idempotent backfill, so no duplicates exist.
CREATE UNIQUE INDEX IF NOT EXISTS goals_one_inbox_per_org_uidx
ON tasks.goals (organization_id)
WHERE is_inbox;
@@ -20,6 +20,7 @@ import RuEmailTemplate from './mail/confirm-email-ru';
import LoginCodeEmailTemplate from './mail/login-code-en';
import type { ExternalAuthUser } from './strategies/external-auth.types';
import { OrganizationRepository } from '../organizations/OrganizationRepository';
import { GoalsRepository } from '../goals/GoalsRepository';
const LOGIN_CODE_TTL_MS = 5 * 60 * 1000;
@@ -30,12 +31,14 @@ export default class AuthController {
private readonly refreshTokenCookieName: string = 'taskview-refresh';
private readonly orgRepository: OrganizationRepository = new OrganizationRepository();
private readonly goalsRepository: GoalsRepository = new GoalsRepository();
private async createPersonalWorkspace(userId: number, email: string, login: string) {
const slug = `org-${crypto.randomUUID().slice(0, 8)}`
const org = await this.orgRepository.create({ name: `${login}'s workspace`, slug }, userId, true)
if (org) {
await this.orgRepository.addMember(org.id, email, 'owner')
await this.goalsRepository.createInboxGoal({ ownerId: userId, organizationId: org.id })
}
}
+20
View File
@@ -63,8 +63,16 @@ export default class GoalsManager {
return new GoalItemForClient(goal, (await this.getPermissionsForGoal(goal.id)).getAllPermissions());
}
private async isInboxGoal(goalId: number): Promise<boolean> {
const goal = await this.goalsRepository.findGoalById(goalId);
return !!goal && goal.isInbox;
}
/** @deprecated use deleteGoalNew instead */
async deleteGoal(goalId: number): Promise<boolean> {
if (await this.isInboxGoal(goalId)) {
return false;
}
return await this.goalsRepository.deleteGoal(goalId);
}
@@ -88,6 +96,9 @@ export default class GoalsManager {
}
async updateArchive(goalId: number, archive: GoalItemInDb['archive']) {
if (archive === 1 && (await this.isInboxGoal(goalId))) {
return false;
}
return await this.goalsRepository.updateArchive(goalId, archive);
}
@@ -134,6 +145,12 @@ export default class GoalsManager {
}
async updateGoalNew(goalData: GoalsArgUpdate): Promise<GoalsItemForClientWithPermissions | false> {
// The Inbox must never be archived. Archiving flows through this endpoint
// (PATCH /module/goals), not the unrouted updateArchive, so the guard lives here.
if (goalData.archive === 1 && (await this.isInboxGoal(goalData.id))) {
return false;
}
const goal = await this.goalsRepository.updateGoalNew(goalData);
if (!goal) {
@@ -153,6 +170,9 @@ export default class GoalsManager {
}
async deleteGoalNew(goalData: GoalsArgDelete) {
if (await this.isInboxGoal(goalData.goalId)) {
return false;
}
return await this.goalsRepository.deleteGoalNew(goalData);
}
+51 -3
View File
@@ -12,7 +12,7 @@ import type { AddGoalToDbArg, GoalItemInDb, GoalItemsInDb, UpdateGoalDbArg } fro
import { logError } from '../../utils/api';
import { updateQuery } from '../../utils/db-helper';
import { callWithCatch } from '../../utils/helpers';
import type { GoalsArgAdd, GoalsArgDelete, GoalsArgUpdate } from './types';
import type { GoalsArgAdd, GoalsArgCreateInbox, GoalsArgDelete, GoalsArgUpdate } from './types';
export class GoalsRepository {
private readonly db: Database;
@@ -157,6 +157,7 @@ export class GoalsRepository {
backlogVersion: GoalsSchema.backlogVersion,
organizationId: GoalsSchema.organizationId,
estimateUnit: GoalsSchema.estimateUnit,
isInbox: GoalsSchema.isInbox,
})
.from(GoalsSchema)
.leftJoin(CollaborationUsersToGoalsSchema, eq(GoalsSchema.id, CollaborationUsersToGoalsSchema.goalId))
@@ -232,9 +233,56 @@ export class GoalsRepository {
return result[0];
}
async updateGoalNew(goalData: GoalsArgUpdate): Promise<GoalsSchemaTypeForSelect | false> {
async findInboxGoal(organizationId: number): Promise<GoalsSchemaTypeForSelect | false> {
const result = await callWithCatch(() =>
this.db.dbDrizzle.update(GoalsSchema).set(goalData).where(eq(GoalsSchema.id, goalData.id)).returning()
this.db.dbDrizzle
.select()
.from(GoalsSchema)
.where(and(eq(GoalsSchema.organizationId, organizationId), eq(GoalsSchema.isInbox, true)))
);
if (!result || result.length === 0) return false;
return result[0];
}
async createInboxGoal(args: GoalsArgCreateInbox): Promise<GoalsSchemaTypeForSelect | false> {
const existing = await this.findInboxGoal(args.organizationId);
if (existing) {
return existing;
}
const result = await callWithCatch(() =>
this.db.dbDrizzle
.insert(GoalsSchema)
.values({
name: 'Inbox',
owner: args.ownerId,
organizationId: args.organizationId,
isInbox: true,
})
.returning()
);
if (!result) {
return false;
}
return result[0];
}
async updateGoalNew(goalData: GoalsArgUpdate): Promise<GoalsSchemaTypeForSelect | false> {
const updates: Partial<typeof GoalsSchema.$inferInsert> = {};
if (goalData.name !== undefined) updates.name = goalData.name;
if (goalData.description !== undefined) updates.description = goalData.description;
if (goalData.color !== undefined) updates.color = goalData.color;
if (goalData.estimateUnit !== undefined) updates.estimateUnit = goalData.estimateUnit;
if (goalData.archive !== undefined) updates.archive = goalData.archive;
if (Object.keys(updates).length === 0) {
return this.findGoalById(goalData.id);
}
const result = await callWithCatch(() =>
this.db.dbDrizzle.update(GoalsSchema).set(updates).where(eq(GoalsSchema.id, goalData.id)).returning()
);
if (!result) {
return false;
+6
View File
@@ -17,6 +17,7 @@ export const GoalsArkTypeUpdate = type({
'description?': 'string | null',
'color?': 'string | null',
"estimateUnit?": "'hours' | 'points'",
'archive?': '0 | 1',
});
export type GoalsArgUpdate = typeof GoalsArkTypeUpdate.infer;
@@ -34,3 +35,8 @@ export const GoalsArkTypeFetch = type({
export type GoalsArgFetch = typeof GoalsArkTypeFetch.infer;
export type GoalsItemForClientWithPermissions = GoalsSchemaTypeForSelect & { permissions: GoalPermissionsForClient };
export type GoalsArgCreateInbox = {
ownerId: number;
organizationId: number;
};
+3
View File
@@ -6,6 +6,7 @@ import { $logger } from '../../modules/logget'
import { logError } from '../../utils/api'
import { generateString, isEmail } from '../../utils/helpers'
import AuthModel from '../auth/AuthModel'
import { GoalsRepository } from '../goals/GoalsRepository'
import { OrganizationRepository } from '../organizations/OrganizationRepository'
import { createSsoProvider } from './providers/provider-factory'
import { SsoRepository } from './SsoRepository'
@@ -17,6 +18,7 @@ export class SsoController {
private readonly ssoRepo = new SsoRepository()
private readonly authModel = new AuthModel()
private readonly orgRepo = new OrganizationRepository()
private readonly goalsRepo = new GoalsRepository()
initiateLogin = async (req: Request, res: Response) => {
const configId = Number(req.params.configId)
@@ -75,6 +77,7 @@ export class SsoController {
const personalOrg = await this.orgRepo.create({ name: `${login}'s workspace`, slug: personalOrgSlug }, id, true)
if (personalOrg) {
await this.orgRepo.addMember(personalOrg.id, ssoResult.email, 'owner')
await this.goalsRepo.createInboxGoal({ ownerId: id, organizationId: personalOrg.id })
}
userData = await this.authModel.getUserByLogin(ssoResult.email, isEmail(ssoResult.email))
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "taskview-ce-monorepo",
"version": "1.48.4",
"version": "1.48.7",
"private": true,
"description": "TaskView CE monorepo containing web, API, and packages",
"workspaces": [
@@ -44,4 +44,4 @@
"passport-github2": "^0.1.12",
"passport-google-oauth20": "^2.0.0"
}
}
}
@@ -44,6 +44,7 @@ export type GoalItem = {
dateCreation: string | null;
organizationId: number | null;
estimateUnit: 'hours' | 'points';
isInbox: boolean;
};
export type GoalArgItemAdd = Pick<GoalItem, 'name'> & Partial<Pick<GoalItem, 'description' | 'color' | 'organizationId'>>;
@@ -1,4 +1,4 @@
import { integer, pgSchema, timestamp, varchar } from "drizzle-orm/pg-core";
import { boolean, integer, pgSchema, timestamp, varchar } from "drizzle-orm/pg-core";
import { UsersSchema } from "./users.schema";
import { OrganizationsSchema } from "./organizations.schema";
// import { createInsertSchema, createSelectSchema } from "drizzle-arktype";
@@ -16,6 +16,7 @@ export const GoalsSchema = pgSchema('tasks').table('goals', {
backlogVersion: integer('backlog_version').default(1),
organizationId: integer('organization_id').references(() => OrganizationsSchema.id, { onDelete: 'cascade' }),
estimateUnit: varchar('estimate_unit', { length: 10 }).$type<'hours' | 'points'>().notNull().default('points'),
isInbox: boolean('is_inbox').notNull().default(false),
});
export type GoalsSchemaTypeForSelect = typeof GoalsSchema.$inferSelect;
@@ -0,0 +1,24 @@
{
"name": "taskview",
"displayName": "TaskView",
"version": "0.1.0",
"description": "Manage TaskView projects and tasks from Claude Code — browse projects, view and create tasks, and log your work without leaving the terminal. Bundles the TaskView MCP server.",
"author": {
"name": "Nikolai Giman"
},
"userConfig": {
"taskview_url": {
"type": "string",
"title": "TaskView API URL",
"description": "TaskView Cloud is https://api.taskview.tech — or your own self-hosted instance URL (e.g. https://taskview.yourcompany.com).",
"required": true
},
"taskview_token": {
"type": "string",
"title": "TaskView API token",
"description": "An API token (tvk_...) generated in your TaskView account settings. Scope it to the minimum permissions and projects the assistant should be able to reach.",
"sensitive": true,
"required": true
}
}
}
+12
View File
@@ -0,0 +1,12 @@
{
"mcpServers": {
"taskview": {
"command": "npx",
"args": ["-y", "taskview-mcp"],
"env": {
"TASKVIEW_URL": "${user_config.taskview_url}",
"TASKVIEW_TOKEN": "${user_config.taskview_token}"
}
}
}
}
+38
View File
@@ -0,0 +1,38 @@
# TaskView plugin for Claude Code
Browse and manage your [TaskView](https://taskview.tech) projects and tasks directly from Claude Code. The plugin bundles the [`taskview-mcp`](https://www.npmjs.com/package/taskview-mcp) server plus skills and slash commands, so setup is just "install and paste a token" — no manual config editing.
## What you get
- **Bundled MCP server** — the full TaskView tool surface (projects, lists, tasks, tags, kanban, collaboration, dependencies, notifications), no separate install.
- **Slash commands:**
- `/taskview-projects` — list your projects
- `/taskview-tasks [project]` — show tasks in a project
- `/taskview-new-task <description>` — create a task
- `/taskview-log [project]` — log the work done this session as a task
- **Skill** — conventions so the assistant resolves ids correctly, paginates, and logs completed work as top-level tasks.
## Requirements
- Node.js >= 24 (the bundled MCP server runs via `npx`)
- A TaskView API token (`tvk_...`) from your account settings — see [API tokens](https://taskview.tech/docs/features/api-tokens)
## Install
```text
/plugin marketplace add Gimanh/taskview-community
/plugin install taskview@taskview
```
On install you'll be prompted for two values:
| Setting | Example | Notes |
|---|---|---|
| **TaskView API URL** | `https://api.taskview.tech` | TaskView Cloud, or your self-hosted instance URL |
| **TaskView API token** | `tvk_...` | Stored securely in your system keychain. Scope it to the minimum permissions/projects needed. |
That's it — the `taskview` MCP server and the commands become available after the plugin is enabled.
## Security
The token is supplied via the plugin's secure `userConfig` (marked sensitive → stored in the OS keychain, not in plaintext settings) and passed to the MCP server only as an environment variable. Every tool call still goes through the full TaskView API stack — authentication, permission checks, and token-scope intersection. Give the token only the scope the assistant should have.
+13
View File
@@ -0,0 +1,13 @@
---
description: Log the work done in this session as a TaskView task
---
Record what was accomplished in the current Claude Code session as a TaskView task, using the `taskview` MCP tools.
1. Summarize the concrete work completed in this session in 13 short lines.
2. Ask the user which project to log it under (or use the one they name in $ARGUMENTS), and resolve its id with `list_goals`.
3. Create **one top-level task** with `create_task` — description = the summary. Set priority/deadline only if the user asks.
4. Show the created task and ask whether to break it into subtasks or add tags.
5. Add task note if needed.
Confirm the project before creating. Do not create the task in a guessed project, and do not create duplicates — check `list_tasks` if unsure.
@@ -0,0 +1,14 @@
---
description: Create a task in TaskView
---
Create a TaskView task using the `taskview` MCP tools.
Request: $ARGUMENTS
1. Parse the request for: task description, target project, optional list, priority (1=low, 2=medium, 3=high), and deadline.
2. Resolve the project id with `list_goals` (and the list id with `list_lists` if a list is named). If the project is missing or ambiguous, ask — do not guess.
3. Call `create_task` with the resolved ids and parsed fields.
4. Confirm what was created (project, description, priority, deadline) and offer to add tags or assignees.
Never create a task in a guessed/unconfirmed project.
@@ -0,0 +1,11 @@
---
description: List your TaskView projects (goals)
---
List the user's TaskView projects using the `taskview` MCP tools.
1. If the user has more than one organization, resolve it with `list_organizations` and ask which one if unclear.
2. Call `list_goals` and present the projects as a short list: name, and a hint of activity if available.
3. Offer next actions — e.g. "view tasks in a project" (`/taskview-tasks`) or "create a task" (`/taskview-new-task`).
Do not invent project names or ids — only show what the tools return.
@@ -0,0 +1,14 @@
---
description: Show tasks in a TaskView project
---
Show the user's TaskView tasks using the `taskview` MCP tools.
Arguments (optional): a project name to scope to — $ARGUMENTS
1. Resolve the project: call `list_goals` and match $ARGUMENTS to a project. If empty or ambiguous, ask which project.
2. Optionally scope to a list with `list_lists` + the `componentId` if the user names one.
3. Call `list_tasks` (0-based `page`, ~30/page) — page through until a page returns fewer than 30. Pass `showCompleted` only if the user wants completed tasks. Use `sortBy` with `descending` when ordering by date or priority.
4. Present a concise checklist: status, description, priority (1=low/2=med/3=high), deadline.
Resolve every id first — never guess project/list ids.
+51
View File
@@ -0,0 +1,51 @@
---
name: taskview
description: Conventions for browsing and managing TaskView projects, lists, and tasks through the bundled taskview MCP tools
---
# Working with TaskView
This plugin connects Claude Code to a TaskView instance through the `taskview`
MCP server. Use it to let the user view and manage their work without leaving
the terminal.
## Terminology
- A **goal** IS a **project** — the words are interchangeable. Every tool/param
that says "goal" (`goalId`, `list_goals`, `create_goal`) means a project. When
the user says "project", use the `*_goal` tools.
## Data model (top → bottom)
- **Organization** — workspace grouping projects and members.
- **Project (goal)** — `goalId`; `list_goals` / `create_goal` / `update_goal`.
- **List (component)** — a section inside a project; pass `componentId` to
`list_tasks` to scope to one list, omit it to see all tasks.
- **Task** — unit of work; supports subtasks, assignees, tags, priority
(1=low, 2=medium, 3=high), deadlines, and dependencies.
## Rules that prevent mistakes
- **Resolve IDs first, never guess them.** Map a name to its numeric id with the
matching `list_*` tool (project → `list_goals`, list → `list_lists`,
members → `list_collaborators_for_goal`, columns → `list_kanban_columns`,
tags → `list_tags`), then pass that id to create/update/delete tools.
- **`list_tasks` is paginated** — `page` is 0-based (~30/page). Keep requesting
the next page until one returns fewer than 30. Completed tasks are hidden
unless `showCompleted` is set. Use `sortBy` (`date` | `priority`) with
`descending` to order.
- **Ask before destructive actions** (`delete_*`) and before creating duplicates
— check with a `list_*` call first.
## Presenting results
- Show tasks as a concise checklist: status, description, priority, deadline.
- When something is ambiguous (which project/list/org), ask one short question
rather than guessing.
## Logging completed work
When the user asks to record what was done, create **one top-level task per
finished unit of work** in the relevant project (resolve it first), with a clear
description and, if known, the list and priority. Do not bury the summary as a
subtask unless the user asks for that.
+2 -2
View File
@@ -2,7 +2,7 @@
"name": "web-nuxt-ui",
"private": true,
"type": "module",
"version": "1.48.4",
"version": "1.48.7",
"scripts": {
"dev": "vite",
"build:packages": "pnpm --filter taskview-db-schemas build && pnpm --filter taskview-api build",
@@ -91,4 +91,4 @@
"vue-tsc": "^3.2.2"
},
"packageManager": "pnpm@10.28.1"
}
}
@@ -105,6 +105,7 @@ import { useGoalsStore } from '@/stores/goals.store'
import { useBaseScreenStore } from '@/stores/base-screen.store'
import { $ls, $tvApi } from '@/plugins/axios'
import { useTaskView } from '@/composables/useTaskView'
import { useInbox } from '@/composables/useInbox'
import { useWeekStart } from '@/composables/useWeekStart'
const weekStart = useWeekStart()
@@ -136,6 +137,7 @@ const { t } = useI18n()
const goalsStore = useGoalsStore()
const baseScreenStore = useBaseScreenStore()
const { isMobile } = useTaskView()
const { inbox } = useInbox()
const taskDescription = ref(props.taskName)
const selectedGoalId = ref<number | undefined>(undefined)
@@ -161,18 +163,13 @@ watch(model, async (isOpen) => {
)
}
// Pre-select the last used project. Read fresh from storage on every open so
// it stays in sync across the multiple dialog instances on the screen.
const activeGoals = goalsStore.goals.filter(g => !g.archive)
// Use the last chosen project (it may be the Inbox); otherwise default to the
// Inbox — that's exactly what it's for.
const saved = await $ls.getValue(LAST_SELECTED_PROJECT_KEY)
const savedId = saved ? Number(saved) : undefined
const savedGoal = savedId ? activeGoals.find(g => g.id === savedId) : undefined
const savedGoal = savedId ? goalsStore.goals.find(g => !g.archive && g.id === savedId) : undefined
if (savedGoal) {
selectedGoalId.value = savedGoal.id
} else if (activeGoals.length === 1) {
selectedGoalId.value = activeGoals[0].id
}
selectedGoalId.value = savedGoal ? savedGoal.id : inbox.value?.id
} else {
// Reset form on close
taskDescription.value = props.taskName
@@ -181,14 +178,16 @@ watch(model, async (isOpen) => {
}
})
const goalOptions = computed(() =>
goalsStore.goals
.filter(g => !g.archive)
.map(g => ({
label: g.name,
value: g.id,
})),
)
const goalOptions = computed(() => {
const active = goalsStore.goals.filter(g => !g.archive)
const options = active
.filter(g => !g.isInbox)
.map(g => ({ label: g.name, value: g.id }))
if (inbox.value) {
options.unshift({ label: t('projects.inbox'), value: inbox.value.id })
}
return options
})
const formattedDeadline = computed(() => {
if (!deadline.value) return ''
@@ -22,7 +22,7 @@ import { useI18n } from 'vue-i18n'
const goalsStore = useGoalsStore()
const { t } = useI18n()
const noGoals = computed(() => goalsStore.goals.filter((g) => !g.archive).length === 0)
const noGoals = computed(() => goalsStore.goals.filter((g) => !g.archive && !g.isInbox).length === 0)
async function addProject(name: string) {
await goalsStore.addGoal({ name })
@@ -33,8 +33,8 @@ const goalsStore = useGoalsStore()
const orgStore = useOrganizationStore()
const { goals } = storeToRefs(goalsStore)
const activeProjects = computed(() => goals.value.filter(p => p.archive === 0))
const archivedProjects = computed(() => goals.value.filter(p => p.archive === 1))
const activeProjects = computed(() => goals.value.filter(p => p.archive === 0 && !p.isInbox))
const archivedProjects = computed(() => goals.value.filter(p => p.archive === 1 && !p.isInbox))
watch(() => orgStore.currentOrg, async () => {
@@ -23,6 +23,22 @@
<template #body>
<TaskDetailPanel />
</template>
<template #footer>
<div class="flex justify-between gap-2 w-full">
<UButton
:label="t('common.delete')"
color="error"
variant="soft"
@click="openDeleteDialog"
/>
<UButton
:label="t('common.close')"
color="neutral"
variant="soft"
@click="closeTask"
/>
</div>
</template>
</USlideover>
<!-- Task Detail Modal -->
@@ -19,6 +19,10 @@
<NotificationBell />
</div>
<SidebarInboxLink />
<USeparator />
<ProjectsSidebar />
<template #footer="{ collapsed }">
@@ -32,6 +36,7 @@ import { useI18n } from 'vue-i18n'
import { useDashboard } from '@/composables/useDashboard'
import TvGoalLikeItem from '@/components/features/base/TvGoalLikeItem.vue'
import ProjectsSidebar from '@/components/features/projects/ProjectsSidebar.vue'
import SidebarInboxLink from '@/components/sidebars/SidebarInboxLink.vue'
import NotificationBell from '@/components/NotificationBell.vue'
import ActiveTimerIndicator from '@/components/ActiveTimerIndicator.vue'
import UserMenu from '@/components/UserMenu.vue'
@@ -13,7 +13,6 @@
:to="{ name: 'user' }"
variant="taskview"
class="flex-1"
:ui="{ container: 'px-4 py-3' }"
>
{{ t('main') }}
</TvGoalLikeItem>
@@ -22,6 +21,10 @@
</div>
<SearchActivator />
<SidebarInboxLink />
<USeparator />
<SidebarProjectSelect />
<SidebarTools />
<ProjectAddInput
@@ -53,6 +56,7 @@ import UserMenu from '@/components/UserMenu.vue'
import TvGoalLikeItem from '@/components/features/base/TvGoalLikeItem.vue'
import ActiveTimerIndicator from '@/components/ActiveTimerIndicator.vue'
import NotificationBell from '@/components/NotificationBell.vue'
import SidebarInboxLink from '@/components/sidebars/SidebarInboxLink.vue'
import SidebarProjectSelect from './dashboard-second/SidebarProjectSelect.vue'
import SidebarTools from './dashboard-second/SidebarTools.vue'
import SidebarOrgSelect from './dashboard-second/SidebarOrgSelect.vue'
@@ -0,0 +1,34 @@
<template>
<TvGoalLikeItem
v-if="inbox"
variant="taskview"
:to="{ name: 'user', params: { projectId: inbox.id, listId: ALL_TASKS_LIST_ID } }"
:active="currentProjectId === inbox.id"
>
<div
class="flex items-center gap-2 w-full"
data-testid="sidebar-inbox-link"
>
<UIcon
name="i-lucide-inbox"
class="size-4 shrink-0"
/>
<span>{{ t('projects.inbox') }}</span>
</div>
</TvGoalLikeItem>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { useRoute } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { ALL_TASKS_LIST_ID } from 'taskview-api'
import { useInbox } from '@/composables/useInbox'
import TvGoalLikeItem from '@/components/features/base/TvGoalLikeItem.vue'
const { t } = useI18n()
const route = useRoute()
const { inbox } = useInbox()
const currentProjectId = computed(() => Number(route.params.projectId) || null)
</script>
@@ -10,7 +10,7 @@
variant="soft"
size="xl"
block
icon="i-lucide-folder"
:icon="currentProject?.isInbox ? 'i-lucide-inbox' : 'i-lucide-folder'"
trailing-icon="i-lucide-chevron-down"
:ui="{ base: 'rounded-xl', trailingIcon: 'ms-auto' }"
>
@@ -18,7 +18,7 @@
class="flex-1 text-left truncate"
:class="currentProject ? '' : 'text-muted'"
>
{{ currentProject?.name ?? t('projects.selectProject') }}
{{ currentProjectLabel }}
</span>
</UButton>
@@ -89,7 +89,7 @@
class="flex items-center justify-end gap-2"
>
<UButton
v-if="canEditGoal && !isArchived"
v-if="canEditGoal && !isArchived && !isInbox"
icon="i-lucide-archive"
color="neutral"
variant="soft"
@@ -99,7 +99,7 @@
@click="archive"
/>
<UButton
v-if="canEditGoal && isArchived"
v-if="canEditGoal && isArchived && !isInbox"
icon="i-lucide-archive-restore"
color="neutral"
variant="soft"
@@ -109,7 +109,7 @@
@click="restore"
/>
<UButton
v-if="canEditGoal"
v-if="canEditGoal && !isInbox"
icon="i-lucide-pencil"
color="neutral"
variant="soft"
@@ -118,7 +118,7 @@
@click="isEditOpen = true"
/>
<UButton
v-if="canDeleteGoal"
v-if="canDeleteGoal && !isInbox"
icon="i-lucide-trash-2"
color="error"
variant="soft"
@@ -165,12 +165,18 @@ const isEditOpen = ref(false)
const isDeleteOpen = ref(false)
const currentProjectId = computed(() => Number(route.params.projectId) || null)
const activeProjects = computed(() => goals.value.filter(p => p.archive === 0))
const archivedProjects = computed(() => goals.value.filter(p => p.archive === 1))
const activeProjects = computed(() => goals.value.filter(p => p.archive === 0 && !p.isInbox))
const archivedProjects = computed(() => goals.value.filter(p => p.archive === 1 && !p.isInbox))
const currentProject = computed<Project | null>(() =>
currentProjectId.value ? goalsStore.goalMap.get(currentProjectId.value) ?? null : null,
)
const isArchived = computed(() => currentProject.value?.archive === 1)
const isInbox = computed(() => currentProject.value?.isInbox === true)
const currentProjectLabel = computed(() =>
currentProject.value
? (currentProject.value.isInbox ? t('projects.inbox') : currentProject.value.name)
: t('projects.selectProject'),
)
const { canEditGoal, canDeleteGoal } = useGoalPermissionsFor(currentProject)
+13
View File
@@ -0,0 +1,13 @@
import { computed } from 'vue'
import { storeToRefs } from 'pinia'
import { useGoalsStore } from '@/stores/goals.store'
import type { Project } from '@/components/features/projects/types'
export const useInbox = () => {
const goalsStore = useGoalsStore()
const { goals } = storeToRefs(goalsStore)
const inbox = computed<Project | null>(() => goals.value.find(g => g.isInbox) ?? null)
return { inbox }
}
+1 -1
View File
@@ -13,7 +13,7 @@ export const useTaskView = () => {
const isFullscreenModal = bp.smallerOrEqual('fullscreenModalMax')
return {
hasActiveGoals: computed(() => goalsStore.goals.length > 0),
hasActiveGoals: computed(() => goalsStore.goals.some(g => !g.isInbox)),
isMobile,
isDesktop,
isFullscreenModal,
+1
View File
@@ -103,6 +103,7 @@ export default {
createNew: 'Create new project',
add: 'Add project',
empty: 'No projects yet',
inbox: 'Inbox',
},
contextMenu: {
tasks: 'Tasks',
+1
View File
@@ -103,6 +103,7 @@ export default {
createNew: 'Crear nuevo proyecto',
add: 'Añadir proyecto',
empty: 'Aún no hay proyectos',
inbox: 'Bandeja de entrada',
},
contextMenu: {
tasks: 'Lista',
+1
View File
@@ -103,6 +103,7 @@ export default {
createNew: 'Создать новый проект',
add: 'Добавить проект',
empty: 'Пока нет проектов',
inbox: 'Входящие',
},
contextMenu: {
tasks: 'Список',
+1
View File
@@ -12,6 +12,7 @@ export const DEFAULT_GOAL_ITEM: GoalItem = {
dateCreation: null,
organizationId: null,
estimateUnit: 'points',
isInbox: false,
}
export const AllGoalPermissions = TvPermissions
+1 -1
View File
@@ -182,7 +182,7 @@ export default defineConfig({
variant: {
taskview: {
root: 'cursor-pointer shadow-sm bg-tv-ui-bg-elevated',
container: 'sm:p-2 sm:px-4',
container: 'p-2 px-4 sm:p-2 sm:px-4',
},
},