Compare commits

..

11 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
Nikolai Giman e1a8bb71a0 fix: reload main screen when become active 2026-06-28 15:02:07 +02:00
Nikolai Giman f1ff303e58 fix: ipad safe area issues 2026-06-28 00:04:42 +02:00
Nikolai Giman 22dc24f8e0 fix: ui and recurring issues 2026-06-27 22:51:29 +02:00
Nikolai Giman 58e3cdcbb0 wip: mobile area 2026-06-21 23:47:43 +02:00
Nikolai Giman 28e944f05e wip: search width 2026-06-21 23:22:33 +02:00
Nikolai Giman 8f21a52ca6 wip: style 2026-06-21 23:18:01 +02:00
Nikolai Giman 96ac9f4d7a feat: ui improvements 2026-06-21 23:02:31 +02:00
Nikolai Giman 4c92aaf03d Merge pull request #66 from Gimanh/feat/recur-tasks
feat: recur tasks
2026-06-14 17:29:53 +02:00
188 changed files with 5507 additions and 990 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
+393 -97
View File
@@ -1,162 +1,458 @@
# TaskView™
![TaskView Logo](./assets/taskview-logo.png)
<p align="center">
<img src="./assets/taskview/kanban-dark.png" alt="TaskView — Kanban board" width="1440" style="max-width: 100%;">
</p>
TaskView is a self-hosted project and task management platform focused on clarity, ownership, and control.
TaskView is built for teams that want a transparent, self-hosted alternative to SaaS task managers.
<p align="center">
<strong>Self-hosted project management for software teams.</strong>
</p>
[![License](https://img.shields.io/badge/license-Source--Available-blue)](./LICENSE)
[![Active Development](https://img.shields.io/badge/status-active-brightgreen)]()
[**Live demo**](https://app.taskview.tech) · [**Documentation**](https://taskview.tech/docs/) · [**iOS**](https://apps.apple.com/lk/app/taskview-todo-list-tasks/id6499107867) · [**Android**](https://play.google.com/store/apps/details?id=com.handscreamgnl.taskview.app&hl=en)
<p align="center">
TaskView combines task management, custom workflows, developer integrations,
analytics, and AI-assisted automation in a platform you can run on your own infrastructure.
</p>
## Apps
* [Docs](https://taskview.tech/docs/)
* [Web](https://app.taskview.tech/)
* [iOS](https://apps.apple.com/lk/app/taskview-todo-list-tasks/id6499107867)
* [Android](https://play.google.com/store/apps/details?id=com.handscreamgnl.taskview.app&hl=en)
<details>
<summary style="font-size: 24px"><strong>View more screenshots</strong></summary>
It is designed for teams and individuals who want:
- full control over their data
- transparent architecture
- predictable workflows
- the ability to run the system on their own infrastructure
<br>
## Key Features
- Projects, lists, tasks, and subtasks
- Kanban boards
- Clear responsibility assignment
- Permissions and roles
- Web and mobile clients
- Self-hosted by design
<img src="./assets/taskview/main-light.png" alt="Main dashboard (light theme)" width="100%">
<img src="./assets/taskview/main-dark.png" alt="Main dashboard (dark theme)" width="100%">
<img src="./assets/taskview/main-collapsed-light.png" alt="Main dashboard with collapsed sidebar (light theme)" width="100%">
<img src="./assets/taskview/main-collapsed-dark.png" alt="Main dashboard with collapsed sidebar (dark theme)" width="100%">
<img src="./assets/taskview/kanban-light.png" alt="Kanban board (light theme)" width="100%">
<img src="./assets/taskview/kanban-dark.png" alt="Kanban board (dark theme)" width="100%">
<img src="./assets/taskview/tasks-light.png" alt="Task list view (light theme)" width="100%">
<img src="./assets/taskview/tasks-dark.png" alt="Task list view (dark theme)" width="100%">
<img src="./assets/taskview/recent-tasks.png" alt="Recent tasks overview" width="100%">
<img src="./assets/taskview/sprints.png" alt="Sprint planning" width="100%">
<img src="./assets/taskview/time-tracking.png" alt="Time tracking" width="100%">
<img src="./assets/taskview/analytics.png" alt="Analytics dashboard" width="100%">
<img src="./assets/taskview/members.png" alt="Organization members management" width="100%">
<img src="./assets/taskview/permissions-light.png" alt="Role permissions (light theme)" width="100%">
<img src="./assets/taskview/permissions-dark.png" alt="Role permissions (dark theme)" width="100%">
<img src="./assets/taskview/sso.png" alt="Single Sign-On (SSO) configuration" width="100%">
<img src="./assets/taskview/api-tokens.png" alt="API tokens management" width="100%">
<img src="./assets/taskview/webhooks.png" alt="Webhooks configuration" width="100%">
<img src="./assets/taskview/integrations.png" alt="Integrations" width="100%">
<img src="./assets/taskview/ui-customization.png" alt="UI customization" width="100%">
<img src="./assets/taskview/settings.png" alt="Settings" width="100%">
## Licensing
</details>
TaskView uses a source-available license.
<p align="center">
<a href="https://app.taskview.tech"><strong>TaskView Cloud</strong></a>
·
<a href="https://taskview.tech/docs/"><strong>Documentation</strong></a>
·
<a href="https://apps.apple.com/lk/app/taskview-todo-list-tasks/id6499107867"><strong>iOS</strong></a>
·
<a href="https://play.google.com/store/apps/details?id=com.handscreamgnl.taskview.app"><strong>Android</strong></a>
·
<a href="https://github.com/Gimanh/taskview-community/releases">
<strong>Releases</strong>
</a>
</p>
You may:
- self-host TaskView
- modify the source code
- use it internally within your organization
<p align="center">
<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>
You may not:
- offer TaskView as a hosted SaaS
- sell TaskView as a service
- create a competing commercial product
- use the TaskView name or branding for derivative products
---
See LICENSE for full terms.
TaskView is a trademark of its authors.
## About TaskView
## Architecture
TaskView is a source-available project and task management platform for small and growing software teams.
This repository is a monorepo.
It is designed for teams that need more structure than a simple task board, but do not want the administration overhead and complexity of large enterprise project management systems.
Main product packages:
With TaskView, you can manage projects, build custom workflows, track dependencies and time, connect development tools, and automate project operations through the API, webhooks, and MCP.
- `api` backend (Node.js)
- `web` web client and mobile
- `taskview-packages/taskview-api` API client library for building integrations and making HTTP requests
- `taskview-packages/taskview-db-schemas` - Drizzle ORM schemas
Your projects. Your infrastructure. Your data.
Product packages share a single version.
## Who TaskView is for
TaskView is a good fit for:
## Versioning
* software development teams;
* startups and small product companies;
* agencies managing internal and client projects;
* organizations that require self-hosting and data ownership;
* teams looking for a lightweight alternative to complex enterprise platforms;
* developers using AI agents to manage and automate project work;
* individuals managing technical or personal projects.
TaskView follows semantic versioning:
- `MAJOR` breaking API or data changes
- `MINOR` new features
- `PATCH` bug fixes
The version represents the entire product, not individual packages.
## Why TaskView
* **Own your data** - run TaskView on your own infrastructure.
* **Flexible workflows** - configure projects, statuses, boards, roles, and permissions.
* **Built for development teams** - connect GitHub, GitLab, API clients, and webhooks.
* **AI-ready** - allow MCP-compatible assistants to work with tasks and projects.
* **Available everywhere** - use TaskView from the web, iOS, and Android.
* **No advertising or behavioral tracking** - your project data remains under your control.
## Status
## Features
TaskView is under active development.
Breaking changes may occur between versions.
### Project and task management
## Getting started
* Projects, lists, tasks, and nested subtasks
* Custom Kanban boards and task statuses
* Priorities, tags, deadlines, and assignees
* Task dependency graph
* Sprints and recurring tasks
* Task history and activity tracking
* Markdown content
* Search and filtering
* Reusable project workflows
This setup is intended for local development.
### Team collaboration
### Development
* Multiple organizations and projects
* User invites and granular permissions
* Clear responsibility assignment
* Real-time updates
* Notifications
* Organization-level user management
**Prerequisites:**
- Docker
- PostgreSQL
- On macOS you can use https://postgresapp.com/
- Bun (used for API development)
- pnpm
### Identity and access management
* SAML 2.0
* OpenID Connect
* SCIM provisioning
* Scoped API tokens
* Project-level access restrictions
* Fine-grained token permissions
### Developer integrations
* GitHub integration
* GitLab integration
* Signed webhooks
* TypeScript API client
* Public HTTP API
* MCP server for AI assistants
### Time tracking and analytics
* Built-in time tracking
* Billable and non-billable time
* Project and team workload reports
* Productivity analytics
* Income and expense tracking
* Financial reports
### Applications
* Web application
* iOS application
* Android application
* Self-hosted server deployment
## AI and MCP integration
TaskView includes an MCP server that allows compatible AI tools to interact with your projects.
Depending on the permissions assigned to an API token, an AI assistant can:
* search and inspect projects;
* create and update tasks;
* manage task statuses;
* work with project lists;
* retrieve task context;
* automate repetitive project operations.
MCP access can be restricted by permission and by selected projects.
### 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
Clone the repository:
```sh
git clone https://github.com/Gimanh/taskview-community.git
cd taskview-community
```
The recommended way to run TaskView is with Docker Compose.
For deployment instructions, environment variables, HTTPS configuration, updates, and backups, see the official documentation:
[TaskView self-hosting documentation](https://taskview.tech/docs/)
> Production deployments should use persistent volumes, HTTPS, regular database backups, and securely generated secrets.
## Repository structure
TaskView is maintained as a monorepo. Product packages share a single version.
```text
taskview-community/
├── api/
│ └── TaskView backend and HTTP API
├── web/
│ └── Vue web application and Capacitor mobile application
├── taskview-packages/
│ ├── taskview-api/
│ │ └── TypeScript API client
│ └── taskview-db-schemas/
│ │ └── Shared Drizzle ORM schemas
│ └── taskview-mcp/
│ └── MCP server for AI integrations
└── build-dockers.sh
└── Docker image build script
```
### Main packages
| Package | Description |
| --------------------------------------- | ----------------------------------------------- |
| `api` | Node.js backend and API server |
| `web` | Vue web client and Capacitor mobile application |
| `taskview-packages/taskview-api` | TypeScript client for the TaskView API |
| `taskview-packages/taskview-db-schemas` | Shared Drizzle ORM database schemas |
| `taskview-packages/taskview-mcp` | MCP server for AI integrations |
## Technology stack
TaskView is primarily built with:
* Vue
* Nuxt UI
* TypeScript
* Node.js
* Express
* PostgreSQL
* Drizzle ORM
* Capacitor
* Docker
## Local development
These instructions are intended for contributors and local development, not for production deployment.
### Prerequisites
You need the following tools:
* Docker
* PostgreSQL
* Bun
* pnpm
On macOS, PostgreSQL can be installed using [Postgres.app](https://postgresapp.com/).
### Install dependencies
From the repository root:
**Steps:**
1. Install dependencies:
```sh
pnpm install
```
2. Create an `.env` file in the **api** directory based on `.env.example` and configure your local credentials.
3. Run the web application:
### Configure the API
Create an environment file in the `api` directory:
```sh
cp api/.env.example api/.env
```
Update `api/.env` with your local database credentials and application configuration.
### Start the web application
```sh
cd web
pnpm dev
```
1. Run the API locally (API uses Bun in development mode):
### Start the API
In a separate terminal:
```sh
cd api
pnpm start
```
## Building Docker Images
The API uses Bun in the local development environment.
You can build Docker images using the provided bash script. The image version is automatically taken from the root `package.json`.
## Building Docker images
Docker image versions are taken from the root `package.json`.
### Build the images
From the repository root:
**Steps:**
1. Build images:
```sh
./build-dockers.sh
```
2. Verify built images:
### Verify the images
```sh
docker images
```
You should see the following images:
`gimanhead/taskview-ce-api-server`
`gimanhead/taskview-ce-webapp`
`gimanhead/taskview-ce-db-migration`
The following images should be available:
3. Test images locally using Docker Compose:
```sh
cd api/dev-containers-test
docker-compose up
```text
gimanhead/taskview-ce-api-server
gimanhead/taskview-ce-webapp
gimanhead/taskview-ce-db-migration
```
Make sure the image versions match the version defined in the root package.json.
### Test the images locally
```sh
cd api/dev-containers-test
docker compose up
```
Ensure that image versions match the version defined in the root `package.json`.
## Versioning
TaskView follows [Semantic Versioning](https://semver.org/):
* `MAJOR` - breaking API, database, or compatibility changes;
* `MINOR` - backward-compatible features;
* `PATCH` - backward-compatible bug fixes.
The version represents the entire TaskView product, not individual packages.
## Project status
TaskView is under active development.
New features, database migrations, API changes, and breaking changes may be introduced between releases. Review release notes and back up your database before updating a self-hosted installation.
## Roadmap
Planned areas of development include:
- [X] Migrate to NuxtUI or similar ui library
- [X] Enterprise SSO and identity integrations
- [X] Redesign
- [X] API tokens
- [X] Webhooks
- [X] MCP server
- [X] Notifications
- [X] Analytics
- [X] API client
- [ ] Desktop version
- [ ] Plugin / extension system
* [ ] Desktop application
* [ ] Plugin and extension system
* [ ] Additional third-party integrations
* [ ] Expanded automation capabilities
* [ ] Continued UI and UX improvements
The roadmap may change based on product priorities and community feedback.
Note for contributors: contributions are accepted under the CLA (see CONTRIBUTING.md). The Project is distributed under the TaskView Source-Available License.
## Contributing
Contributions are welcome.
Before contributing:
1. Read [`CONTRIBUTING.md`](./CONTRIBUTING.md).
2. Check existing issues and pull requests.
3. Open an issue before starting a large architectural change.
4. Follow the project's code style and architecture.
5. Sign the Contributor License Agreement when required.
Contributions are accepted under the project's CLA.
## Licensing
TaskView is distributed under the TaskView Source-Available License.
You may:
* self-host TaskView;
* modify the source code;
* use TaskView internally within your organization;
* create integrations and internal extensions.
You may not:
* offer TaskView as a hosted SaaS;
* sell access to TaskView as a service;
* create a competing commercial product based on TaskView;
* use the TaskView name, logo, or branding for derivative products.
See [`LICENSE`](./LICENSE) for the complete terms.
TaskView is **source-available**, not OSI-approved open-source software.
For commercial licensing questions, hosted service permissions, or other use cases not covered by the license, contact the project maintainer.
## Security
Do not publish security vulnerabilities in public GitHub issues.
Report security issues privately using the contact information provided in the repository or on the TaskView website.
When running TaskView in production:
* use HTTPS;
* generate unique application secrets;
* restrict database access;
* keep Docker images and dependencies updated;
* configure persistent storage;
* create regular backups;
* restrict API-token permissions to the minimum required scope.
## Links
* [TaskView website](https://taskview.tech/)
* [TaskView Cloud](https://app.taskview.tech/)
* [Documentation](https://taskview.tech/docs/)
* [iOS application](https://apps.apple.com/lk/app/taskview-todo-list-tasks/id6499107867)
* [Android application](https://play.google.com/store/apps/details?id=com.handscreamgnl.taskview.app)
* [GitHub repository](https://github.com/Gimanh/taskview-community)
---
TaskView is developed and maintained by Nikolai Giman.
TaskView is developed and maintained by **Nikolai Giman**.
Copyright © 2026 Nikolai Giman
TaskView™ and the TaskView logo are trademarks of their respective owner.
Copyright © 2026 Nikolai Giman.
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "taskview-ce-api-server",
"version": "1.45.0",
"version": "1.48.7",
"scripts": {
"dev": "bun run --watch ./server.ts",
"start": "NODE_ENV=production node ./dist/taskview-server.js",
+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;
@@ -28,7 +28,7 @@ export class AnalyticsController {
scope = { kind: out.scope }
}
const range = resolveRange(out.period, out.from, out.to)
const range = resolveRange({ period: out.period, timezone: out.timezone, from: out.from, to: out.to })
if (!range) return res.status(400).send('invalid range')
const sectionIds = out.sections
@@ -41,6 +41,7 @@ export class AnalyticsController {
organizationId: out.organizationId,
period: out.period,
range,
timezone: out.timezone,
sectionIds,
})
return res.tvJson(data)
@@ -76,7 +77,7 @@ export class AnalyticsController {
scope = { kind: out.scope }
}
const range = resolveRange(out.period, out.from, out.to)
const range = resolveRange({ period: out.period, timezone: out.timezone, from: out.from, to: out.to })
if (!range) return res.status(400).send('invalid range')
const meta = parseDrillDownMeta(out.meta)
@@ -10,6 +10,7 @@ import type { AppUser } from '../../core/AppUser'
import { $logger } from '../../modules/logget'
import { GoalPermissions } from '../../types/auth.types'
import { AnalyticsRepository } from './AnalyticsRepository'
import { formatDateInZone } from './helpers'
import { SectionRegistry } from './sections/SectionRegistry'
import type {
AnalyticsArgBuildSections,
@@ -45,7 +46,7 @@ export class AnalyticsManager {
}
async buildSections(params: AnalyticsArgBuildSections): Promise<AnalyticsSectionsResponse> {
const { scope, organizationId, period, range, sectionIds } = params
const { scope, organizationId, period, range, timezone, sectionIds } = params
const allAccessible = await this.getAccessibleGoalIds(organizationId)
const accessibleGoalIds = this.narrowToScope(allAccessible, scope)
@@ -86,7 +87,7 @@ export class AnalyticsManager {
return {
scope,
period,
range: { from: range.from.toISOString(), to: range.to.toISOString() },
range: { from: formatDateInZone(range.from, timezone), to: formatDateInZone(range.to, timezone) },
sections,
availableGoals,
failedSectionIds,
+43 -23
View File
@@ -1,7 +1,8 @@
import { type } from 'arktype'
import { sql, type SQL } from 'drizzle-orm'
import { DateTime } from 'luxon'
import type { AnalyticsPeriod } from 'taskview-api'
import { DrillDownMetaArkType, type AnalyticsRange, type DrillDownMeta } from './types'
import { DrillDownMetaArkType, type AnalyticsRange, type DrillDownMeta, type ResolveRangeArgs } from './types'
const MAX_INT32 = 2147483647
@@ -23,32 +24,51 @@ export function parseDrillDownMeta(raw: string | undefined): DrillDownMeta {
}
}
export function resolveRange(
period: AnalyticsPeriod,
from?: string,
to?: string,
): AnalyticsRange | null {
const now = new Date()
const DAYS_BY_PERIOD: Record<Exclude<AnalyticsPeriod, 'custom' | 'month'>, number> = {
'7d': 7,
'30d': 30,
'90d': 90,
'180d': 180,
'365d': 365,
}
/**
* Resolve a period into a concrete UTC instant range, with all calendar
* boundaries ("this month", a custom day) computed in the *viewer's* IANA
* timezone — not the server's. The returned Dates are real instants used for
* SQL filtering; the user-facing 'YYYY-MM-DD' labels come from
* {@link formatDateInZone} with the same timezone.
*/
export function resolveRange(args: ResolveRangeArgs): AnalyticsRange | null {
const { period, from, to } = args
const zone = args.timezone && DateTime.now().setZone(args.timezone).isValid ? args.timezone : 'utc'
const now = DateTime.now().setZone(zone)
if (period === 'custom') {
if (!from || !to) return null
const fromDate = new Date(from)
const toDate = new Date(to)
if (Number.isNaN(fromDate.getTime()) || Number.isNaN(toDate.getTime())) return null
if (fromDate > toDate) return null
const maxRangeMs = 365 * 24 * 60 * 60 * 1000
if (toDate.getTime() - fromDate.getTime() > maxRangeMs) return null
return { from: fromDate, to: toDate }
const fromDt = DateTime.fromISO(from.slice(0, 10), { zone }).startOf('day')
const toDt = DateTime.fromISO(to.slice(0, 10), { zone }).endOf('day')
if (!fromDt.isValid || !toDt.isValid) return null
if (fromDt > toDt) return null
const maxRangeMs = 366 * 24 * 60 * 60 * 1000
if (toDt.toMillis() - fromDt.toMillis() > maxRangeMs) return null
return { from: fromDt.toJSDate(), to: toDt.toJSDate() }
}
const daysByPeriod: Record<Exclude<AnalyticsPeriod, 'custom'>, number> = {
'7d': 7,
'30d': 30,
'90d': 90,
'180d': 180,
'365d': 365,
if (period === 'month') {
return { from: now.startOf('month').toJSDate(), to: now.toJSDate() }
}
const days = daysByPeriod[period]
const fromDate = new Date(now.getTime() - days * 24 * 60 * 60 * 1000)
return { from: fromDate, to: now }
return { from: now.minus({ days: DAYS_BY_PERIOD[period] }).toJSDate(), to: now.toJSDate() }
}
/**
* Format a range boundary as a 'YYYY-MM-DD' calendar day in the viewer's
* timezone. Slicing a UTC ISO string instead would shift the day across the
* date line for offset timezones (e.g. a "this month" range rendering from the
* 31st of the prior month).
*/
export function formatDateInZone(date: Date, timezone?: string): string {
const dt = DateTime.fromJSDate(date).setZone(timezone && DateTime.fromJSDate(date).setZone(timezone).isValid ? timezone : 'utc')
return dt.toISODate() as string
}
+14 -2
View File
@@ -20,8 +20,9 @@ const nonNegativeIntFromQuery = type('string | number')
export const AnalyticsFetchSectionsArkType = type({
scope: "'org' | 'project'",
organizationId: positiveIntFromQuery,
period: "'7d' | '30d' | '90d' | '180d' | '365d' | 'custom'",
period: "'month' | '7d' | '30d' | '90d' | '180d' | '365d' | 'custom'",
'goalId?': positiveIntFromQuery,
'timezone?': 'string',
'from?': 'string',
'to?': 'string',
'sections?': 'string',
@@ -30,8 +31,9 @@ export const AnalyticsFetchSectionsArkType = type({
export const AnalyticsDrillDownArkType = type({
scope: "'org' | 'project'",
organizationId: positiveIntFromQuery,
period: "'7d' | '30d' | '90d' | '180d' | '365d' | 'custom'",
period: "'month' | '7d' | '30d' | '90d' | '180d' | '365d' | 'custom'",
'goalId?': positiveIntFromQuery,
'timezone?': 'string',
'from?': 'string',
'to?': 'string',
'bucket?': 'string',
@@ -53,6 +55,14 @@ export type AnalyticsRange = {
to: Date
}
export type ResolveRangeArgs = {
period: AnalyticsPeriod
/** IANA timezone the period boundaries are resolved in; defaults to UTC. */
timezone?: string
from?: string
to?: string
}
export type BuilderContext = {
appUser: AppUser
scope: AnalyticsScope
@@ -97,6 +107,8 @@ export type AnalyticsArgBuildSections = {
organizationId: number
period: AnalyticsPeriod
range: AnalyticsRange
/** IANA timezone of the viewer; used to format the response range. */
timezone?: string
sectionIds?: string[]
}
@@ -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;
};
@@ -28,6 +28,8 @@ export class NotificationDispatcher implements Dispatcher {
eventBus.on('task.updated', (data) => this.onTaskUpdated(data));
eventBus.on('task.assigneesChanged', (data) => this.onAssigneesChanged(data));
eventBus.on('task.deleted', (data) => this.onTaskDeleted(data));
eventBus.on('recurrence.created', (data) => this.onRecurrenceScheduled(data));
eventBus.on('recurrence.updated', (data) => this.onRecurrenceScheduled(data));
}
async registerWorkers(): Promise<void> {
@@ -79,6 +81,19 @@ export class NotificationDispatcher implements Dispatcher {
}
}
private async onRecurrenceScheduled(data: AppEvents['recurrence.created'] | AppEvents['recurrence.updated']): Promise<void> {
const instance = await new RecurrenceRepository().findOpenInstance(data.rule.id);
if (!instance) return;
this.notificationsRepo.deleteByTaskAndType(instance.id, NotificationType.DEADLINE);
await this.deadlineScheduler.cancel(instance.id);
if (data.rule.notifyOnOccurrence && instance.endDate) {
$logger.info(`[NotificationDispatcher] Scheduling occurrence deadline for task=${instance.id}, rule=${data.rule.id}`);
await this.deadlineScheduler.schedule(instance, data.initiatorId);
}
}
private async onAssigneesChanged(data: AppEvents['task.assigneesChanged']): Promise<void> {
if (data.userIds.length === 0) return;
@@ -230,6 +230,22 @@ export class RecurrenceManager {
const updated = await this.repository.patch({ ruleId: args.ruleId, patch });
if (!updated) return fail('invalid_state');
if (patch.dtstart !== undefined || patch.timezone !== undefined || patch.rrule !== undefined) {
const openInstance = await this.repository.findOpenInstance(args.ruleId);
if (openInstance?.recurrenceInstanceDate) {
await this.repository.updateInstanceWindow({
taskId: openInstance.id,
window: RecurrenceParser.instanceWindowUtc({
occurrenceDate: openInstance.recurrenceInstanceDate,
dtstart: updated.dtstart,
hasTime: updated.hasTime,
timezone: updated.timezone,
durationMinutes: updated.templateDurationMinutes,
}),
});
}
}
eventBus.emit('recurrence.updated', { rule: updated, changes: patch, initiatorId: this.initiatorId });
return ok(await this.cleanRuleFieldsRegardPermissions(updated));
}
@@ -19,6 +19,7 @@ import type {
CreateRuleWithOriginResult,
RecurrenceRulePatchArgs,
RemoveTemplateAssigneeFromGoalArgs,
UpdateInstanceWindowArgs,
} from './types';
const PG_UNIQUE_VIOLATION = '23505';
@@ -194,6 +195,21 @@ export class RecurrenceRepository {
return result?.[0] ?? null;
}
/** Re-stamp an open instance's deadline window after the series time/timezone changed. */
async updateInstanceWindow(args: UpdateInstanceWindowArgs): Promise<void> {
await callWithCatch(() =>
this.db.dbDrizzle
.update(TasksSchema)
.set({
startDate: args.window.startDate,
startTime: args.window.startTime,
endDate: args.window.endDate,
endTime: args.window.endTime,
})
.where(eq(TasksSchema.id, args.taskId))
);
}
/** Active rules with no open instance — the reconcile sweep input. Empty in normal operation. */
async findStalledActiveRules(): Promise<RecurrenceRulesSchemaTypeForSelect[]> {
const openInstance = this.db.dbDrizzle
+5
View File
@@ -81,6 +81,11 @@ export type InstanceWindow = {
/** Repository args */
export type UpdateInstanceWindowArgs = {
taskId: number;
window: InstanceWindow;
};
export type RecurrenceRulePatchArgs = {
ruleId: number;
patch: Partial<{
+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))
@@ -36,6 +36,7 @@ export class TaskItemForClient {
amount: number | null;
transactionType: 1 | 0 | null;
nodeGraphPosition: Record<string, unknown> | null;
recurrenceRuleId: number | null;
constructor(task: TaskItemInDb, tags: number[] = [], historyId: number | null = null, assignees: number[] = []) {
this.id = task.id;
@@ -69,5 +70,6 @@ export class TaskItemForClient {
this.amount = task.amount;
this.transactionType = task.transaction_type;
this.nodeGraphPosition = task.node_graph_position;
this.recurrenceRuleId = task.recurrence_rule_id;
}
}
+1
View File
@@ -26,6 +26,7 @@ export type TaskItemInDb = {
amount: number | null;
transaction_type: 1 | 0 | null;
node_graph_position: Record<string, unknown> | null;
recurrence_rule_id: number | null;
// history_id?: number | null;
};
Binary file not shown.

After

Width:  |  Height:  |  Size: 583 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 558 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1012 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 879 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1019 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 984 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1005 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 927 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1018 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 998 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 994 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 928 KiB

+45
View File
@@ -0,0 +1,45 @@
---
title: MCP Server
description: Connect AI assistants like Claude Code and Claude Desktop to TaskView via the Model Context Protocol (MCP). Manage projects and tasks with a scoped API token.
navigation:
icon: i-lucide-bot
---
TaskView ships an MCP (Model Context Protocol) server that lets AI assistants such as Claude Code and Claude Desktop work with your projects and tasks through the TaskView API.
```
AI client ──stdio──▶ taskview-mcp ──HTTPS──▶ TaskView API
```
## Prerequisites
- Node.js >= 24
- A TaskView API token (`tvk_...`) — create one in your account settings. See [API tokens](/docs/features/api-tokens).
## Configuration
No installation is required — the server runs via `npx`. Add it to your MCP client config:
```json
{
"mcpServers": {
"taskview": {
"command": "npx",
"args": ["-y", "taskview-mcp"],
"env": {
"TASKVIEW_URL": "https://api.taskview.tech",
"TASKVIEW_TOKEN": "tvk_your_token_here"
}
}
}
}
```
- **Claude Code** — add to `.claude/settings.json` (project) or `~/.claude.json` (global).
- **Claude Desktop** — add to `claude_desktop_config.json`.
Set `TASKVIEW_URL` to your own instance when self-hosting.
## Permissions
The assistant can only do what the API token allows. Token permissions are scoped to selected projects and intersected with your RBAC role, so an AI client never exceeds your own access. Grant the minimum scope needed.
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "taskview-ce-monorepo",
"version": "1.45.0",
"version": "1.48.7",
"private": true,
"description": "TaskView CE monorepo containing web, API, and packages",
"workspaces": [
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "taskview-api",
"private": false,
"version": "1.45.0",
"version": "1.48.3",
"type": "module",
"repository": {
"type": "git",
@@ -35,6 +35,7 @@ export default class TvAnalyticsApi extends TvApiBase {
organizationId: String(arg.organizationId),
period: arg.period,
}
if (arg.timezone) params.timezone = arg.timezone
if (arg.from) params.from = arg.from
if (arg.to) params.to = arg.to
if (arg.sections?.length) params.sections = arg.sections.join(',')
@@ -53,6 +54,7 @@ export default class TvAnalyticsApi extends TvApiBase {
organizationId: String(arg.organizationId),
period: arg.period,
}
if (arg.timezone) params.timezone = arg.timezone
if (arg.from) params.from = arg.from
if (arg.to) params.to = arg.to
if (arg.bucket) params.bucket = arg.bucket
@@ -13,7 +13,7 @@ export type AnalyticsScope =
| { kind: 'org' }
| { kind: 'project'; goalId: number }
export type AnalyticsPeriod = '7d' | '30d' | '90d' | '180d' | '365d' | 'custom'
export type AnalyticsPeriod = 'month' | '7d' | '30d' | '90d' | '180d' | '365d' | 'custom'
export type AnalyticsRange = {
from: string
@@ -136,6 +136,8 @@ export type AnalyticsFetchSectionsArg = {
scope: AnalyticsScope
organizationId: number
period: AnalyticsPeriod
/** IANA timezone of the viewer; period boundaries are resolved in it. */
timezone?: string
from?: string
to?: string
sections?: string[]
@@ -165,6 +167,8 @@ export type AnalyticsFetchDrillDownArg = {
scope: AnalyticsScope
organizationId: number
period: AnalyticsPeriod
/** IANA timezone of the viewer; period boundaries are resolved in it. */
timezone?: string
from?: string
to?: string
bucket?: string
@@ -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;
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "taskview-mcp",
"version": "1.45.0",
"version": "1.48.3",
"description": "MCP (Model Context Protocol) server for TaskView — lets AI assistants (Claude Code, Claude Desktop, etc.) manage projects and tasks via the TaskView API",
"type": "module",
"bin": {
@@ -107,7 +107,7 @@ export function registerOrganizationsTools(server: McpServer, api: TvApi) {
server.registerTool(
'add_organization_member',
{
description: 'Add a member to an organization by email. Requires admin or owner role.',
description: 'Add a member to an organization by email. Requires admin or owner role. NOTE: organization membership alone does NOT grant access to any project — the user will not see a project until they are also added to it via invite_collaborator. After adding the member, invite them to the relevant project(s).',
inputSchema: {
organizationId: z.coerce.number().describe('Organization ID'),
email: z.string().describe('Email of the user to add'),
@@ -81,7 +81,7 @@ export function registerTasksTools(server: McpServer, api: TvApi) {
server.registerTool(
'update_task',
{
description: 'Update an existing task (description, status, priority, dates, etc.)',
description: 'Update an existing task (description, status, priority, dates, cost, etc.)',
inputSchema: {
id: z.coerce.number().describe('Task ID to update'),
description: z.string().optional().describe('New task description'),
@@ -93,6 +93,10 @@ export function registerTasksTools(server: McpServer, api: TvApi) {
startDate: z.string().optional().nullable().describe('Start date (ISO format)'),
endDate: z.string().optional().nullable().describe('End/due date (ISO format)'),
statusId: z.coerce.number().optional().nullable().describe('Kanban column status ID'),
amount: z.coerce.string().optional().nullable()
.describe('Cost/value of the task (decimal as string, e.g. "1500.00"). Pass null to clear.'),
transactionType: z.union([z.literal(1), z.literal(0)]).optional().nullable()
.describe('Cost type: 1=income, 0=expense, null=none'),
},
},
async (params) => {
@@ -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
@@ -7,8 +7,8 @@ android {
applicationId "com.handscreamgnl.taskview.app"
minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 1420
versionName "1.42.0"
versionCode 14801
versionName "1.48.1"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
aaptOptions {
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.
+4 -4
View File
@@ -304,7 +304,7 @@
CODE_SIGN_ALLOW_ENTITLEMENTS_MODIFICATION = YES;
CODE_SIGN_ENTITLEMENTS = App/App.entitlements;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1.42.0;
CURRENT_PROJECT_VERSION = 1.48.1;
DEVELOPMENT_TEAM = H2W2SG48JT;
INFOPLIST_FILE = App/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
@@ -312,7 +312,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 1.42.0;
MARKETING_VERSION = 1.48.1;
OTHER_SWIFT_FLAGS = "$(inherited) \"-D\" \"COCOAPODS\" \"-DDEBUG\"";
PRODUCT_BUNDLE_IDENTIFIER = com.handscream.taskview.app;
PRODUCT_NAME = "$(TARGET_NAME)";
@@ -329,7 +329,7 @@
CODE_SIGN_ALLOW_ENTITLEMENTS_MODIFICATION = YES;
CODE_SIGN_ENTITLEMENTS = App/App.entitlements;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1.42.0;
CURRENT_PROJECT_VERSION = 1.48.1;
DEVELOPMENT_TEAM = H2W2SG48JT;
INFOPLIST_FILE = App/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
@@ -337,7 +337,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 1.42.0;
MARKETING_VERSION = 1.48.1;
PRODUCT_BUNDLE_IDENTIFIER = com.handscream.taskview.app;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "";
+1 -1
View File
@@ -2,7 +2,7 @@
"name": "web-nuxt-ui",
"private": true,
"type": "module",
"version": "1.45.0",
"version": "1.48.7",
"scripts": {
"dev": "vite",
"build:packages": "pnpm --filter taskview-db-schemas build && pnpm --filter taskview-api build",
+2
View File
@@ -9,6 +9,8 @@
@theme {
--tv-ui-bg-elevated: #fff;
--color-tv-ui-bg-elevated: var(--tv-ui-bg-elevated);
--radius-10: 10px;
--radius-14: 14px;
}
/* Safe area utilities */
+5 -5
View File
@@ -23,10 +23,10 @@
<UModal
v-model:open="isOpen"
:fullscreen="isMobile"
:fullscreen="isFullscreenModal"
:ui="{
overlay: 'sm:items-center sm:justify-center',
content: 'sm:max-w-lg sm:max-h-[80vh] sm:rounded-lg sm:m-auto w-full',
content: isFullscreenModal ? 'w-full' : 'sm:max-w-lg sm:max-h-[80vh] sm:m-auto w-full',
body: 'p-0!',
footer: 'p-4!',
}"
@@ -91,7 +91,7 @@
>
<UButton
:label="t('notifications.loadMore')"
variant="ghost"
variant="soft"
color="neutral"
size="xs"
block
@@ -107,7 +107,7 @@
<UButton
:label="t('common.close')"
color="neutral"
variant="outline"
variant="soft"
@click="isOpen = false"
/>
</div>
@@ -133,7 +133,7 @@ const router = useRouter()
const notificationsStore = useNotificationsStore()
const userStore = useUserStore()
const orgStore = useOrganizationStore()
const { isMobile } = useTaskView()
const { isFullscreenModal } = useTaskView()
const isOpen = ref(false)
const notificationIconMap: Partial<Record<NotificationType, string>> = {
+38 -2
View File
@@ -65,6 +65,7 @@ import { saveLocale } from '@/plugins/i18n'
import { useUpdater } from '@/composables/useUpdater'
import { useUserStore } from '@/stores/user.store'
import { useOrganizationStore } from '@/stores/organization.store'
import { useOrgSwitcher } from '@/composables/useOrgSwitcher'
import { $ls } from '@/plugins/axios'
import avatarImg from '@/assets/images/avatar-1.jpeg'
@@ -79,6 +80,7 @@ const router = useRouter()
const toast = useToast()
const userStore = useUserStore()
const orgStore = useOrganizationStore()
const { switchOrg } = useOrgSwitcher()
const prodOrDev = ref<'prod' | 'dev'>('prod')
let counter = 0
@@ -155,8 +157,7 @@ const items = computed<DropdownMenuItem[][]>(() => [
icon: org.id === orgStore.currentOrg?.id ? 'i-lucide-check' : undefined,
onSelect(e: Event) {
e.preventDefault()
orgStore.setCurrentOrg(org)
router.push({ name: 'user', params: { orgSlug: org.slug } })
switchOrg(org)
},
})),
},
@@ -260,6 +261,15 @@ const items = computed<DropdownMenuItem[][]>(() => [
saveLocale('de')
},
},
{
label: 'Español',
type: 'checkbox',
checked: locale.value === 'es',
onSelect(e: Event) {
e.preventDefault()
saveLocale('es')
},
},
],
},
{
@@ -288,6 +298,32 @@ const items = computed<DropdownMenuItem[][]>(() => [
},
],
},
{
label: t('userMenu.sidebarView'),
icon: 'i-lucide-panel-left',
children: [
{
label: t('userMenu.sidebarViewSecond'),
icon: 'i-lucide-layout-list',
type: 'checkbox',
checked: appStore.sidebarView === 'second',
onSelect(e: Event) {
e.preventDefault()
appStore.setSidebarView('second')
},
},
{
label: t('userMenu.sidebarViewFirst'),
icon: 'i-lucide-panel-left',
type: 'checkbox',
checked: appStore.sidebarView === 'first',
onSelect(e: Event) {
e.preventDefault()
appStore.setSidebarView('first')
},
},
],
},
],
[
{
@@ -2,15 +2,15 @@
<div class="flex flex-col gap-6 p-1 lg:p-6 w-full max-w-full lg:max-w-2xl m-0 lg:mx-auto">
<NotificationSettings />
<UPageCard class="w-full">
<UPageCard class="w-full rounded-3xl">
<SessionsPanel />
</UPageCard>
<UPageCard class="w-full">
<UPageCard class="w-full rounded-3xl">
<ApiTokensPanel />
</UPageCard>
<UPageCard class="w-full">
<UPageCard class="w-full rounded-3xl">
<div class="flex flex-col gap-4">
<h2 class="text-lg font-semibold">
{{ t('account.management') }}
@@ -12,6 +12,8 @@
v-model="code"
:placeholder="t('account.enterCode')"
spellcheck="false"
variant="soft"
:ui="{ base: 'rounded-xl' }"
/>
<div class="flex justify-end gap-2">
@@ -1,5 +1,5 @@
<template>
<UPageCard class="w-full">
<UPageCard class="w-full rounded-3xl">
<div class="flex flex-col gap-4">
<div>
<h2 class="text-lg font-semibold">
@@ -5,15 +5,46 @@
:items="scopeOptions"
value-key="value"
class="sm:w-64"
size="xl"
@update:model-value="onScopeChange"
/>
<USelectMenu
:model-value="analyticsStore.period"
:items="periodOptions"
value-key="value"
class="sm:w-40"
@update:model-value="onPeriodChange"
/>
<UPopover v-model:open="periodOpen">
<UButton
icon="i-lucide-calendar"
:label="activatorLabel"
color="neutral"
variant="soft"
class="sm:w-auto justify-start"
:ui="{ leadingIcon: 'size-4.5' }"
/>
<template #content>
<div class="p-2 flex flex-col gap-2 w-72">
<div class="flex flex-wrap gap-1.5">
<UButton
v-for="opt in periodOptions"
:key="opt.value"
:label="opt.label"
color="neutral"
:variant="analyticsStore.period === opt.value ? 'solid' : 'soft'"
size="xs"
@click="onPresetClick(opt.value)"
/>
</div>
<USeparator />
<UCalendar
:model-value="rangeModel"
range
:week-starts-on="weekStart"
@update:model-value="onRangeSelect"
/>
</div>
</template>
</UPopover>
<UButton
icon="i-lucide-refresh-cw"
variant="ghost"
@@ -26,14 +57,24 @@
<script setup lang="ts">
import type { AnalyticsPeriod, AnalyticsScope } from 'taskview-api'
import { computed } from 'vue'
import { computed, ref, shallowRef, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { useDateFormat } from '@vueuse/core'
import { CalendarDate } from '@internationalized/date'
import type { DateValue } from '@internationalized/date'
import { useAnalyticsStore } from '@/stores/analytics.store'
import { useWeekStart } from '@/composables/useWeekStart'
const analyticsStore = useAnalyticsStore()
const { t } = useI18n()
const weekStart = useWeekStart()
const periodOpen = ref(false)
type DateRange = { start: DateValue | undefined; end: DateValue | undefined }
const periodOptions = computed<{ label: string; value: AnalyticsPeriod }[]>(() => [
{ label: t('analytics.filters.periods.month'), value: 'month' },
{ label: t('analytics.filters.periods.7d'), value: '7d' },
{ label: t('analytics.filters.periods.30d'), value: '30d' },
{ label: t('analytics.filters.periods.90d'), value: '90d' },
@@ -41,6 +82,55 @@ const periodOptions = computed<{ label: string; value: AnalyticsPeriod }[]>(() =
{ label: t('analytics.filters.periods.365d'), value: '365d' },
])
function isoToCalendarDate(iso: string): CalendarDate {
const [year, month, day] = iso.slice(0, 10).split('-').map(Number)
return new CalendarDate(year, month, day)
}
// Parse a 'YYYY-MM-DD' as a local date (new Date(iso) would treat it as UTC).
function localDate(iso: string): Date {
const [year, month, day] = iso.slice(0, 10).split('-').map(Number)
return new Date(year, month - 1, day)
}
// Seed the calendar with the active range so the current selection is visible.
function rangeFromStore(): DateRange {
const r = analyticsStore.range
if (!r) return { start: undefined, end: undefined }
return { start: isoToCalendarDate(r.from), end: isoToCalendarDate(r.to) }
}
const rangeModel = shallowRef<DateRange>(rangeFromStore())
// Reflect the resolved range whenever it changes (preset clicks, refresh).
watch(() => analyticsStore.range, () => {
rangeModel.value = rangeFromStore()
})
// User picked a range in the calendar → switch to a custom period.
function onRangeSelect(value: DateRange | null) {
rangeModel.value = value ?? { start: undefined, end: undefined }
if (!value?.start || !value?.end) return
analyticsStore.setCustomRange(value.start.toString().slice(0, 10), value.end.toString().slice(0, 10))
periodOpen.value = false
}
const activatorLabel = computed(() => {
if (analyticsStore.period !== 'custom') {
return periodOptions.value.find(o => o.value === analyticsStore.period)?.label ?? ''
}
const r = analyticsStore.range
if (!r) return t('analytics.filters.periods.custom')
const from = useDateFormat(localDate(r.from), 'DD MMM YYYY').value
const to = useDateFormat(localDate(r.to), 'DD MMM YYYY').value
return `${from}${to}`
})
function onPresetClick(value: AnalyticsPeriod) {
analyticsStore.setPeriod(value)
periodOpen.value = false
}
type ScopeOption = { label: string; value: string; scope: AnalyticsScope }
const scopeOptions = computed<ScopeOption[]>(() => {
@@ -65,10 +155,4 @@ function onScopeChange(value: string) {
const option = scopeOptions.value.find(o => o.value === value)
if (option) analyticsStore.setScope(option.scope)
}
function onPeriodChange(value: AnalyticsPeriod) {
analyticsStore.setPeriod(value)
}
</script>
@@ -1,5 +1,5 @@
<template>
<div class="flex items-center justify-between p-4 border border-default rounded-lg">
<UCard variant="soft" :ui="{body:'w-full flex items-center justify-between p-4', root:'rounded-2xl'}">
<div class="flex items-center gap-3 min-w-0">
<UIcon
name="i-lucide-key-round"
@@ -32,10 +32,10 @@
icon="i-lucide-trash-2"
variant="ghost"
color="error"
size="md"
size="xl"
@click="showDeleteConfirm = true"
/>
</div>
</UCard>
<UModal
v-model:open="showDeleteConfirm"
@@ -40,9 +40,9 @@
<UModal
v-model:open="showAddDialog"
:fullscreen="isMobile"
:fullscreen="isFullscreenModal"
:ui="{
content: 'sm:max-w-md sm:rounded-lg sm:m-auto w-full',
content: isFullscreenModal ? 'w-full' : 'sm:max-w-md sm:rounded-lg sm:m-auto w-full',
footer: 'p-4!',
}"
>
@@ -106,8 +106,8 @@ import { useBreakpoints, breakpointsTailwind } from '@vueuse/core'
import { useAdditionalServer } from '@/composables/useAdditionalServer'
const { t } = useI18n()
const bp = useBreakpoints(breakpointsTailwind)
const isMobile = bp.smaller('lg')
const bp = useBreakpoints({ ...breakpointsTailwind, fullscreenModalMax: 1366 })
const isFullscreenModal = bp.smallerOrEqual('fullscreenModalMax')
const mainServer = ref('')
const allServers = ref<string[]>([])
@@ -0,0 +1,173 @@
<template>
<UPopover
v-model:open="open"
:content="{ align: 'start' }"
:ui="{ content: 'rounded-2xl' }"
>
<UButton
:disabled="disabled"
color="neutral"
variant="soft"
block
size="xl"
icon="i-lucide-calendar"
:trailing-icon="open ? 'i-lucide-chevron-up' : 'i-lucide-chevron-down'"
:ui="activatorUi('text-muted')"
>
<span class="flex-1 flex items-center gap-2 text-left font-medium">
<template v-if="isRange">
<span :class="model?.start ? '' : 'text-muted'">{{ startLabel }}</span>
<UIcon
name="i-lucide-arrow-right"
class="size-3.5 shrink-0 text-dimmed"
/>
<span :class="model?.end ? '' : 'text-muted'">{{ endLabel }}</span>
</template>
<span
v-else
:class="model?.end ? '' : 'text-muted'"
>{{ endLabel }}</span>
</span>
</UButton>
<template #content>
<div class="flex flex-col gap-3 p-3">
<div class="flex flex-wrap gap-2">
<UButton
v-for="q in quickRanges"
:key="q.key"
color="neutral"
variant="soft"
size="xs"
:label="q.label"
@click="applyQuick(q.key)"
/>
</div>
<UCalendar
v-if="isRange"
v-model="rangeModel"
range
:week-starts-on="weekStart"
/>
<UCalendar
v-else
v-model="singleModel"
:week-starts-on="weekStart"
/>
<div class="flex items-center justify-between gap-2 pt-1">
<UCheckbox
v-model="isRange"
size="md"
:ui="{ root: 'items-center' }"
:label="t('deadline.selectRange')"
/>
<UButton
v-if="model?.start || model?.end"
icon="i-lucide-x"
:label="t('common.delete')"
color="error"
variant="ghost"
size="sm"
@click="clear"
/>
</div>
</div>
</template>
</UPopover>
</template>
<script setup lang="ts">
import { ref, computed, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { useDateFormat } from '@vueuse/core'
import {
type CalendarDate,
today,
getLocalTimeZone,
startOfWeek,
endOfWeek,
startOfMonth,
endOfMonth,
} from '@internationalized/date'
import { useWeekStart } from '@/composables/useWeekStart'
import { useNuxtUiTaskItemStyles } from '@/composables/useNuxtUiTaskItemStyles'
import type { DateRangeValue } from '@/types/tvDate.types'
const model = defineModel<DateRangeValue>()
const { disabled = false } = defineProps<{
disabled?: boolean
}>()
const { t } = useI18n()
const weekStart = useWeekStart()
const { activatorUi } = useNuxtUiTaskItemStyles()
const open = ref(false)
const isRange = ref(false)
// drop the start date when switching back to single-date mode
watch(isRange, (on) => {
if (!on && model.value?.start) {
model.value = { start: undefined, end: model.value.end }
}
})
// single-date mode fills the END date (the deadline)
const singleModel = computed<CalendarDate | undefined>({
get: () => model.value?.end,
set: (date) => {
model.value = { start: undefined, end: date }
if (date) open.value = false
},
})
const rangeModel = computed<DateRangeValue>({
get: () => ({ start: model.value?.start, end: model.value?.end }),
set: (value) => {
model.value = value ?? null
if (value?.start && value?.end) open.value = false
},
})
const format = (date?: CalendarDate): string =>
date ? useDateFormat(new Date(date.toString()), 'DD MMM').value : ''
const startLabel = computed(() => model.value?.start ? format(model.value.start) : t('deadline.startDate'))
const endLabel = computed(() => {
if (model.value?.end) return format(model.value.end)
return isRange.value ? t('deadline.endDate') : t('tasks.deadline')
})
type QuickKey = 'today' | 'tomorrow' | 'week' | 'month'
const quickRanges = computed<{ key: QuickKey, label: string }[]>(() => [
{ key: 'today', label: t('deadline.today') },
{ key: 'tomorrow', label: t('deadline.tomorrow') },
{ key: 'week', label: t('deadline.thisWeek') },
{ key: 'month', label: t('deadline.thisMonth') },
])
function applyQuick(key: QuickKey) {
const t0 = today(getLocalTimeZone())
if (key === 'today') commit(t0, t0)
else if (key === 'tomorrow') {
const d = t0.add({ days: 1 })
commit(d, d)
}
else if (key === 'week') commit(startOfWeek(t0, 'ru-RU'), endOfWeek(t0, 'ru-RU'))
else commit(startOfMonth(t0), endOfMonth(t0))
}
// in single mode keep only the end date; in range mode keep both
function commit(start: CalendarDate, end: CalendarDate) {
model.value = isRange.value ? { start, end } : { start: undefined, end }
open.value = false
}
function clear() {
model.value = null
}
</script>
@@ -0,0 +1,47 @@
<template>
<TvDateRange
:model-value="range"
:disabled="!canEditTaskDeadline"
@update:model-value="onUpdate"
/>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import type { Task } from 'taskview-api'
import { CalendarDate } from '@internationalized/date'
import TvDateRange from '@/components/features/base/TvDateRange.vue'
import { useTasksStore } from '@/stores/tasks.store'
import { useGoalPermissions } from '@/composables/useGoalPermissions'
import type { DateRangeValue } from '@/types/tvDate.types'
const props = defineProps<{
task: Task
}>()
const tasksStore = useTasksStore()
const { canEditTaskDeadline } = useGoalPermissions()
function parse(dateStr: string | null | undefined): CalendarDate | undefined {
if (!dateStr) return undefined
const [year, month, day] = dateStr.split('-').map(Number)
if (!year || !month || !day) return undefined
return new CalendarDate(year, month, day)
}
const range = computed<DateRangeValue>(() => ({
start: parse(props.task.startDate),
end: parse(props.task.endDate),
}))
async function onUpdate(value: DateRangeValue | undefined) {
const v = value ?? null
await tasksStore.saveDateForTask({
id: props.task.id,
startDate: v?.start ? v.start.toString() : null,
startTime: v?.start ? props.task.startTime : null,
endDate: v?.end ? v.end.toString() : null,
endTime: v?.end ? props.task.endTime : null,
})
}
</script>
@@ -0,0 +1,98 @@
<template>
<UPopover
v-if="withActivator"
v-model:open="open"
:content="{ align: 'start' }"
:ui="{ content: 'rounded-2xl' }"
>
<UButton
:disabled="disabled"
color="neutral"
variant="soft"
block
size="xl"
:icon="activatorIcon || (multiple ? placeholderIcon : current?.icon) || placeholderIcon"
:trailing-icon="open ? 'i-lucide-chevron-up' : 'i-lucide-chevron-down'"
:ui="activatorUi((multiple ? undefined : current?.iconClass) ?? 'text-muted')"
>
<span
class="flex-1 text-left truncate min-w-0"
:class="hasSelection ? '' : 'text-muted'"
:style="!multiple && current?.color ? { color: current.color } : undefined"
>{{ activatorLabel }}</span>
</UButton>
<template #content>
<div class="p-2 min-w-56">
<TvDropdownOptions
v-model="model"
:items="items"
:disabled="disabled"
:multiple="multiple"
@update:model-value="onSelect"
/>
</div>
</template>
</UPopover>
<TvDropdownOptions
v-else
v-model="model"
:items="items"
:disabled="disabled"
:multiple="multiple"
/>
</template>
<script setup lang="ts" generic="V, T = V">
import { ref, computed } from 'vue'
import TvDropdownOptions from '@/components/features/base/TvDropdownOptions.vue'
import { useNuxtUiTaskItemStyles } from '@/composables/useNuxtUiTaskItemStyles'
import type { TvDropdownOption } from '@/types/tvDropdown.types'
const model = defineModel<T>()
const {
items,
withActivator = true,
disabled = false,
placeholder = '',
placeholderIcon = '',
activatorIcon = '',
multiple = false,
} = defineProps<{
items: TvDropdownOption<V>[]
withActivator?: boolean
disabled?: boolean
placeholder?: string
placeholderIcon?: string
/** Fixed activator icon — overrides the selected option's icon. */
activatorIcon?: string
multiple?: boolean
}>()
const { activatorUi } = useNuxtUiTaskItemStyles()
const open = ref(false)
const current = computed(() => items.find(o => o.value === (model.value as unknown as V)))
const selectedOptions = computed(() => {
if (Array.isArray(model.value)) {
const selected = model.value as V[]
return items.filter(o => selected.includes(o.value))
}
return current.value ? [current.value] : []
})
const hasSelection = computed(() => selectedOptions.value.length > 0)
const activatorLabel = computed(() => {
if (multiple) {
return selectedOptions.value.length
? selectedOptions.value.map(o => o.label).join(', ')
: placeholder
}
return current.value?.label ?? placeholder
})
function onSelect() {
if (!multiple) open.value = false
}
</script>
@@ -0,0 +1,79 @@
<template>
<div class="flex flex-col gap-1">
<UButton
v-for="opt in items"
:key="String(opt.value)"
:disabled="disabled"
color="neutral"
variant="ghost"
block
size="sm"
:icon="opt.icon"
class="items-center justify-between gap-3"
:ui="dropdownItemUi(`${opt.iconClass ?? ''} size-4.5`)"
:class="!opt.color && isSelected(opt.value) ? 'bg-primary/10' : ''"
:style="opt.color && isSelected(opt.value) ? { backgroundColor: opt.color + '14' } : undefined"
@click="select(opt.value)"
>
<span class="flex-1 flex flex-col items-start text-left gap-0.5">
<span class="text-base text-highlighted leading-tight">{{ opt.label }}</span>
<span
v-if="opt.description"
class="text-xs text-muted leading-tight"
>{{ opt.description }}</span>
</span>
<template #trailing>
<span
v-if="isSelected(opt.value)"
class="flex items-center justify-center size-5.5 rounded-full shrink-0"
:class="opt.color ? '' : 'bg-primary'"
:style="opt.color ? { backgroundColor: opt.color } : undefined"
>
<UIcon
name="i-lucide-check"
class="size-4 text-white"
/>
</span>
<span
v-else
class="size-5.5 rounded-full border-2 border-default shrink-0"
/>
</template>
</UButton>
</div>
</template>
<script setup lang="ts" generic="V, T = V">
import { useNuxtUiTaskItemStyles } from '@/composables/useNuxtUiTaskItemStyles'
import type { TvDropdownOption } from '@/types/tvDropdown.types'
const { dropdownItemUi } = useNuxtUiTaskItemStyles()
const model = defineModel<T>()
const { items, disabled = false, multiple = false } = defineProps<{
items: TvDropdownOption<V>[]
disabled?: boolean
multiple?: boolean
}>()
function isSelected(value: V): boolean {
return Array.isArray(model.value)
? (model.value as V[]).includes(value)
: (model.value as unknown as V) === value
}
function select(value: V) {
if (disabled) return
if (multiple) {
const current = Array.isArray(model.value) ? [...(model.value as V[])] : []
const index = current.indexOf(value)
if (index === -1) current.push(value)
else current.splice(index, 1)
model.value = current as unknown as T
} else {
model.value = value as unknown as T
}
}
</script>
@@ -1,7 +1,7 @@
<template>
<UPageCard
v-bind="{ ...props }"
class="shadow-sm"
class="shadow-sm rounded-xl"
:class="{ 'bg-primary/10 border border-primary/30': active }"
>
<div class="flex items-center justify-between">
@@ -0,0 +1,27 @@
<template>
<TvDropdown
v-model="model"
:items="options"
:with-activator="withActivator"
:disabled="disabled"
:placeholder="t('tasks.selectStatus')"
placeholder-icon="i-lucide-columns-3"
activator-icon="i-lucide-columns-3"
/>
</template>
<script setup lang="ts">
import { useI18n } from 'vue-i18n'
import TvDropdown from '@/components/features/base/TvDropdown.vue'
import { useKanbanStatusOptions, type StatusValue } from '@/composables/useKanbanStatusOptions'
const model = defineModel<StatusValue>()
const { withActivator = true, disabled = false } = defineProps<{
withActivator?: boolean
disabled?: boolean
}>()
const { t } = useI18n()
const { options } = useKanbanStatusOptions()
</script>
@@ -0,0 +1,26 @@
<template>
<TvDropdown
v-model="model"
:items="options"
:with-activator="withActivator"
:disabled="disabled"
:placeholder="t('tasks.noList')"
placeholder-icon="i-lucide-inbox"
/>
</template>
<script setup lang="ts">
import { useI18n } from 'vue-i18n'
import TvDropdown from '@/components/features/base/TvDropdown.vue'
import { useGoalListOptions, type ListValue } from '@/composables/useGoalListOptions'
const model = defineModel<ListValue>()
const { withActivator = true, disabled = false } = defineProps<{
withActivator?: boolean
disabled?: boolean
}>()
const { t } = useI18n()
const { options } = useGoalListOptions()
</script>
@@ -1,20 +1,21 @@
<template>
<nav class="fixed bottom-0 left-0 right-0 z-50 lg:hidden border-t border-default pb-safe bg-default">
<div class="flex items-center justify-between min-h-14">
<div class="flex items-stretch justify-around min-h-16 gap-1 p-1.5">
<UButton
v-for="item in navItems"
:key="item.label"
:key="item.key"
:icon="item.icon"
:to="item.to"
color="neutral"
variant="ghost"
size="lg"
class="flex-1 h-full rounded-none"
:class="{ 'text-primary': item.active?.() }"
:label="item.label"
:color="item.active() ? 'primary' : 'neutral'"
:variant="item.active() ? 'soft' : 'ghost'"
block
class="flex-1 min-w-0"
:ui="{
base: ' justify-center',
base: 'flex-col gap-1 py-1.5 rounded-xl',
leadingIcon: 'size-5',
label: 'text-[11px] leading-none truncate max-w-full',
}"
@click="item.onClick?.()"
@click="onItemClick(item)"
/>
</div>
</nav>
@@ -22,89 +23,104 @@
<script setup lang="ts">
import { computed } from 'vue'
import { useRoute } from 'vue-router'
import { useRoute, useRouter, type RouteLocationRaw } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { useDashboard } from '@/composables/useDashboard'
import { useAppRouteInfo } from '@/composables/useAppRouteInfo'
import { ALL_TASKS_LIST_ID } from 'taskview-api'
import { useGoalsStore } from '@/stores/goals.store'
import { useGoalPermissionsFor } from '@/composables/useGoalPermissions'
type NavItem = {
key: string
label: string
icon: string
to?: RouteLocationRaw
active: () => boolean
onClick?: () => void
}
const route = useRoute()
const router = useRouter()
const { t } = useI18n()
const { isSidebarOpen } = useDashboard()
const { isUserRoute, isAccountRoute, hasProject, projectId, hasList } = useAppRouteInfo()
const { isUserRoute, isAccountRoute, hasProject, projectId } = useAppRouteInfo()
const goalsStore = useGoalsStore()
const navItems = computed(() => {
if (isAccountRoute.value) {
return [
{
label: t('main'),
icon: 'i-lucide-house',
to: { name: 'user' },
active: (): boolean => false,
},
]
}
const currentGoal = computed(() =>
hasProject.value ? goalsStore.goalMap.get(projectId.value) ?? null : null,
)
const { canViewKanban, canViewGraph } = useGoalPermissionsFor(currentGoal)
if (isUserRoute.value && !hasProject.value) {
return [
{
label: t('projects.title'),
icon: 'i-lucide-folder',
active: (): boolean => isSidebarOpen.value,
onClick: () => { isSidebarOpen.value = !isSidebarOpen.value },
},
{
label: t('account.title'),
icon: 'i-lucide-settings',
to: { name: 'account' },
active: (): boolean => false,
},
]
}
return [
// A stable core (Home · Projects · … · Account) keeps tab positions predictable
// across routes; project shortcuts (Kanban/Graph) only slot in when inside a
// project and the user is allowed to see them.
const navItems = computed<NavItem[]>(() => {
const items: NavItem[] = [
{
key: 'home',
label: t('main'),
icon: 'i-lucide-house',
to: { name: 'user' },
active: (): boolean => false,
active: () => isUserRoute.value && !hasProject.value,
},
{
key: 'projects',
label: t('projects.title'),
icon: 'i-lucide-folder',
active: (): boolean => isSidebarOpen.value,
icon: 'i-lucide-panel-left',
active: () => isSidebarOpen.value,
onClick: () => { isSidebarOpen.value = !isSidebarOpen.value },
},
{
label: t('projects.kanban'),
icon: 'i-lucide-kanban',
to: { name: 'kanban', params: { projectId: projectId.value } },
active: (): boolean => route.name === 'kanban',
},
{
label: t('projects.graph'),
icon: 'i-lucide-git-fork',
to: { name: 'graph', params: { projectId: projectId.value } },
active: (): boolean => route.name === 'graph',
},
{
label: t('projects.collaboration'),
icon: 'i-lucide-users',
to: { name: 'collaboration', params: { projectId: projectId.value } },
active: (): boolean => route.name === 'collaboration',
},
{
label: t('lists.title'),
icon: 'i-lucide-list',
to: { name: 'user', params: { projectId: projectId.value } },
active: (): boolean => isUserRoute.value && hasProject.value && !hasList.value,
},
{
label: t('tasks.allTasks'),
icon: 'i-lucide-list-checks',
to: { name: 'user', params: { projectId: projectId.value, listId: ALL_TASKS_LIST_ID } },
active: (): boolean => isUserRoute.value && hasList.value,
},
]
if (hasProject.value) {
if (canViewKanban.value) {
items.push({
key: 'kanban',
label: t('contextMenu.kanban'),
icon: 'i-lucide-kanban',
to: { name: 'kanban', params: { projectId: projectId.value } },
active: () => route.name === 'kanban',
})
}
if (canViewGraph.value) {
items.push({
key: 'graph',
label: t('contextMenu.graph'),
icon: 'i-lucide-git-fork',
to: { name: 'graph', params: { projectId: projectId.value } },
active: () => route.name === 'graph',
})
}
items.push({
key: 'list',
label: t('contextMenu.tasks'),
icon: 'i-lucide-list',
to: { name: 'user', params: { projectId: projectId.value, listId: ALL_TASKS_LIST_ID } },
active: () => isUserRoute.value && hasProject.value,
})
}
// Inside a project the bar fills up with project shortcuts (List/Kanban/Graph),
// so drop Settings there — it stays reachable from the global tabs.
if (!hasProject.value) {
items.push({
key: 'account',
label: t('account.nav'),
icon: 'i-lucide-settings',
to: { name: 'account' },
active: () => isAccountRoute.value,
})
}
return items
})
function onItemClick(item: NavItem) {
if (item.onClick) {
item.onClick()
return
}
if (item.to) router.push(item.to)
}
</script>
@@ -0,0 +1,26 @@
<template>
<TvDropdown
v-model="model"
:items="options"
:with-activator="withActivator"
:disabled="disabled"
:placeholder="t('tasks.priority')"
placeholder-icon="i-lucide-flag"
/>
</template>
<script setup lang="ts">
import { useI18n } from 'vue-i18n'
import TvDropdown from '@/components/features/base/TvDropdown.vue'
import { usePriorityOptions, type PriorityValue } from '@/composables/usePriorityOptions'
const model = defineModel<PriorityValue>()
const { withActivator = true, disabled = false } = defineProps<{
withActivator?: boolean
disabled?: boolean
}>()
const { t } = useI18n()
const { options } = usePriorityOptions()
</script>
@@ -0,0 +1,84 @@
<template>
<TvDropdown
v-if="canView"
:model-value="currentSprintId"
:items="options"
:disabled="!canAssign"
:placeholder="t('tasks.fields.sprint')"
placeholder-icon="i-lucide-rocket"
activator-icon="i-lucide-rocket"
@update:model-value="selectSprint"
/>
</template>
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import type { Sprint } from 'taskview-api'
import { $tvApi } from '@/plugins/axios'
import { useTasksStore } from '@/stores/tasks.store'
import { useGoalsStore } from '@/stores/goals.store'
import { useGoalPermissionsFor } from '@/composables/useGoalPermissions'
import { logError } from '@/helpers/Helper'
import TvDropdown from '@/components/features/base/TvDropdown.vue'
import type { TvDropdownOption } from '@/types/tvDropdown.types'
const props = defineProps<{
taskId: number
goalId: number
currentSprintId: number | null
}>()
const { t } = useI18n()
const toast = useToast()
const tasksStore = useTasksStore()
const goalsStore = useGoalsStore()
const goal = computed(() => goalsStore.goalMap.get(props.goalId) ?? null)
const { canViewSprints: canView, canAssignSprintTasks: canAssign } = useGoalPermissionsFor(goal)
const sprints = ref<Sprint[]>([])
const loadedGoalId = ref<number | null>(null)
const options = computed<TvDropdownOption<number | null>[]>(() => [
{ value: null, label: t('tasks.sprintNone'), icon: 'i-lucide-rocket', iconClass: 'text-dimmed' },
...sprints.value
.filter(s => s.status !== 'completed')
.map(s => ({
value: s.id,
label: s.name,
description: s.status ? t(`sprints.status.${s.status}`) : undefined,
icon: 'i-lucide-rocket',
iconClass: 'text-dimmed',
})),
])
async function loadSprints() {
if (loadedGoalId.value === props.goalId) return
const result = await $tvApi.sprints.listForGoal({ goalId: props.goalId }).catch(logError)
if (!result) return
sprints.value = result
loadedGoalId.value = props.goalId
}
async function selectSprint(value: number | null | undefined) {
const sprintId = value ?? null
if (sprintId === props.currentSprintId) return
const result = await $tvApi.sprints
.setTaskSprint({ taskId: props.taskId, sprintId })
.catch(logError)
if (!result) {
toast.add({ title: t('tasks.sprintUpdateFailed'), color: 'error' })
return
}
const selected = tasksStore.selectedTask
if (selected && selected.id === props.taskId) {
selected.sprintId = sprintId
}
}
watch(() => props.goalId, loadSprints, { immediate: true })
</script>
@@ -16,6 +16,7 @@
v-model="activeTab"
:items="tabs"
class="w-full"
:ui="{ list: 'rounded-2xl', trigger: 'rounded-xl', indicator: 'rounded-xl' }"
>
<template #members>
<MembersList
@@ -46,13 +46,13 @@
<UButton
:label="t('common.cancel')"
color="neutral"
variant="outline"
variant="soft"
@click="isOpen = false"
/>
<UButton
:label="t('common.save')"
color="primary"
variant="outline"
variant="soft"
@click="handleSave"
/>
</div>
@@ -1,5 +1,5 @@
<template>
<div class="flex items-center justify-between p-3 rounded-lg border border-default hover:bg-elevated transition-colors">
<div class="flex items-center justify-between p-3 rounded-2xl border border-default hover:bg-elevated transition-colors">
<div class="flex items-center gap-3">
<UAvatar
:alt="member.email"
@@ -14,13 +14,13 @@
<UButton
:label="t('common.cancel')"
color="neutral"
variant="outline"
variant="soft"
@click="isOpen = false"
/>
<UButton
:label="t('contextMenu.delete')"
color="error"
variant="outline"
variant="soft"
@click="handleDelete"
/>
</div>
@@ -39,7 +39,7 @@
<UButton
:label="t('common.done')"
color="primary"
variant="outline"
variant="soft"
@click="isOpen = false"
/>
</div>
@@ -1,5 +1,5 @@
<template>
<div class="flex items-center justify-between p-3 rounded-lg border border-default hover:bg-elevated transition-colors">
<div class="flex items-center justify-between p-3 rounded-2xl border border-default hover:bg-elevated transition-colors">
<div class="flex items-center gap-2">
<UIcon
name="i-lucide-shield"
@@ -29,14 +29,14 @@
<div class="flex justify-end gap-2">
<UButton
color="neutral"
variant="outline"
variant="soft"
@click="close"
>
{{ t('common.cancel') }}
</UButton>
<UButton
:disabled="!taskName.trim()"
variant="outline"
variant="soft"
@click="addTask"
>
{{ t('tasks.add') }}
@@ -6,7 +6,8 @@
:placeholder="t('kanban.addColumn')"
icon="i-lucide-plus"
size="xl"
class="h-full max-w-[340px] min-w-[272px] bg-neutral-800 flex flex-col w-[91.666667%] rounded-lg"
variant="soft"
class="h-full max-w-[340px] min-w-[272px] flex flex-col w-[91.666667%] rounded-lg"
/>
<UButton
@@ -24,13 +24,14 @@
<div class="flex justify-end gap-2">
<UButton
color="neutral"
variant="outline"
variant="soft"
@click="model = false"
>
{{ t('common.cancel') }}
</UButton>
<UButton
color="error"
variant="soft"
@click="confirmDelete"
>
{{ t('common.delete') }}
@@ -10,7 +10,9 @@
<UInput
v-model="formData.name"
:placeholder="t('lists.namePlaceholder')"
variant="soft"
class="w-full"
:ui="{ base: 'rounded-xl' }"
/>
</UFormField>
</div>
@@ -7,11 +7,30 @@
<template #leading>
<TvCollapseSidebarDesktop />
</template>
<template #right>
<UButtonGroup size="md">
<UButton
icon="i-lucide-list"
:color="view === 'list' ? 'primary' : 'neutral'"
:variant="view === 'list' ? 'soft' : 'ghost'"
square
@click="view = 'list'"
/>
<UButton
icon="i-lucide-layout-grid"
:color="view === 'widgets' ? 'primary' : 'neutral'"
:variant="view === 'widgets' ? 'soft' : 'ghost'"
square
@click="view = 'widgets'"
/>
</UButtonGroup>
</template>
</UDashboardNavbar>
</template>
<template #body>
<DesktopScreen />
<DesktopScreen v-if="view === 'widgets'" />
<DashboardList v-else />
</template>
</UDashboardPanel>
</template>
@@ -19,7 +38,23 @@
<script setup lang="ts">
import { useI18n } from 'vue-i18n'
import TvCollapseSidebarDesktop from '@/components/features/base/TvCollapseSidebarDesktop.vue'
import { useLsRef } from '@/composables/useLsRef'
import { useRefreshOnResume } from '@/composables/useRefreshOnResume'
import { useBaseScreenStore } from '@/stores/base-screen.store'
import { useCollaborationStore } from '@/stores/collaboration.store'
import DesktopScreen from './screen-main/DesktopScreen.vue'
import DashboardList from './screen-main/DashboardList.vue'
const { t } = useI18n()
const view = useLsRef<'list' | 'widgets'>('main-screen-view', 'list')
const baseScreenStore = useBaseScreenStore()
const collaborationStore = useCollaborationStore()
useRefreshOnResume(() => {
baseScreenStore.fetchAllState()
baseScreenStore.fetchAllAvailableLists()
collaborationStore.fetchAllCollaborationUsers()
})
</script>
@@ -0,0 +1,99 @@
<template>
<div class="overflow-auto flex flex-col">
<div class="sticky top-0 p-2 lg:p-5 z-10 bg-default">
<div class="w-full max-w-3xl mx-auto">
<SearchActivator />
</div>
</div>
<div class="flex flex-col p-2 md:p-5 md:pt-0 w-full max-w-3xl mx-auto gap-5">
<DashboardSection
icon="i-lucide-sunrise"
accent="indigo"
:title="t('msg.todayTasks')"
:subtitle="t('msg.todayExplanation')"
:add-title="t('msg.addTaskToToday')"
:tasks="todayPending"
:completed-tasks="todayCompleted"
section-key="today"
@toggle="(task) => toggle(task, 'tasksToday')"
/>
<DashboardSection
icon="i-lucide-clock"
accent="sky"
:title="t('msg.upcomingTasks')"
:subtitle="t('msg.upcomingExplanation')"
:add-title="t('msg.addTaskToUpcoming')"
:tasks="upcomingPending"
:completed-tasks="upcomingCompleted"
upcoming-task
groupable
section-key="upcoming"
@toggle="(task) => toggle(task, 'tasksUpcoming')"
/>
<DashboardSection
icon="i-lucide-inbox"
accent="violet"
:title="t('msg.lastTasks')"
:subtitle="t('msg.lastAddedTasksExplanation')"
:add-title="t('msg.addTask')"
:tasks="recentTasks"
:completed-tasks="recentCompleted"
no-dates
tabbed
section-key="recent"
:show-progress="false"
@toggle="toggleRecent"
/>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import SearchActivator from './parts/SearchActivator.vue'
import DashboardSection from './DashboardList/parts/DashboardSection.vue'
import type { TaskItem } from '@/types/tasks.types'
import { useBaseScreenStore } from '@/stores/base-screen.store'
import { useCollaborationStore } from '@/stores/collaboration.store'
import { useOrganizationStore } from '@/stores/organization.store'
const { t } = useI18n()
const baseScreenStore = useBaseScreenStore()
const collaborationStore = useCollaborationStore()
const orgStore = useOrganizationStore()
const todayPending = computed(() => baseScreenStore.tasksToday.filter(task => !task.complete))
const todayCompleted = computed(() => baseScreenStore.tasksToday.filter(task => task.complete))
const upcomingPending = computed(() => baseScreenStore.tasksUpcoming.filter(task => !task.complete))
const upcomingCompleted = computed(() => baseScreenStore.tasksUpcoming.filter(task => task.complete))
const recentTasks = computed(() => baseScreenStore.tasks)
const recentCompleted = computed(() => baseScreenStore.tasksLastCompleted)
function toggle(task: TaskItem, prop: 'tasksToday' | 'tasksUpcoming') {
baseScreenStore.updateTaskStatusNew({ status: !task.complete, taskId: task.id }, prop)
}
function toggleRecent(task: TaskItem, bucket: 'pending' | 'completed') {
baseScreenStore.updateTaskStatusNew(
{ status: !task.complete, taskId: task.id },
bucket === 'completed' ? 'tasksLastCompleted' : 'tasks',
)
}
function loadData() {
Promise.allSettled([
baseScreenStore.fetchAllState(),
baseScreenStore.fetchAllAvailableLists(),
collaborationStore.fetchAllCollaborationUsers(),
])
}
loadData()
watch(() => orgStore.currentOrg, loadData)
</script>
@@ -0,0 +1,55 @@
<template>
<MainScreenAddTaskDialog
v-model="open"
:title="title"
:disabled="disabled"
:upcoming-task="upcomingTask"
:no-dates="noDates"
>
<template #activator="props">
<UButton
v-if="compact"
v-bind="props"
icon="i-lucide-plus"
color="neutral"
variant="soft"
size="sm"
:disabled="disabled"
:ui="{ base: 'rounded-full hover:bg-primary/10 hover:text-primary' }"
@click.stop
/>
<button
v-else
v-bind="props"
type="button"
:disabled="disabled"
class="flex items-center gap-2.5 w-full px-4 py-3 rounded-2xl border border-dashed border-default text-muted hover:text-primary hover:border-primary hover:bg-primary/5 transition-colors disabled:opacity-50 disabled:cursor-not-allowed cursor-pointer"
>
<span class="flex items-center justify-center size-6 rounded-full border border-dashed border-current shrink-0">
<UIcon
name="i-lucide-plus"
class="size-3.5"
/>
</span>
<span class="text-[15px]">{{ t('msg.addTask') }}</span>
</button>
</template>
</MainScreenAddTaskDialog>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { useI18n } from 'vue-i18n'
import MainScreenAddTaskDialog from '@/components/features/main/screen-main/parts/MainScreenAddTaskDialog.vue'
defineProps<{
title: string
disabled?: boolean
upcomingTask?: boolean
noDates?: boolean
compact?: boolean
}>()
const { t } = useI18n()
const open = ref(false)
</script>
@@ -0,0 +1,251 @@
<template>
<div class="relative">
<!-- Timeline connecting line under the icon -->
<div class="absolute left-6 top-14 bottom-0 w-px bg-accented hidden sm:block" />
<UCollapsible
v-model:open="expanded"
:ui="{ content: 'p-0 pl-0 sm:pl-16 pb-8' }"
>
<DashboardSectionHeader
:icon="icon"
:accent="accentClasses"
:title="title"
:subtitle="subtitle"
:expanded="expanded"
:show-progress="showProgress"
:done-count="doneCount"
:total-count="totalCount"
:progress-percent="progressPercent"
:add-title="addTitle"
:add-disabled="!hasActiveGoals"
:upcoming-task="upcomingTask"
:no-dates="noDates"
:groupable="groupable"
:grouped="grouped"
@toggle-group="grouped = !grouped"
/>
<template #content>
<!-- Tabbed mode: split active / completed into tabs (used by Recent) -->
<UTabs
v-if="tabbed"
v-model="activeTab"
:items="tabItems"
variant="pill"
size="md"
class="w-full pt-3"
:ui="{ list: 'rounded-full', trigger: 'rounded-full', indicator: 'rounded-full' }"
>
<template #active>
<div class="flex flex-col gap-2 pt-3">
<TaskItem
v-for="task in tasks"
:key="task.id"
:task="task"
@toggle="$emit('toggle', $event, 'pending')"
/>
<p
v-if="!tasks.length"
class="text-sm text-muted text-center py-4"
>
{{ t('msg.noTasks') }}
</p>
<DashboardAddTask
:title="addTitle"
:disabled="!hasActiveGoals"
:no-dates="noDates"
/>
</div>
</template>
<template #completed>
<div class="flex flex-col gap-2 pt-3">
<TaskItem
v-for="task in completedTasks"
:key="task.id"
:task="task"
@toggle="$emit('toggle', $event, 'completed')"
/>
<p
v-if="!completedTasks.length"
class="text-sm text-muted text-center py-4"
>
{{ t('msg.noTasks') }}
</p>
</div>
</template>
</UTabs>
<!-- Default stacked mode: pending list + add + completed group -->
<div
v-else
class="flex flex-col gap-3 pt-3"
>
<div
v-if="groupable && grouped && tasks.length"
class="flex flex-col gap-4"
>
<div
v-for="group in taskGroups"
:key="group.key"
class="flex flex-col gap-2"
>
<div class="flex items-center gap-3 px-1">
<span class="text-sm text-highlighted opacity-80 font-medium">
{{ group.label }}
</span>
<span class="text-xs text-muted">{{ group.tasks.length }}</span>
<div class="flex-1 h-px bg-default" />
</div>
<TaskItem
v-for="task in group.tasks"
:key="task.id"
:task="task"
@toggle="$emit('toggle', $event, 'pending')"
/>
</div>
</div>
<div
v-else-if="tasks.length"
class="flex flex-col gap-2"
>
<TaskItem
v-for="task in tasks"
:key="task.id"
:task="task"
@toggle="$emit('toggle', $event, 'pending')"
/>
</div>
<DashboardAddTask
:title="addTitle"
:disabled="!hasActiveGoals"
:upcoming-task="upcomingTask"
:no-dates="noDates"
/>
<div
v-if="completedTasks.length"
class="flex flex-col gap-1 mt-2"
>
<div class="flex items-center gap-3 px-1">
<span class="text-xs font-semibold uppercase tracking-wide text-success">
{{ t('msg.completed') }} · {{ completedTasks.length }}
</span>
<div class="flex-1 h-px bg-default" />
</div>
<TaskItem
v-for="task in completedTasks"
:key="task.id"
:task="task"
@toggle="$emit('toggle', $event, 'completed')"
/>
</div>
</div>
</template>
</UCollapsible>
</div>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import type { TabsItem } from '@nuxt/ui'
import type { TaskItem as TaskItemType } from '@/types/tasks.types'
import { useTaskView } from '@/composables/useTaskView'
import { useLsRef } from '@/composables/useLsRef'
import TaskItem from '@/components/features/tasks/parts/TaskItem.vue'
import DashboardAddTask from './DashboardAddTask.vue'
import DashboardSectionHeader from './DashboardSectionHeader.vue'
type Accent = 'indigo' | 'sky' | 'violet'
type TaskBucket = 'pending' | 'completed'
const props = withDefaults(defineProps<{
icon: string
accent: Accent
title: string
subtitle: string
addTitle: string
tasks: TaskItemType[]
completedTasks: TaskItemType[]
upcomingTask?: boolean
noDates?: boolean
showProgress?: boolean
tabbed?: boolean
groupable?: boolean
sectionKey: string
}>(), {
showProgress: true,
})
defineEmits<{
toggle: [task: TaskItemType, bucket: TaskBucket]
}>()
const { t, locale } = useI18n()
const { hasActiveGoals } = useTaskView()
const grouped = useLsRef(`taskview:dashboard:${props.sectionKey}:grouped`, true)
const NO_DATE_KEY = '~'
function dateKey(date: Date): string {
const pad = (n: number) => String(n).padStart(2, '0')
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`
}
function groupLabel(key: string): string {
const date = new Date(`${key}T00:00:00`)
const day = new Intl.DateTimeFormat(locale.value, { day: 'numeric' }).format(date)
const month = new Intl.DateTimeFormat(locale.value, { month: 'short' }).format(date)
const weekday = new Intl.DateTimeFormat(locale.value, { weekday: 'long' }).format(date)
const formatted = `${day} ${month} ${weekday}`
return formatted.charAt(0).toUpperCase() + formatted.slice(1)
}
const taskGroups = computed(() => {
const groups = new Map<string, TaskItemType[]>()
for (const task of props.tasks) {
const key = task.endDate ? dateKey(new Date(task.endDate)) : NO_DATE_KEY
if (!groups.has(key)) groups.set(key, [])
groups.get(key)!.push(task)
}
return [...groups.entries()]
.sort(([a], [b]) => a.localeCompare(b))
.map(([key, tasks]) => ({ key, label: key === NO_DATE_KEY ? t('msg.noDeadline') : groupLabel(key), tasks }))
})
const ACCENTS: Record<Accent, { bg: string; icon: string; bar: string }> = {
indigo: { bg: 'bg-indigo-500/15', icon: 'text-indigo-500', bar: 'bg-indigo-500' },
sky: { bg: 'bg-sky-500/15', icon: 'text-sky-500', bar: 'bg-sky-500' },
violet: { bg: 'bg-violet-500/15', icon: 'text-violet-500', bar: 'bg-violet-500' },
}
const accentClasses = computed(() => ACCENTS[props.accent])
const expanded = useLsRef(`taskview:dashboard:${props.sectionKey}:expanded`, true)
const activeTab = ref('active')
const tabItems = computed<TabsItem[]>(() => [
{
label: t('msg.lastAddedTasks'),
value: 'active',
slot: 'active',
badge: props.tasks.length.toString(),
},
{
label: t('msg.lastCompletedTasks'),
value: 'completed',
slot: 'completed',
badge: props.completedTasks.length.toString(),
},
])
const doneCount = computed(() => props.completedTasks.length)
const totalCount = computed(() => props.tasks.length + props.completedTasks.length)
const progressPercent = computed(() =>
totalCount.value === 0 ? 0 : Math.round((doneCount.value / totalCount.value) * 100),
)
</script>

Some files were not shown because too many files have changed in this diff Show More