Compare commits

...

6 Commits

Author SHA1 Message Date
Nikolai Giman 22dc24f8e0 fix: ui and recurring issues 2026-06-27 22:51:29 +02:00
Nikolai Giman 58e3cdcbb0 wip: mobile area 2026-06-21 23:47:43 +02:00
Nikolai Giman 28e944f05e wip: search width 2026-06-21 23:22:33 +02:00
Nikolai Giman 8f21a52ca6 wip: style 2026-06-21 23:18:01 +02:00
Nikolai Giman 96ac9f4d7a feat: ui improvements 2026-06-21 23:02:31 +02:00
Nikolai Giman 4c92aaf03d Merge pull request #66 from Gimanh/feat/recur-tasks
feat: recur tasks
2026-06-14 17:29:53 +02:00
155 changed files with 4979 additions and 948 deletions
+354 -97
View File
@@ -1,162 +1,419 @@
# TaskView™
![TaskView Logo](./assets/taskview-logo.png)
<p align="center">
<img src="./assets/taskview/kanban-dark.png" alt="TaskView logo" width="320">
</p>
TaskView is a self-hosted project and task management platform focused on clarity, ownership, and control.
TaskView is built for teams that want a transparent, self-hosted alternative to SaaS task managers.
<p align="center">
<strong>Self-hosted project management for software teams.</strong>
</p>
[![License](https://img.shields.io/badge/license-Source--Available-blue)](./LICENSE)
[![Active Development](https://img.shields.io/badge/status-active-brightgreen)]()
[**Live demo**](https://app.taskview.tech) · [**Documentation**](https://taskview.tech/docs/) · [**iOS**](https://apps.apple.com/lk/app/taskview-todo-list-tasks/id6499107867) · [**Android**](https://play.google.com/store/apps/details?id=com.handscreamgnl.taskview.app&hl=en)
<p align="center">
TaskView combines task management, custom workflows, developer integrations,
analytics, and AI-assisted automation in a platform you can run on your own infrastructure.
</p>
## Apps
* [Docs](https://taskview.tech/docs/)
* [Web](https://app.taskview.tech/)
* [iOS](https://apps.apple.com/lk/app/taskview-todo-list-tasks/id6499107867)
* [Android](https://play.google.com/store/apps/details?id=com.handscreamgnl.taskview.app&hl=en)
<details>
<summary><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>
<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.
See the [TaskView MCP documentation](https://taskview.tech/docs/integrations/mcp) for configuration examples.
## 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.
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "taskview-ce-api-server",
"version": "1.45.0",
"version": "1.48.1",
"scripts": {
"dev": "bun run --watch ./server.ts",
"start": "NODE_ENV=production node ./dist/taskview-server.js",
@@ -83,4 +83,4 @@
"engines": {
"node": ">=24 <25"
}
}
}
@@ -28,7 +28,7 @@ export class AnalyticsController {
scope = { kind: out.scope }
}
const range = resolveRange(out.period, out.from, out.to)
const range = resolveRange({ period: out.period, timezone: out.timezone, from: out.from, to: out.to })
if (!range) return res.status(400).send('invalid range')
const sectionIds = out.sections
@@ -41,6 +41,7 @@ export class AnalyticsController {
organizationId: out.organizationId,
period: out.period,
range,
timezone: out.timezone,
sectionIds,
})
return res.tvJson(data)
@@ -76,7 +77,7 @@ export class AnalyticsController {
scope = { kind: out.scope }
}
const range = resolveRange(out.period, out.from, out.to)
const range = resolveRange({ period: out.period, timezone: out.timezone, from: out.from, to: out.to })
if (!range) return res.status(400).send('invalid range')
const meta = parseDrillDownMeta(out.meta)
@@ -10,6 +10,7 @@ import type { AppUser } from '../../core/AppUser'
import { $logger } from '../../modules/logget'
import { GoalPermissions } from '../../types/auth.types'
import { AnalyticsRepository } from './AnalyticsRepository'
import { formatDateInZone } from './helpers'
import { SectionRegistry } from './sections/SectionRegistry'
import type {
AnalyticsArgBuildSections,
@@ -45,7 +46,7 @@ export class AnalyticsManager {
}
async buildSections(params: AnalyticsArgBuildSections): Promise<AnalyticsSectionsResponse> {
const { scope, organizationId, period, range, sectionIds } = params
const { scope, organizationId, period, range, timezone, sectionIds } = params
const allAccessible = await this.getAccessibleGoalIds(organizationId)
const accessibleGoalIds = this.narrowToScope(allAccessible, scope)
@@ -86,7 +87,7 @@ export class AnalyticsManager {
return {
scope,
period,
range: { from: range.from.toISOString(), to: range.to.toISOString() },
range: { from: formatDateInZone(range.from, timezone), to: formatDateInZone(range.to, timezone) },
sections,
availableGoals,
failedSectionIds,
+43 -23
View File
@@ -1,7 +1,8 @@
import { type } from 'arktype'
import { sql, type SQL } from 'drizzle-orm'
import { DateTime } from 'luxon'
import type { AnalyticsPeriod } from 'taskview-api'
import { DrillDownMetaArkType, type AnalyticsRange, type DrillDownMeta } from './types'
import { DrillDownMetaArkType, type AnalyticsRange, type DrillDownMeta, type ResolveRangeArgs } from './types'
const MAX_INT32 = 2147483647
@@ -23,32 +24,51 @@ export function parseDrillDownMeta(raw: string | undefined): DrillDownMeta {
}
}
export function resolveRange(
period: AnalyticsPeriod,
from?: string,
to?: string,
): AnalyticsRange | null {
const now = new Date()
const DAYS_BY_PERIOD: Record<Exclude<AnalyticsPeriod, 'custom' | 'month'>, number> = {
'7d': 7,
'30d': 30,
'90d': 90,
'180d': 180,
'365d': 365,
}
/**
* Resolve a period into a concrete UTC instant range, with all calendar
* boundaries ("this month", a custom day) computed in the *viewer's* IANA
* timezone — not the server's. The returned Dates are real instants used for
* SQL filtering; the user-facing 'YYYY-MM-DD' labels come from
* {@link formatDateInZone} with the same timezone.
*/
export function resolveRange(args: ResolveRangeArgs): AnalyticsRange | null {
const { period, from, to } = args
const zone = args.timezone && DateTime.now().setZone(args.timezone).isValid ? args.timezone : 'utc'
const now = DateTime.now().setZone(zone)
if (period === 'custom') {
if (!from || !to) return null
const fromDate = new Date(from)
const toDate = new Date(to)
if (Number.isNaN(fromDate.getTime()) || Number.isNaN(toDate.getTime())) return null
if (fromDate > toDate) return null
const maxRangeMs = 365 * 24 * 60 * 60 * 1000
if (toDate.getTime() - fromDate.getTime() > maxRangeMs) return null
return { from: fromDate, to: toDate }
const fromDt = DateTime.fromISO(from.slice(0, 10), { zone }).startOf('day')
const toDt = DateTime.fromISO(to.slice(0, 10), { zone }).endOf('day')
if (!fromDt.isValid || !toDt.isValid) return null
if (fromDt > toDt) return null
const maxRangeMs = 366 * 24 * 60 * 60 * 1000
if (toDt.toMillis() - fromDt.toMillis() > maxRangeMs) return null
return { from: fromDt.toJSDate(), to: toDt.toJSDate() }
}
const daysByPeriod: Record<Exclude<AnalyticsPeriod, 'custom'>, number> = {
'7d': 7,
'30d': 30,
'90d': 90,
'180d': 180,
'365d': 365,
if (period === 'month') {
return { from: now.startOf('month').toJSDate(), to: now.toJSDate() }
}
const days = daysByPeriod[period]
const fromDate = new Date(now.getTime() - days * 24 * 60 * 60 * 1000)
return { from: fromDate, to: now }
return { from: now.minus({ days: DAYS_BY_PERIOD[period] }).toJSDate(), to: now.toJSDate() }
}
/**
* Format a range boundary as a 'YYYY-MM-DD' calendar day in the viewer's
* timezone. Slicing a UTC ISO string instead would shift the day across the
* date line for offset timezones (e.g. a "this month" range rendering from the
* 31st of the prior month).
*/
export function formatDateInZone(date: Date, timezone?: string): string {
const dt = DateTime.fromJSDate(date).setZone(timezone && DateTime.fromJSDate(date).setZone(timezone).isValid ? timezone : 'utc')
return dt.toISODate() as string
}
+14 -2
View File
@@ -20,8 +20,9 @@ const nonNegativeIntFromQuery = type('string | number')
export const AnalyticsFetchSectionsArkType = type({
scope: "'org' | 'project'",
organizationId: positiveIntFromQuery,
period: "'7d' | '30d' | '90d' | '180d' | '365d' | 'custom'",
period: "'month' | '7d' | '30d' | '90d' | '180d' | '365d' | 'custom'",
'goalId?': positiveIntFromQuery,
'timezone?': 'string',
'from?': 'string',
'to?': 'string',
'sections?': 'string',
@@ -30,8 +31,9 @@ export const AnalyticsFetchSectionsArkType = type({
export const AnalyticsDrillDownArkType = type({
scope: "'org' | 'project'",
organizationId: positiveIntFromQuery,
period: "'7d' | '30d' | '90d' | '180d' | '365d' | 'custom'",
period: "'month' | '7d' | '30d' | '90d' | '180d' | '365d' | 'custom'",
'goalId?': positiveIntFromQuery,
'timezone?': 'string',
'from?': 'string',
'to?': 'string',
'bucket?': 'string',
@@ -53,6 +55,14 @@ export type AnalyticsRange = {
to: Date
}
export type ResolveRangeArgs = {
period: AnalyticsPeriod
/** IANA timezone the period boundaries are resolved in; defaults to UTC. */
timezone?: string
from?: string
to?: string
}
export type BuilderContext = {
appUser: AppUser
scope: AnalyticsScope
@@ -97,6 +107,8 @@ export type AnalyticsArgBuildSections = {
organizationId: number
period: AnalyticsPeriod
range: AnalyticsRange
/** IANA timezone of the viewer; used to format the response range. */
timezone?: string
sectionIds?: string[]
}
@@ -28,6 +28,8 @@ export class NotificationDispatcher implements Dispatcher {
eventBus.on('task.updated', (data) => this.onTaskUpdated(data));
eventBus.on('task.assigneesChanged', (data) => this.onAssigneesChanged(data));
eventBus.on('task.deleted', (data) => this.onTaskDeleted(data));
eventBus.on('recurrence.created', (data) => this.onRecurrenceScheduled(data));
eventBus.on('recurrence.updated', (data) => this.onRecurrenceScheduled(data));
}
async registerWorkers(): Promise<void> {
@@ -79,6 +81,19 @@ export class NotificationDispatcher implements Dispatcher {
}
}
private async onRecurrenceScheduled(data: AppEvents['recurrence.created'] | AppEvents['recurrence.updated']): Promise<void> {
const instance = await new RecurrenceRepository().findOpenInstance(data.rule.id);
if (!instance) return;
this.notificationsRepo.deleteByTaskAndType(instance.id, NotificationType.DEADLINE);
await this.deadlineScheduler.cancel(instance.id);
if (data.rule.notifyOnOccurrence && instance.endDate) {
$logger.info(`[NotificationDispatcher] Scheduling occurrence deadline for task=${instance.id}, rule=${data.rule.id}`);
await this.deadlineScheduler.schedule(instance, data.initiatorId);
}
}
private async onAssigneesChanged(data: AppEvents['task.assigneesChanged']): Promise<void> {
if (data.userIds.length === 0) return;
@@ -230,6 +230,22 @@ export class RecurrenceManager {
const updated = await this.repository.patch({ ruleId: args.ruleId, patch });
if (!updated) return fail('invalid_state');
if (patch.dtstart !== undefined || patch.timezone !== undefined || patch.rrule !== undefined) {
const openInstance = await this.repository.findOpenInstance(args.ruleId);
if (openInstance?.recurrenceInstanceDate) {
await this.repository.updateInstanceWindow({
taskId: openInstance.id,
window: RecurrenceParser.instanceWindowUtc({
occurrenceDate: openInstance.recurrenceInstanceDate,
dtstart: updated.dtstart,
hasTime: updated.hasTime,
timezone: updated.timezone,
durationMinutes: updated.templateDurationMinutes,
}),
});
}
}
eventBus.emit('recurrence.updated', { rule: updated, changes: patch, initiatorId: this.initiatorId });
return ok(await this.cleanRuleFieldsRegardPermissions(updated));
}
@@ -19,6 +19,7 @@ import type {
CreateRuleWithOriginResult,
RecurrenceRulePatchArgs,
RemoveTemplateAssigneeFromGoalArgs,
UpdateInstanceWindowArgs,
} from './types';
const PG_UNIQUE_VIOLATION = '23505';
@@ -194,6 +195,21 @@ export class RecurrenceRepository {
return result?.[0] ?? null;
}
/** Re-stamp an open instance's deadline window after the series time/timezone changed. */
async updateInstanceWindow(args: UpdateInstanceWindowArgs): Promise<void> {
await callWithCatch(() =>
this.db.dbDrizzle
.update(TasksSchema)
.set({
startDate: args.window.startDate,
startTime: args.window.startTime,
endDate: args.window.endDate,
endTime: args.window.endTime,
})
.where(eq(TasksSchema.id, args.taskId))
);
}
/** Active rules with no open instance — the reconcile sweep input. Empty in normal operation. */
async findStalledActiveRules(): Promise<RecurrenceRulesSchemaTypeForSelect[]> {
const openInstance = this.db.dbDrizzle
+5
View File
@@ -81,6 +81,11 @@ export type InstanceWindow = {
/** Repository args */
export type UpdateInstanceWindowArgs = {
taskId: number;
window: InstanceWindow;
};
export type RecurrenceRulePatchArgs = {
ruleId: number;
patch: Partial<{
@@ -36,6 +36,7 @@ export class TaskItemForClient {
amount: number | null;
transactionType: 1 | 0 | null;
nodeGraphPosition: Record<string, unknown> | null;
recurrenceRuleId: number | null;
constructor(task: TaskItemInDb, tags: number[] = [], historyId: number | null = null, assignees: number[] = []) {
this.id = task.id;
@@ -69,5 +70,6 @@ export class TaskItemForClient {
this.amount = task.amount;
this.transactionType = task.transaction_type;
this.nodeGraphPosition = task.node_graph_position;
this.recurrenceRuleId = task.recurrence_rule_id;
}
}
+1
View File
@@ -26,6 +26,7 @@ export type TaskItemInDb = {
amount: number | null;
transaction_type: 1 | 0 | null;
node_graph_position: Record<string, unknown> | null;
recurrence_rule_id: number | null;
// history_id?: number | null;
};
Binary file not shown.

After

Width:  |  Height:  |  Size: 583 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 558 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1012 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 879 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1019 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 984 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1005 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 927 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1018 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 998 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 994 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 928 KiB

+45
View File
@@ -0,0 +1,45 @@
---
title: MCP Server
description: Connect AI assistants like Claude Code and Claude Desktop to TaskView via the Model Context Protocol (MCP). Manage projects and tasks with a scoped API token.
navigation:
icon: i-lucide-bot
---
TaskView ships an MCP (Model Context Protocol) server that lets AI assistants such as Claude Code and Claude Desktop work with your projects and tasks through the TaskView API.
```
AI client ──stdio──▶ taskview-mcp ──HTTPS──▶ TaskView API
```
## Prerequisites
- Node.js >= 24
- A TaskView API token (`tvk_...`) — create one in your account settings. See [API tokens](/docs/features/api-tokens).
## Configuration
No installation is required — the server runs via `npx`. Add it to your MCP client config:
```json
{
"mcpServers": {
"taskview": {
"command": "npx",
"args": ["-y", "taskview-mcp"],
"env": {
"TASKVIEW_URL": "https://api.taskview.tech",
"TASKVIEW_TOKEN": "tvk_your_token_here"
}
}
}
}
```
- **Claude Code** — add to `.claude/settings.json` (project) or `~/.claude.json` (global).
- **Claude Desktop** — add to `claude_desktop_config.json`.
Set `TASKVIEW_URL` to your own instance when self-hosting.
## Permissions
The assistant can only do what the API token allows. Token permissions are scoped to selected projects and intersected with your RBAC role, so an AI client never exceeds your own access. Grant the minimum scope needed.
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "taskview-ce-monorepo",
"version": "1.45.0",
"version": "1.48.1",
"private": true,
"description": "TaskView CE monorepo containing web, API, and packages",
"workspaces": [
@@ -44,4 +44,4 @@
"passport-github2": "^0.1.12",
"passport-google-oauth20": "^2.0.0"
}
}
}
@@ -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
@@ -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) => {
+2 -2
View File
@@ -7,8 +7,8 @@ android {
applicationId "com.handscreamgnl.taskview.app"
minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 1420
versionName "1.42.0"
versionCode 14801
versionName "1.48.1"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
aaptOptions {
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.
+4 -4
View File
@@ -304,7 +304,7 @@
CODE_SIGN_ALLOW_ENTITLEMENTS_MODIFICATION = YES;
CODE_SIGN_ENTITLEMENTS = App/App.entitlements;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1.42.0;
CURRENT_PROJECT_VERSION = 1.48.1;
DEVELOPMENT_TEAM = H2W2SG48JT;
INFOPLIST_FILE = App/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
@@ -312,7 +312,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 1.42.0;
MARKETING_VERSION = 1.48.1;
OTHER_SWIFT_FLAGS = "$(inherited) \"-D\" \"COCOAPODS\" \"-DDEBUG\"";
PRODUCT_BUNDLE_IDENTIFIER = com.handscream.taskview.app;
PRODUCT_NAME = "$(TARGET_NAME)";
@@ -329,7 +329,7 @@
CODE_SIGN_ALLOW_ENTITLEMENTS_MODIFICATION = YES;
CODE_SIGN_ENTITLEMENTS = App/App.entitlements;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1.42.0;
CURRENT_PROJECT_VERSION = 1.48.1;
DEVELOPMENT_TEAM = H2W2SG48JT;
INFOPLIST_FILE = App/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
@@ -337,7 +337,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 1.42.0;
MARKETING_VERSION = 1.48.1;
PRODUCT_BUNDLE_IDENTIFIER = com.handscream.taskview.app;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "";
+2 -2
View File
@@ -2,7 +2,7 @@
"name": "web-nuxt-ui",
"private": true,
"type": "module",
"version": "1.45.0",
"version": "1.48.1",
"scripts": {
"dev": "vite",
"build:packages": "pnpm --filter taskview-db-schemas build && pnpm --filter taskview-api build",
@@ -91,4 +91,4 @@
"vue-tsc": "^3.2.2"
},
"packageManager": "pnpm@10.28.1"
}
}
+2
View File
@@ -9,6 +9,8 @@
@theme {
--tv-ui-bg-elevated: #fff;
--color-tv-ui-bg-elevated: var(--tv-ui-bg-elevated);
--radius-10: 10px;
--radius-14: 14px;
}
/* Safe area utilities */
+3 -3
View File
@@ -26,7 +26,7 @@
:fullscreen="isMobile"
: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: '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>
+38 -2
View File
@@ -65,6 +65,7 @@ import { saveLocale } from '@/plugins/i18n'
import { useUpdater } from '@/composables/useUpdater'
import { useUserStore } from '@/stores/user.store'
import { useOrganizationStore } from '@/stores/organization.store'
import { useOrgSwitcher } from '@/composables/useOrgSwitcher'
import { $ls } from '@/plugins/axios'
import avatarImg from '@/assets/images/avatar-1.jpeg'
@@ -79,6 +80,7 @@ const router = useRouter()
const toast = useToast()
const userStore = useUserStore()
const orgStore = useOrganizationStore()
const { switchOrg } = useOrgSwitcher()
const prodOrDev = ref<'prod' | 'dev'>('prod')
let counter = 0
@@ -155,8 +157,7 @@ const items = computed<DropdownMenuItem[][]>(() => [
icon: org.id === orgStore.currentOrg?.id ? 'i-lucide-check' : undefined,
onSelect(e: Event) {
e.preventDefault()
orgStore.setCurrentOrg(org)
router.push({ name: 'user', params: { orgSlug: org.slug } })
switchOrg(org)
},
})),
},
@@ -260,6 +261,15 @@ const items = computed<DropdownMenuItem[][]>(() => [
saveLocale('de')
},
},
{
label: 'Español',
type: 'checkbox',
checked: locale.value === 'es',
onSelect(e: Event) {
e.preventDefault()
saveLocale('es')
},
},
],
},
{
@@ -288,6 +298,32 @@ const items = computed<DropdownMenuItem[][]>(() => [
},
],
},
{
label: t('userMenu.sidebarView'),
icon: 'i-lucide-panel-left',
children: [
{
label: t('userMenu.sidebarViewSecond'),
icon: 'i-lucide-layout-list',
type: 'checkbox',
checked: appStore.sidebarView === 'second',
onSelect(e: Event) {
e.preventDefault()
appStore.setSidebarView('second')
},
},
{
label: t('userMenu.sidebarViewFirst'),
icon: 'i-lucide-panel-left',
type: 'checkbox',
checked: appStore.sidebarView === 'first',
onSelect(e: Event) {
e.preventDefault()
appStore.setSidebarView('first')
},
},
],
},
],
[
{
@@ -2,15 +2,15 @@
<div class="flex flex-col gap-6 p-1 lg:p-6 w-full max-w-full lg:max-w-2xl m-0 lg:mx-auto">
<NotificationSettings />
<UPageCard class="w-full">
<UPageCard class="w-full rounded-3xl">
<SessionsPanel />
</UPageCard>
<UPageCard class="w-full">
<UPageCard class="w-full rounded-3xl">
<ApiTokensPanel />
</UPageCard>
<UPageCard class="w-full">
<UPageCard class="w-full rounded-3xl">
<div class="flex flex-col gap-4">
<h2 class="text-lg font-semibold">
{{ t('account.management') }}
@@ -12,6 +12,8 @@
v-model="code"
:placeholder="t('account.enterCode')"
spellcheck="false"
variant="soft"
:ui="{ base: 'rounded-xl' }"
/>
<div class="flex justify-end gap-2">
@@ -1,5 +1,5 @@
<template>
<UPageCard class="w-full">
<UPageCard class="w-full rounded-3xl">
<div class="flex flex-col gap-4">
<div>
<h2 class="text-lg font-semibold">
@@ -5,15 +5,46 @@
:items="scopeOptions"
value-key="value"
class="sm:w-64"
size="xl"
@update:model-value="onScopeChange"
/>
<USelectMenu
:model-value="analyticsStore.period"
:items="periodOptions"
value-key="value"
class="sm:w-40"
@update:model-value="onPeriodChange"
/>
<UPopover v-model:open="periodOpen">
<UButton
icon="i-lucide-calendar"
:label="activatorLabel"
color="neutral"
variant="soft"
class="sm:w-auto justify-start"
:ui="{ leadingIcon: 'size-4.5' }"
/>
<template #content>
<div class="p-2 flex flex-col gap-2 w-72">
<div class="flex flex-wrap gap-1.5">
<UButton
v-for="opt in periodOptions"
:key="opt.value"
:label="opt.label"
color="neutral"
:variant="analyticsStore.period === opt.value ? 'solid' : 'soft'"
size="xs"
@click="onPresetClick(opt.value)"
/>
</div>
<USeparator />
<UCalendar
:model-value="rangeModel"
range
:week-starts-on="weekStart"
@update:model-value="onRangeSelect"
/>
</div>
</template>
</UPopover>
<UButton
icon="i-lucide-refresh-cw"
variant="ghost"
@@ -26,14 +57,24 @@
<script setup lang="ts">
import type { AnalyticsPeriod, AnalyticsScope } from 'taskview-api'
import { computed } from 'vue'
import { computed, ref, shallowRef, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { useDateFormat } from '@vueuse/core'
import { CalendarDate } from '@internationalized/date'
import type { DateValue } from '@internationalized/date'
import { useAnalyticsStore } from '@/stores/analytics.store'
import { useWeekStart } from '@/composables/useWeekStart'
const analyticsStore = useAnalyticsStore()
const { t } = useI18n()
const weekStart = useWeekStart()
const periodOpen = ref(false)
type DateRange = { start: DateValue | undefined; end: DateValue | undefined }
const periodOptions = computed<{ label: string; value: AnalyticsPeriod }[]>(() => [
{ label: t('analytics.filters.periods.month'), value: 'month' },
{ label: t('analytics.filters.periods.7d'), value: '7d' },
{ label: t('analytics.filters.periods.30d'), value: '30d' },
{ label: t('analytics.filters.periods.90d'), value: '90d' },
@@ -41,6 +82,55 @@ const periodOptions = computed<{ label: string; value: AnalyticsPeriod }[]>(() =
{ label: t('analytics.filters.periods.365d'), value: '365d' },
])
function isoToCalendarDate(iso: string): CalendarDate {
const [year, month, day] = iso.slice(0, 10).split('-').map(Number)
return new CalendarDate(year, month, day)
}
// Parse a 'YYYY-MM-DD' as a local date (new Date(iso) would treat it as UTC).
function localDate(iso: string): Date {
const [year, month, day] = iso.slice(0, 10).split('-').map(Number)
return new Date(year, month - 1, day)
}
// Seed the calendar with the active range so the current selection is visible.
function rangeFromStore(): DateRange {
const r = analyticsStore.range
if (!r) return { start: undefined, end: undefined }
return { start: isoToCalendarDate(r.from), end: isoToCalendarDate(r.to) }
}
const rangeModel = shallowRef<DateRange>(rangeFromStore())
// Reflect the resolved range whenever it changes (preset clicks, refresh).
watch(() => analyticsStore.range, () => {
rangeModel.value = rangeFromStore()
})
// User picked a range in the calendar → switch to a custom period.
function onRangeSelect(value: DateRange | null) {
rangeModel.value = value ?? { start: undefined, end: undefined }
if (!value?.start || !value?.end) return
analyticsStore.setCustomRange(value.start.toString().slice(0, 10), value.end.toString().slice(0, 10))
periodOpen.value = false
}
const activatorLabel = computed(() => {
if (analyticsStore.period !== 'custom') {
return periodOptions.value.find(o => o.value === analyticsStore.period)?.label ?? ''
}
const r = analyticsStore.range
if (!r) return t('analytics.filters.periods.custom')
const from = useDateFormat(localDate(r.from), 'DD MMM YYYY').value
const to = useDateFormat(localDate(r.to), 'DD MMM YYYY').value
return `${from}${to}`
})
function onPresetClick(value: AnalyticsPeriod) {
analyticsStore.setPeriod(value)
periodOpen.value = false
}
type ScopeOption = { label: string; value: string; scope: AnalyticsScope }
const scopeOptions = computed<ScopeOption[]>(() => {
@@ -65,10 +155,4 @@ function onScopeChange(value: string) {
const option = scopeOptions.value.find(o => o.value === value)
if (option) analyticsStore.setScope(option.scope)
}
function onPeriodChange(value: AnalyticsPeriod) {
analyticsStore.setPeriod(value)
}
</script>
@@ -1,5 +1,5 @@
<template>
<div class="flex items-center justify-between p-4 border border-default rounded-lg">
<UCard variant="soft" :ui="{body:'w-full flex items-center justify-between p-4', root:'rounded-2xl'}">
<div class="flex items-center gap-3 min-w-0">
<UIcon
name="i-lucide-key-round"
@@ -32,10 +32,10 @@
icon="i-lucide-trash-2"
variant="ghost"
color="error"
size="md"
size="xl"
@click="showDeleteConfirm = true"
/>
</div>
</UCard>
<UModal
v-model:open="showDeleteConfirm"
@@ -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,11 @@
<script setup lang="ts">
import { useI18n } from 'vue-i18n'
import TvCollapseSidebarDesktop from '@/components/features/base/TvCollapseSidebarDesktop.vue'
import { useLsRef } from '@/composables/useLsRef'
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')
</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>
@@ -0,0 +1,90 @@
<template>
<div class="flex items-center sm:items-start gap-4 w-full cursor-pointer select-none">
<div
class="flex items-center justify-center size-12 rounded-2xl shrink-0"
:class="accent.bg"
>
<UIcon
:name="icon"
class="size-6"
:class="accent.icon"
/>
</div>
<div class="flex-1 min-w-0 flex items-start justify-between gap-3">
<div class="min-w-0">
<div class="flex items-center gap-2 min-w-0">
<h2 class="text-xl font-medium text-highlighted truncate">
{{ title }}
</h2>
<UIcon
name="i-lucide-chevron-down"
class="size-5 text-muted transition-transform duration-200 shrink-0"
:class="{ '-rotate-90': !expanded }"
/>
</div>
<p class="text-sm text-muted hidden sm:block">
{{ subtitle }}
</p>
</div>
<div class="flex items-center gap-3 shrink-0 self-center">
<template v-if="showProgress">
<span class="text-sm font-semibold text-muted">{{ doneCount }}/{{ totalCount }}</span>
<div class="w-16 h-1.5 rounded-full bg-elevated overflow-hidden">
<div
class="h-full rounded-full transition-[width] duration-300"
:class="accent.bar"
:style="{ width: `${progressPercent}%` }"
/>
</div>
</template>
<UButton
v-if="groupable"
:icon="grouped ? 'i-lucide-list' : 'i-lucide-calendar-days'"
:aria-label="grouped ? t('msg.showAsList') : t('msg.groupByDate')"
color="neutral"
variant="ghost"
size="sm"
@click.stop="$emit('toggle-group')"
/>
<DashboardAddTask
compact
:title="addTitle"
:disabled="addDisabled"
:upcoming-task="upcomingTask"
:no-dates="noDates"
/>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { useI18n } from 'vue-i18n'
import DashboardAddTask from './DashboardAddTask.vue'
defineProps<{
icon: string
accent: { bg: string; icon: string; bar: string }
title: string
subtitle: string
expanded: boolean
showProgress: boolean
doneCount: number
totalCount: number
progressPercent: number
addTitle: string
addDisabled?: boolean
upcomingTask?: boolean
noDates?: boolean
groupable?: boolean
grouped?: boolean
}>()
defineEmits<{
'toggle-group': []
}>()
const { t } = useI18n()
</script>
@@ -1,7 +1,7 @@
<template>
<div class="overflow-auto flex flex-col gap-0">
<div class="md:block sticky top-0 p-2 lg:p-5 z-10 bg-default">
<SearchAll />
<SearchActivator />
</div>
<div class="flex flex-col gap-5 p-2 md:p-5 md:pt-0 w-full mx-auto">
<div class="flex gap-5 flex-col md:flex-row">
@@ -14,7 +14,7 @@
</template>
<script async setup lang="ts">
import { watch } from 'vue'
import SearchAll from './parts/SearchAll.vue'
import SearchActivator from './parts/SearchActivator.vue'
import WidgetLastAddedTasks from './parts/WidgetLastAddedTasks.vue'
import WidgetToday from './parts/WidgetToday.vue'
import WidgetUpcoming from './parts/WidgetUpcoming.vue'
@@ -18,54 +18,54 @@
</template>
<template #body>
<UCard>
<div class="flex flex-col gap-4">
<UFormField :label="t('tasks.description')">
<UInput
v-model="taskDescription"
:placeholder="t('tasks.addPlaceholder')"
class="w-full"
@keyup.enter="addTask"
/>
</UFormField>
<!-- <UCard> -->
<div class="flex flex-col gap-4">
<UFormField :label="t('tasks.description')">
<UInput
v-model="taskDescription"
:placeholder="t('tasks.addPlaceholder')"
class="w-full"
@keyup.enter="addTask"
/>
</UFormField>
<UFormField :label="t('projects.project')">
<USelectMenu
v-model="selectedGoalId"
:items="goalOptions"
:placeholder="t('projects.selectProject')"
:search-input="false"
size="xl"
value-key="value"
option-key="value"
class="w-full"
/>
</UFormField>
<UFormField :label="t('projects.project')">
<USelectMenu
v-model="selectedGoalId"
:items="goalOptions"
:placeholder="t('projects.selectProject')"
:search-input="false"
size="xl"
value-key="value"
option-key="value"
class="w-full"
/>
</UFormField>
<UFormField
v-if="!noDates"
:label="t('tasks.deadline')"
>
<UPopover>
<UButton
:label="formattedDeadline || t('tasks.addDueDate')"
icon="i-lucide-calendar"
color="neutral"
variant="outline"
class="w-full justify-start"
:ui="{leadingIcon: 'size-4.5'}"
<UFormField
v-if="!noDates"
:label="t('tasks.deadline')"
>
<UPopover>
<UButton
:label="formattedDeadline || t('tasks.addDueDate')"
icon="i-lucide-calendar"
color="neutral"
variant="soft"
class="w-full justify-start"
:ui="{leadingIcon: 'size-4.5'}"
/>
<template #content>
<UCalendar
v-model="(deadline as any)"
:week-starts-on="weekStart"
class="p-2"
/>
<template #content>
<UCalendar
v-model="(deadline as any)"
:week-starts-on="weekStart"
class="p-2"
/>
</template>
</UPopover>
</UFormField>
</div>
</UCard>
</template>
</UPopover>
</UFormField>
</div>
<!-- </UCard> -->
</template>
<template #default="slotProps">
@@ -79,7 +79,7 @@
<div class="flex justify-end gap-2 w-full">
<UButton
color="neutral"
variant="outline"
variant="soft"
@click="close"
>
{{ t('common.cancel') }}
@@ -87,7 +87,7 @@
<UButton
:disabled="!canAdd"
:loading="loading"
variant="outline"
variant="soft"
@click="addTask"
>
{{ t('tasks.add') }}
@@ -98,7 +98,7 @@
</template>
<script setup lang="ts">
import { computed, onMounted, ref, watch } from 'vue'
import { computed, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { CalendarDate } from '@internationalized/date'
import { useGoalsStore } from '@/stores/goals.store'
@@ -141,14 +141,6 @@ const taskDescription = ref(props.taskName)
const selectedGoalId = ref<number | undefined>(undefined)
const deadline = ref<CalendarDate | null>(null)
const loading = ref(false)
const lastSavedProjectId = ref<number | undefined>(undefined)
onMounted(async () => {
const saved = await $ls.getValue(LAST_SELECTED_PROJECT_KEY)
if (saved) {
lastSavedProjectId.value = Number(saved)
}
})
watch(() => props.taskName, (newVal) => {
taskDescription.value = newVal
@@ -169,16 +161,15 @@ watch(model, async (isOpen) => {
)
}
// Set default goal from localStorage or first available
// Pre-select the last used project. Read fresh from storage on every open so
// it stays in sync across the multiple dialog instances on the screen.
const activeGoals = goalsStore.goals.filter(g => !g.archive)
const saved = await $ls.getValue(LAST_SELECTED_PROJECT_KEY)
const savedId = saved ? Number(saved) : undefined
const savedGoal = savedId ? activeGoals.find(g => g.id === savedId) : undefined
if (lastSavedProjectId.value) {
const savedGoal = activeGoals.find(g => g.id === lastSavedProjectId.value)
if (savedGoal) {
selectedGoalId.value = savedGoal.id
} else if (activeGoals.length === 1) {
selectedGoalId.value = activeGoals[0].id
}
if (savedGoal) {
selectedGoalId.value = savedGoal.id
} else if (activeGoals.length === 1) {
selectedGoalId.value = activeGoals[0].id
}
@@ -214,9 +205,8 @@ async function addTask() {
loading.value = true
try {
// Save selected project to localStorage
// Remember the chosen project for the next time the dialog is opened.
await $ls.setValue(LAST_SELECTED_PROJECT_KEY, selectedGoalId.value.toString())
lastSavedProjectId.value = selectedGoalId.value
const endDate = deadline.value
? `${deadline.value.year}-${String(deadline.value.month).padStart(2, '0')}-${String(deadline.value.day).padStart(2, '0')}`
@@ -0,0 +1,19 @@
<template>
<UDashboardSearchButton
:label="t('admin.search')"
:disabled="!hasActiveGoals"
class="w-full"
variant="soft"
size="xl"
/>
</template>
<script lang="ts" setup>
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { useGoalsStore } from '@/stores/goals.store'
const { t } = useI18n()
const goalsStore = useGoalsStore()
const hasActiveGoals = computed(() => goalsStore.goals.length > 0)
</script>
@@ -1,12 +1,5 @@
<template>
<div>
<UDashboardSearchButton
:label="t('admin.search')"
:disabled="!hasActiveGoals"
class="w-full"
variant="subtle"
/>
<UDashboardSearch
v-model:search-term="searchTerm"
v-model:open="isOpen"
@@ -80,7 +73,6 @@ import { useI18n } from 'vue-i18n'
import { refDebounced } from '@vueuse/core'
import MainScreenAddTaskDialog from './MainScreenAddTaskDialog.vue'
import { useBaseScreenStore } from '@/stores/base-screen.store'
import { useGoalsStore } from '@/stores/goals.store'
import { ALL_TASKS_LIST_ID } from 'taskview-api'
import type { TaskItem } from '@/types/tasks.types'
import { useTaskView } from '@/composables/useTaskView'
@@ -94,9 +86,6 @@ const isOpen = ref(false)
const baseScreenStore = useBaseScreenStore()
const tasks = ref<TaskItem[]>([])
const addTaskDialogModel = ref(false)
const goalsStore = useGoalsStore()
const hasActiveGoals = computed(() => goalsStore.goals.length > 0)
watch(searchTermDebounced, async (term) => {
if (!term) {
@@ -35,6 +35,7 @@
<UButton
:label="t('organizations.save')"
:loading="loading"
variant="soft"
@click="create"
/>
</div>
@@ -36,6 +36,7 @@
<UTabs
:items="tabs"
class="w-full"
:ui="{ list: 'rounded-2xl', indicator: 'rounded-xl' }"
>
<template #general>
<div class="flex flex-col gap-4 pt-4">
@@ -58,6 +59,7 @@
v-if="isAdmin"
:label="t('organizations.save')"
:loading="saving"
variant="soft"
@click="save"
/>
</div>
@@ -82,7 +84,7 @@
v-if="isOwner && !organization.isPersonal"
:label="t('organizations.delete')"
color="error"
variant="ghost"
variant="soft"
icon="i-lucide-trash-2"
:ui="{leadingIcon: 'size-4.5'}"
@click="deleteOrg"
@@ -90,7 +92,7 @@
<div v-else />
<UButton
:label="t('organizations.close')"
variant="ghost"
variant="soft"
@click="open = false"
/>
</div>
@@ -16,13 +16,13 @@
<UButton
icon="i-lucide-pencil"
size="xs"
variant="ghost"
variant="soft"
@click="$emit('edit')"
/>
<UButton
icon="i-lucide-trash-2"
size="xs"
variant="ghost"
variant="soft"
color="error"
@click="$emit('delete')"
/>
@@ -38,7 +38,7 @@
<UButton
icon="i-lucide-copy"
size="xs"
variant="ghost"
variant="soft"
@click="copyToClipboard(callbackUrl)"
/>
</div>
@@ -5,6 +5,7 @@
v-model="form.protocol"
:items="protocolOptions"
class="w-full"
variant="soft"
size="xl"
/>
</UFormField>
@@ -39,15 +40,16 @@
:has-secrets="hasSecrets"
/>
<div class="flex gap-2">
<div class="flex justify-end gap-2">
<UButton
:label="isEditing ? t('sso.save') : t('sso.create')"
:loading="saving"
variant="soft"
@click="$emit('save')"
/>
<UButton
:label="t('sso.cancel')"
variant="ghost"
variant="soft"
color="neutral"
@click="$emit('cancel')"
/>
@@ -6,6 +6,7 @@
<UButton
:label="t('sso.configure')"
class="mt-3"
variant="soft"
@click="showForm = true"
/>
</div>
@@ -1,15 +1,12 @@
<template>
<div class="shadow-sm rounded-lg">
<UInput
v-model="projectName"
:placeholder="t('projects.addPlaceholder')"
:placeholder="placeholder ?? t('projects.addPlaceholder')"
size="xl"
variant="soft"
class="w-full"
data-testid="project-add-input"
:ui="{
base: 'bg-tv-ui-bg-elevated',
}"
@keydown.enter="addProject"
>
<template #trailing>
@@ -29,13 +26,16 @@
/>
</template>
</UInput>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { useI18n } from 'vue-i18n'
defineProps<{
placeholder?: string
}>()
const emit = defineEmits<{
add: [name: string]
}>()
@@ -17,13 +17,14 @@
<UButton
:label="t('common.no')"
color="neutral"
variant="outline"
variant="soft"
@click="close"
/>
<UButton
:label="t('common.yes')"
color="error"
class="min-w-12"
variant="soft"
:ui="{base: 'justify-center'}"
data-testid="confirm-delete-button"
@click="confirm"
@@ -12,7 +12,9 @@
<UInput
v-model="form.name"
:placeholder="t('projects.namePlaceholder')"
variant="soft"
class="w-full"
:ui="{ base: 'rounded-xl' }"
data-testid="project-edit-name"
/>
</UFormField>
@@ -21,13 +23,16 @@
v-model="form.description"
:placeholder="t('projects.descriptionPlaceholder')"
:rows="3"
variant="soft"
class="w-full"
:ui="{ base: 'rounded-xl' }"
/>
</UFormField>
<UFormField :label="t('projects.estimateUnit')">
<USelect
v-model="form.estimateUnit"
:items="estimateUnitItems"
variant="soft"
class="w-full"
data-testid="project-edit-estimate-unit"
/>
@@ -40,13 +45,13 @@
<UButton
:label="t('common.cancel')"
color="neutral"
variant="outline"
variant="soft"
@click="close"
/>
<UButton
:label="t('common.save')"
color="primary"
variant="outline"
variant="soft"
data-testid="project-edit-save"
@click="save"
/>
@@ -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'}" class=" ">
<div class="flex items-start gap-3 min-w-0">
<UIcon
:name="deviceIcon"
@@ -28,15 +28,16 @@
</div>
</div>
</div>
<UButton
v-if="!session.isCurrent"
icon="i-lucide-log-out"
variant="ghost"
color="error"
size="md"
size="xl"
@click="showDeleteConfirm = true"
/>
</div>
</UCard>
<UModal
v-model:open="showDeleteConfirm"
@@ -40,7 +40,7 @@
/>
<span
class="truncate"
:class="{ 'line-through text-dimmed': task.complete }"
:class="{ 'text-dimmed': task.complete }"
>
{{ task.description }}
</span>
@@ -2,7 +2,7 @@
<div class="h-full relative flex flex-col gap-4 @container">
<template v-if="task">
<!-- Title & Checkbox -->
<div class="flex items-start gap-3 shadow-sm rounded-lg dark:bg-tv-ui-bg-elevated">
<div class="flex items-start gap-3 shadow-sm rounded-2xl dark:bg-tv-ui-bg-elevated">
<div class="flex-1">
<UTextarea
v-model="titleValue"
@@ -11,6 +11,7 @@
:autoresize="true"
class="w-full"
:class="{ 'text-muted': task.complete }"
:ui="{ base: 'rounded-2xl' }"
>
<template #leading>
<div class="h-full">
@@ -61,15 +62,17 @@
:class="colClass(fieldId)"
@save="updateNote"
/>
<TaskKanbanStatusSelect
v-else-if="fieldId === 'status'"
:task-id="task.id"
:current-status-id="task.statusId"
<TvKanbanStatusSelect
v-else-if="fieldId === 'status' && canViewKanban"
:model-value="task.statusId"
:disabled="!canManageKanban"
:class="colClass(fieldId)"
@update:model-value="updateStatus"
/>
<TaskPrioritySelect
v-else-if="fieldId === 'priority'"
<TvPriority
v-else-if="fieldId === 'priority' && canViewTaskPriority"
:model-value="task.priorityId"
:disabled="!canEditTaskPriority"
:class="colClass(fieldId)"
@update:model-value="updatePriority"
/>
@@ -79,11 +82,11 @@
:assigned-user-ids="task.assignedUsers"
:class="colClass(fieldId)"
/>
<TaskListSelect
v-else-if="fieldId === 'list'"
:task-id="task.id"
:current-list-id="task.goalListId"
<TvListSelect
v-else-if="fieldId === 'list' && canViewLists"
:model-value="task.goalListId"
:class="colClass(fieldId)"
@update:model-value="updateList"
/>
<TaskTagsManager
v-else-if="fieldId === 'tags'"
@@ -92,11 +95,16 @@
:goal-id="projectId"
:class="colClass(fieldId)"
/>
<TaskDeadline
v-else-if="fieldId === 'deadline'"
:task="task"
:class="[colClass(fieldId), 'h-fit']"
/>
<template v-else-if="fieldId === 'deadline'">
<TvDeadlineSelect
:task="task"
:class="[colClass(fieldId), 'h-fit']"
/>
<TaskRecurrence
:task="task"
:class="colClass(fieldId)"
/>
</template>
<TaskAmountEditor
v-else-if="fieldId === 'amount'"
:task-id="task.id"
@@ -104,7 +112,7 @@
:transaction-type="task.transactionType"
:class="colClass(fieldId)"
/>
<TaskSprintSelect
<TvSprintSelect
v-else-if="fieldId === 'sprint'"
:task-id="task.id"
:goal-id="task.goalId"
@@ -151,19 +159,22 @@ import { useDebounceFn } from '@vueuse/core'
import { useI18n } from 'vue-i18n'
import { useTasksStore } from '@/stores/tasks.store'
import NoteEditor from '@/components/features/tasks/parts/NoteEditor.vue'
import TaskPrioritySelect from '@/components/features/tasks/parts/TaskPrioritySelect.vue'
import TvPriority from '@/components/features/base/TvPriority.vue'
import TaskTagsManager from '@/components/features/tasks/parts/tags/TaskTagsManager.vue'
import TaskAssigneeSelect from '@/components/features/tasks/parts/TaskAssigneeSelect.vue'
import TaskListSelect from '@/components/features/tasks/parts/TaskListSelect.vue'
import TaskKanbanStatusSelect from '@/components/features/tasks/parts/TaskKanbanStatusSelect.vue'
import TvListSelect from '@/components/features/base/TvListSelect.vue'
import TvKanbanStatusSelect from '@/components/features/base/TvKanbanStatusSelect.vue'
import TaskAmountEditor from '@/components/features/tasks/parts/TaskAmountEditor.vue'
import TaskTimeTracking from '@/components/features/tasks/parts/TaskTimeTracking.vue'
import TaskHistory from '@/components/features/tasks/parts/TaskHistory.vue'
import TaskDeadline from '@/components/features/tasks/parts/TaskDeadline.vue'
import TvDeadlineSelect from '@/components/features/base/TvDeadlineSelect.vue'
import TaskRecurrence from '@/components/features/tasks/parts/TaskRecurrence.vue'
import TaskSubtasks from '@/components/features/tasks/parts/TaskSubtasks.vue'
import TaskSprintSelect from '@/components/features/tasks/parts/TaskSprintSelect.vue'
import TvSprintSelect from '@/components/features/base/TvSprintSelect.vue'
import TaskEstimateInput from '@/components/features/tasks/parts/TaskEstimateInput.vue'
import type { TaskBase } from 'taskview-api'
import type { PriorityValue } from '@/composables/usePriorityOptions'
import type { ListValue } from '@/composables/useGoalListOptions'
import type { StatusValue } from '@/composables/useKanbanStatusOptions'
import { useGoalPermissions } from '@/composables/useGoalPermissions'
import { useUiPreferences } from '@/composables/useUiPreferences'
import { tasksSection } from '@/uiCustomization/sections/tasks'
@@ -173,6 +184,11 @@ const toast = useToast()
const tasksStore = useTasksStore()
const {
canEditTaskStatus,
canViewTaskPriority,
canEditTaskPriority,
canViewLists,
canViewKanban,
canManageKanban,
} = useGoalPermissions()
const { catalogue: taskFieldsCatalogue } = tasksSection.useSection()
@@ -253,13 +269,29 @@ async function updateNote(note: string) {
}
}
async function updatePriority(priorityId: TaskBase['priorityId']) {
if (!task.value) return
async function updatePriority(priorityId: PriorityValue) {
if (!task.value || priorityId == null) return
await tasksStore.udpatePriority({
id: task.value.id,
priorityId,
})
}
async function updateList(goalListId: ListValue | undefined) {
if (!task.value || goalListId === undefined || goalListId === task.value.goalListId) return
await tasksStore.moveTaskToAnotherList({
id: task.value.id,
goalListId,
})
}
async function updateStatus(statusId: StatusValue | undefined) {
if (!task.value || statusId === undefined || statusId === task.value.statusId) return
await tasksStore.updateTaskStatusId({
id: task.value.id,
statusId,
})
}
</script>
@@ -32,7 +32,7 @@
:fullscreen="isMobile"
:ui="{
overlay: 'sm:items-center sm:justify-center ',
content: 'sm:max-w-[1100px] sm:max-h-[80vh] sm:rounded-lg sm:m-auto w-full',
content: 'sm:max-w-[1100px] sm:max-h-[80vh] sm:m-auto w-full',
body: 'p-4!',
footer: 'p-4!',
}"
@@ -60,13 +60,13 @@
<UButton
:label="t('common.delete')"
color="error"
variant="outline"
variant="soft"
@click="openDeleteDialog"
/>
<UButton
:label="t('common.close')"
color="neutral"
variant="outline"
variant="soft"
@click="closeTask"
/>
</div>
@@ -1,5 +1,5 @@
<template>
<div class="note-editor border border-default rounded-lg dark:bg-tv-ui-bg-elevated!">
<div class="note-editor border border-default rounded-2xl overflow-hidden dark:bg-tv-ui-bg-elevated!">
<UEditor
v-if="canViewTaskNote"
#default="{ editor }"
@@ -15,6 +15,7 @@
:editor="editor"
:items="toolbarItems"
class="border-b border-default overflow-x-auto"
:ui="{base: 'p-2'}"
/>
<!-- <UEditorDragHandle :editor="editor" /> -->
</UEditor>
@@ -1,5 +1,5 @@
<template>
<div class="w-full h-fit shadow-sm rounded-lg p-2 dark:bg-tv-ui-bg-elevated">
<div class="w-full h-fit">
<!-- <label class="text-sm text-muted mb-2 block">{{ t('tasks.amount') }}</label> -->
<div class="flex gap-2">
@@ -13,6 +13,7 @@
size="xl"
:placeholder="t('tasks.enterAmount')"
class="flex-1"
:ui="{base: 'rounded-14! h-13'}"
:variant="isDark ? 'subtle' : 'soft'"
@update:model-value="handleAmountInput"
@focus="editing = true"
@@ -33,10 +34,11 @@
:items="tabItems"
:content="false"
variant="pill"
size="md"
size="xl"
color="success"
:class="{'pointer-events-none': !canDeleteTask}"
class="shrink-0 shadow-sm rounded-lg border border-accented flex-1"
class="shrink-0 shadow-sm rounded-lg border border-accented"
:ui="{root: 'border-none rounded-14! shadow-none!', list:'rounded-14! grow', indicator: 'rounded-10!'}"
@update:model-value="handleTabChange"
/>
</div>
@@ -49,7 +51,9 @@
icon="i-lucide-x"
color="info"
variant="soft"
size="sm"
class="items-center justify-center"
size="xl"
:ui="{base: 'rounded-14! size-13'}"
@click="clearAmount"
/>
</div>
@@ -1,62 +1,24 @@
<template>
<div
<TvDropdown
v-if="canViewAssignedUsersToTask"
class="h-fit dark:bg-tv-ui-bg-elevated shadow-sm rounded-lg p-2"
>
<!-- Badge mode (< 10 users) -->
<div
v-if="users.length < 10"
class="flex flex-wrap gap-2"
>
<UBadge
v-for="user in users"
:key="user.id"
:label="getUserDisplayName(user)"
:variant="isUserAssigned(user.id) ? (isDark ? 'subtle' : 'solid') : (isDark ? 'subtle' : 'outline')"
:color="isUserAssigned(user.id) ? 'primary' : 'neutral'"
:ui="{ leadingIcon: 'size-4' }"
icon="lucide:at-sign"
class="cursor-pointer select-none truncate"
size="xl"
@click="toggleAssignee(user.id)"
/>
<span
v-if="users.length === 0"
class="text-sm text-muted"
>
{{ t('tasks.noUsersAvailable') }}
</span>
</div>
<!-- Dropdown mode (>= 10 users) -->
<USelectMenu
v-else
v-model="selectedUserIds"
:disabled="!canAssignUsersToTask"
:items="userItems"
:placeholder="t('tasks.selectAssignees')"
:searchable="true"
:search-placeholder="t('tasks.searchUsers')"
:ui="{
base:'w-full'
}"
multiple
value-key="value"
variant="soft"
@update:model-value="handleSelectionChange"
/>
</div>
:model-value="selectedUserIds"
:items="options"
:disabled="!canAssignUsersToTask"
:placeholder="t('tasks.selectAssignees')"
placeholder-icon="i-lucide-at-sign"
activator-icon="i-lucide-at-sign"
multiple
@update:model-value="updateAssignees"
/>
</template>
<script setup lang="ts">
import { ref, computed, watch } from 'vue'
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import type { CollaborationResponseFetchAllUsers } from 'taskview-api'
import { useCollaborationStore } from '@/stores/collaboration.store'
import { useTasksStore } from '@/stores/tasks.store'
import { useColor } from '@/composables/useColotMode'
import { useGoalPermissions } from '@/composables/useGoalPermissions'
import { useProjectContext } from '@/composables/useProjectContext'
import { useUserOptions, type UserValue } from '@/composables/useUserOptions'
import TvDropdown from '@/components/features/base/TvDropdown.vue'
const props = defineProps<{
taskId: number
@@ -64,55 +26,17 @@ const props = defineProps<{
}>()
const { t } = useI18n()
const collaborationStore = useCollaborationStore()
const tasksStore = useTasksStore()
const { isDark } = useColor()
const selectedUserIds = ref<number[]>([...props.assignedUserIds])
const { canViewAssignedUsersToTask, canAssignUsersToTask } = useGoalPermissions()
const { options } = useUserOptions()
const projectContext = useProjectContext()
const users = computed(() =>
projectContext ? projectContext.users.value : collaborationStore.users,
)
const userItems = computed(() =>
users.value.map(user => ({
label: getUserDisplayName(user),
value: user.id,
})),
)
watch(() => props.assignedUserIds, (newIds) => {
selectedUserIds.value = [...newIds]
}, { deep: true })
function getUserDisplayName(user: CollaborationResponseFetchAllUsers): string {
return user.email
}
function isUserAssigned(userId: number): boolean {
return props.assignedUserIds.includes(userId)
}
async function toggleAssignee(userId: number) {
if (!canAssignUsersToTask.value) return
const currentAssignees = [...props.assignedUserIds]
const isCurrentlyAssigned = currentAssignees.includes(userId)
const newAssignees = isCurrentlyAssigned
? currentAssignees.filter(id => id !== userId)
: [...currentAssignees, userId]
const selectedUserIds = computed(() => props.assignedUserIds)
async function updateAssignees(value: UserValue | UserValue[] | undefined) {
const userIds = Array.isArray(value) ? value : []
await tasksStore.updateTaskAssignee({
taskId: props.taskId,
userIds: newAssignees,
})
}
async function handleSelectionChange(newSelection: number[]) {
await tasksStore.updateTaskAssignee({
taskId: props.taskId,
userIds: newSelection,
userIds,
})
}
</script>
@@ -15,12 +15,13 @@
<UButton
:label="t('common.cancel')"
color="neutral"
variant="outline"
variant="soft"
@click="isOpen = false"
/>
<UButton
:label="t('common.delete')"
color="error"
variant="soft"
@click="confirmDelete"
/>
</div>
@@ -1,37 +1,26 @@
<template>
<div class="w-full h-fit dark:bg-tv-ui-bg-elevated shadow-sm rounded-lg p-2">
<label class="text-sm text-muted mb-2 flex items-center gap-1">
<UIcon
name="i-lucide-gauge"
class="size-4"
/>
{{ t('tasks.fields.estimate') }}
<span>({{ unitLabel(unit, true) }})</span>
</label>
<UInputNumber
:model-value="localValue"
:disabled="!canEdit"
:min="0"
:step="1"
:placeholder="t('tasks.estimatePlaceholder')"
size="xl"
class="w-full"
:variant="isDark ? 'subtle' : 'soft'"
@update:model-value="onInput"
@blur="save"
/>
</div>
<UInputNumber
:model-value="localValue"
:disabled="!canEdit"
:min="0"
:step="1"
icon="i-lucide-gauge"
:placeholder="t('tasks.estimatePlaceholder')"
size="xl"
variant="soft"
class="w-full"
:ui="activatorUi('text-muted')"
@update:model-value="onInput"
@blur="save"
/>
</template>
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import { ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { useTasksStore } from '@/stores/tasks.store'
import { useGoalsStore } from '@/stores/goals.store'
import { useColor } from '@/composables/useColotMode'
import { useGoalPermissions } from '@/composables/useGoalPermissions'
import { useSprintFormat } from '@/components/features/sprints/composables/useSprintFormat'
import { useNuxtUiTaskItemStyles } from '@/composables/useNuxtUiTaskItemStyles'
const props = defineProps<{
taskId: number
@@ -41,12 +30,8 @@ const props = defineProps<{
const { t } = useI18n()
const tasksStore = useTasksStore()
const goalsStore = useGoalsStore()
const { isDark } = useColor()
const { canEditTaskPriority } = useGoalPermissions()
const { unitLabel } = useSprintFormat()
const unit = computed(() => goalsStore.goalMap.get(props.goalId)?.estimateUnit ?? 'points')
const { activatorUi } = useNuxtUiTaskItemStyles()
const canEdit = canEditTaskPriority
@@ -1,31 +1,29 @@
<template>
<div class="shadow-sm rounded-lg p-2 dark:bg-tv-ui-bg-elevated">
<div
class="flex items-center justify-between"
<div class="flex flex-col gap-2">
<UButton
icon="i-lucide-history"
color="neutral"
variant="soft"
size="xl"
block
:trailing-icon="isExpanded ? 'i-lucide-chevron-up' : 'i-lucide-chevron-down'"
:ui="activatorUi('text-muted')"
@click="toggleExpand"
>
<label class="text-base text-muted">{{ t('history.title') }}</label>
<UButton
v-if="!isExpanded"
:label="t('history.show')"
icon="i-lucide-history"
<span class="flex-1 text-left">{{ t('history.title') }}</span>
<UBadge
v-if="changes.length > 0"
:label="String(changes.length)"
color="neutral"
variant="ghost"
size="md"
variant="subtle"
size="sm"
:ui="{ base: 'rounded-full' }"
/>
<UButton
v-else
:label="t('history.hide')"
icon="i-lucide-chevron-up"
color="neutral"
variant="ghost"
size="md"
/>
</div>
</UButton>
<div
v-if="loading"
class="flex items-center justify-center py-4"
class="flex items-center justify-center py-6"
>
<UIcon
name="i-lucide-loader-2"
@@ -35,19 +33,17 @@
<div
v-else-if="isExpanded && changes.length > 0 && canAccessTaskHistory"
class="space-y-2 max-h-64 overflow-y-auto mt-2"
class="flex flex-col gap-1 rounded-2xl bg-elevated p-2 max-h-64 overflow-y-auto"
>
<div
v-for="(item, index) in changes"
:key="index"
class="flex items-center justify-between p-2 rounded-lg border border-default hover:bg-elevated transition-colors"
class="flex items-center gap-2 px-3 py-2.5 rounded-xl bg-default shadow-xs"
>
<div class="flex-1 min-w-0">
<p class="text-sm truncate">
{{ item.description }}
</p>
</div>
<div class="flex items-center gap-1">
<p class="flex-1 min-w-0 text-sm truncate">
{{ item.description }}
</p>
<div class="flex items-center gap-0.5 shrink-0">
<UButton
:disabled="!canAccessTaskHistory"
icon="i-lucide-info"
@@ -72,38 +68,27 @@
<div
v-else-if="isExpanded && changes.length === 0"
class="flex flex-col items-center justify-center py-4 text-muted"
class="flex flex-col items-center justify-center gap-2 rounded-2xl bg-elevated py-8 text-muted"
>
<UIcon
name="i-lucide-history"
class="size-8 mb-2"
class="size-8"
/>
<p class="text-sm">
{{ t('history.empty') }}
</p>
</div>
<UModal v-model:open="isDetailModalOpen">
<template #content>
<UCard>
<template #header>
<div class="flex items-center justify-between">
<h3 class="text-lg font-medium">
{{ t('history.details') }}
</h3>
<UButton
icon="i-lucide-x"
color="neutral"
variant="ghost"
size="xs"
@click="isDetailModalOpen = false"
/>
</div>
</template>
<p class="text-sm whitespace-pre-wrap break-words">
{{ selectedChange?.description }}
</p>
</UCard>
<UModal
v-model:open="isDetailModalOpen"
:fullscreen="isMobile"
:title="t('history.details')"
:ui="{ content: 'sm:max-w-md' }"
>
<template #body>
<p class="text-sm whitespace-pre-wrap break-words">
{{ selectedChange?.description }}
</p>
</template>
</UModal>
</div>
@@ -116,6 +101,8 @@ import { storeToRefs } from 'pinia'
import { useTaskHistory } from '@/stores/task-history.store'
import type { TaskItem } from '@/types/tasks.types'
import { useGoalPermissions } from '@/composables/useGoalPermissions'
import { useNuxtUiTaskItemStyles } from '@/composables/useNuxtUiTaskItemStyles'
import { useTaskView } from '@/composables/useTaskView'
type HistoryChange = {
historyId: number | null
@@ -129,6 +116,8 @@ const props = defineProps<{
const { t } = useI18n()
const taskHistoryStore = useTaskHistory()
const { canAccessTaskHistory, canRecoveryTaskHistory } = useGoalPermissions()
const { activatorUi } = useNuxtUiTaskItemStyles()
const { isMobile } = useTaskView()
const { history } = storeToRefs(taskHistoryStore)
const isExpanded = ref(false)
@@ -1,6 +1,6 @@
<template>
<div
class="flex flex-col gap-3 p-3 rounded-lg border border-default hover:bg-elevated transition-colors cursor-pointer"
class="flex flex-col gap-3 p-3.5 rounded-2xl hover:bg-elevated bg-accented/20 transition-colors cursor-pointer"
:data-testid="`task-item-${task.id}`"
@click="handleOpenTask"
>
@@ -22,8 +22,8 @@
<div class="flex-1 min-w-0 self-start flex flex-col gap-1">
<p
class="text-base break-all"
:class="{ 'line-through text-muted': localComplete }"
class="text-base wrap-break-word"
:class="{ 'text-muted': localComplete }"
>
{{ task.description }}
</p>
@@ -39,7 +39,7 @@
icon="i-lucide-calendar"
:color="isOverdue ? 'error' : 'neutral'"
:class="isOverdue ? '' : 'bg-elevated text-muted'"
:ui="{ leadingIcon: 'size-3' }"
:ui="{ base: 'rounded-md', leadingIcon: 'size-3' }"
class="max-w-full"
variant="subtle"
size="md"
@@ -51,7 +51,7 @@
icon="i-lucide-repeat"
color="neutral"
class="bg-elevated text-muted"
:ui="{ leadingIcon: 'size-3' }"
:ui="{ base: 'rounded-md', leadingIcon: 'size-3' }"
variant="subtle"
size="md"
/>
@@ -63,7 +63,7 @@
icon="i-lucide-folder"
color="neutral"
class="bg-elevated text-muted max-w-full"
:ui="{ leadingIcon: 'size-3' }"
:ui="{ base: 'rounded-md', leadingIcon: 'size-3' }"
variant="subtle"
size="md"
/>
@@ -75,7 +75,7 @@
icon="i-lucide-list"
color="neutral"
class="bg-elevated text-muted max-w-full"
:ui="{ leadingIcon: 'size-3' }"
:ui="{ base: 'rounded-md', leadingIcon: 'size-3' }"
variant="subtle"
size="md"
/>
@@ -86,7 +86,7 @@
:label="formattedAmount"
:icon="transactionIcon"
:color="transactionColor"
:ui="{ leadingIcon: 'size-3' }"
:ui="{ base: 'rounded-md', leadingIcon: 'size-3' }"
variant="subtle"
size="md"
class="max-w-full"
@@ -100,7 +100,7 @@
icon="i-lucide-user"
color="info"
class="bg-elevated text-muted max-w-full"
:ui="{ leadingIcon: 'size-3',}"
:ui="{ base: 'rounded-md', leadingIcon: 'size-3' }"
variant="subtle"
size="md"
/>
@@ -114,7 +114,7 @@
:label="tag.name"
:style="{ backgroundColor: tag.color + '20', color: tag.color }"
variant="subtle"
:ui="{ leadingIcon: 'size-3' }"
:ui="{ base: 'rounded-md', leadingIcon: 'size-3' }"
icon="i-lucide-tag"
size="md"
class="max-w-full"

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