Compare commits
11 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 9b08c82548 | |||
| 8eb94d0d2b | |||
| 78c910f789 | |||
| e1a8bb71a0 | |||
| f1ff303e58 | |||
| 22dc24f8e0 | |||
| 58e3cdcbb0 | |||
| 28e944f05e | |||
| 8f21a52ca6 | |||
| 96ac9f4d7a | |||
| 4c92aaf03d |
@@ -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."
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -5,6 +5,8 @@ ReleasesBuilds/*
|
||||
iconcreator
|
||||
*.prod.*
|
||||
.DS_Store
|
||||
.cursor
|
||||
.claude
|
||||
node_modules
|
||||
__APP_BUILD__
|
||||
ssl-create
|
||||
|
||||
@@ -1,162 +1,458 @@
|
||||
# TaskView™
|
||||
|
||||

|
||||
<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)
|
||||
[]()
|
||||
|
||||
[**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,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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -81,6 +81,11 @@ export type InstanceWindow = {
|
||||
|
||||
/** Repository args */
|
||||
|
||||
export type UpdateInstanceWindowArgs = {
|
||||
taskId: number;
|
||||
window: InstanceWindow;
|
||||
};
|
||||
|
||||
export type RecurrenceRulePatchArgs = {
|
||||
ruleId: number;
|
||||
patch: Partial<{
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
|
||||
|
After Width: | Height: | Size: 583 KiB |
|
After Width: | Height: | Size: 558 KiB |
|
After Width: | Height: | Size: 1.0 MiB |
|
After Width: | Height: | Size: 1012 KiB |
|
After Width: | Height: | Size: 879 KiB |
|
After Width: | Height: | Size: 1.4 MiB |
|
After Width: | Height: | Size: 1.4 MiB |
|
After Width: | Height: | Size: 1.1 MiB |
|
After Width: | Height: | Size: 1.1 MiB |
|
After Width: | Height: | Size: 1.1 MiB |
|
After Width: | Height: | Size: 1.1 MiB |
|
After Width: | Height: | Size: 1019 KiB |
|
After Width: | Height: | Size: 1.1 MiB |
|
After Width: | Height: | Size: 1.1 MiB |
|
After Width: | Height: | Size: 984 KiB |
|
After Width: | Height: | Size: 1005 KiB |
|
After Width: | Height: | Size: 927 KiB |
|
After Width: | Height: | Size: 1018 KiB |
|
After Width: | Height: | Size: 1.2 MiB |
|
After Width: | Height: | Size: 1.2 MiB |
|
After Width: | Height: | Size: 998 KiB |
|
After Width: | Height: | Size: 994 KiB |
|
After Width: | Height: | Size: 928 KiB |
@@ -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,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,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,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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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}"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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 1–3 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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
|
||||
@@ -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 = "";
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 */
|
||||
|
||||
@@ -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>> = {
|
||||
|
||||
@@ -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>
|
||||