├── .github
└── workflows
│ └── webpack.yml
├── .gitignore
├── LICENSE.md
├── README.md
├── docker-compose.yml
├── docker
├── .dockerignore
├── client.Dockerfile
└── server.Dockerfile
├── docs
├── .babelrc
├── dashboard.png
├── grid.svg
├── help.png
├── package-lock.json
├── package.json
├── postcss.config.js
├── public
│ ├── index.html
│ └── logo.svg
├── src
│ ├── App.js
│ ├── components
│ │ ├── Card.js
│ │ └── Mochi.js
│ ├── index.js
│ └── styles.css
├── tailwind.config.js
└── webpack.config.js
├── logo.svg
├── package.json
├── packages
├── client
│ ├── .gitignore
│ ├── index.html
│ ├── package.json
│ ├── src
│ │ ├── App.module.css
│ │ ├── App.tsx
│ │ ├── Home.module.css
│ │ ├── Home.tsx
│ │ ├── assets
│ │ │ ├── favicon.ico
│ │ │ ├── logo.svg
│ │ │ └── xmas.png
│ │ ├── commandPipeline
│ │ │ ├── commandProcessor.ts
│ │ │ ├── commandRegistry.ts
│ │ │ ├── commands
│ │ │ │ ├── assignTask.command.ts
│ │ │ │ ├── createProject.command.ts
│ │ │ │ ├── createRule.command.ts
│ │ │ │ ├── createTask.command.ts
│ │ │ │ ├── listProjects.command.ts
│ │ │ │ ├── listRules.command.ts
│ │ │ │ └── toggleDraft.command.ts
│ │ │ ├── index.ts
│ │ │ └── types.ts
│ │ ├── components
│ │ │ ├── Kanban
│ │ │ │ ├── TaskCard
│ │ │ │ │ ├── TaskCard.module.css
│ │ │ │ │ └── TaskCard.tsx
│ │ │ │ └── TaskColumn
│ │ │ │ │ ├── TaskColumn.module.css
│ │ │ │ │ └── TaskColumn.tsx
│ │ │ ├── SetupWizard
│ │ │ │ ├── setupWizard.module.css
│ │ │ │ └── setupWizard.tsx
│ │ │ ├── TimeTrack
│ │ │ │ ├── Appointment
│ │ │ │ │ ├── Appointment.module.css
│ │ │ │ │ └── Appointment.tsx
│ │ │ │ ├── TimeMarker
│ │ │ │ │ ├── TimeMarker.module.css
│ │ │ │ │ └── TimeMarker.tsx
│ │ │ │ ├── WeekCalendar
│ │ │ │ │ ├── WeekCalendar.module.css
│ │ │ │ │ └── WeekCalendar.tsx
│ │ │ │ ├── WeekCalendarBody
│ │ │ │ │ ├── WeekCalendarBody.module.css
│ │ │ │ │ └── WeekCalendarBody.tsx
│ │ │ │ ├── WeekCalendarHeader
│ │ │ │ │ ├── WeekCalendarHeader.module.css
│ │ │ │ │ └── WeekCalendarHeader.tsx
│ │ │ │ └── WeekCalendarHourRow
│ │ │ │ │ ├── WeekCalendarHourRow.module.css
│ │ │ │ │ └── WeekCalendarHourRow.tsx
│ │ │ ├── modals
│ │ │ │ ├── BaseModal
│ │ │ │ │ ├── BaseModal.module.css
│ │ │ │ │ └── BaseModal.tsx
│ │ │ │ ├── BranchNameModal
│ │ │ │ │ ├── BranchNameModal.module.css
│ │ │ │ │ └── BranchNameModal.tsx
│ │ │ │ ├── DeleteModal
│ │ │ │ │ └── DeleteModal.tsx
│ │ │ │ ├── EditAppointmentModal
│ │ │ │ │ ├── EditAppointmentModal.module.css
│ │ │ │ │ └── EditAppointmentModal.tsx
│ │ │ │ ├── EditOrCreateTaskModal
│ │ │ │ │ ├── EditOrCreateTaskModal.module.css
│ │ │ │ │ └── EditOrCreateTaskModal.tsx
│ │ │ │ ├── HelpModal
│ │ │ │ │ ├── HelpModal.module.css
│ │ │ │ │ └── HelpModal.tsx
│ │ │ │ ├── PipelineModal
│ │ │ │ │ ├── PipelineModal.module.css
│ │ │ │ │ └── PipelineModal.tsx
│ │ │ │ ├── ReplyModal
│ │ │ │ │ ├── ReplyModal.module.css
│ │ │ │ │ └── ReplyModal.tsx
│ │ │ │ ├── TaskDetailsModal
│ │ │ │ │ ├── TaskDetailsModal.module.css
│ │ │ │ │ ├── TaskDetailsModal.tsx
│ │ │ │ │ └── task-details-modal.store.ts
│ │ │ │ └── index.ts
│ │ │ └── shared
│ │ │ │ ├── Badge
│ │ │ │ ├── Badge.module.css
│ │ │ │ └── Badge.tsx
│ │ │ │ ├── Button
│ │ │ │ ├── Button.module.css
│ │ │ │ └── Button.tsx
│ │ │ │ ├── Card
│ │ │ │ ├── Card.module.css
│ │ │ │ └── Card.tsx
│ │ │ │ ├── CommandLine
│ │ │ │ ├── CommandLine.module.css
│ │ │ │ └── CommandLine.tsx
│ │ │ │ ├── Comment
│ │ │ │ ├── Comment.module.css
│ │ │ │ └── Comment.tsx
│ │ │ │ ├── DiscussionCard
│ │ │ │ ├── DiscussionCard.module.css
│ │ │ │ └── DiscussionCard.tsx
│ │ │ │ ├── Header
│ │ │ │ ├── Header.module.css
│ │ │ │ └── Header.tsx
│ │ │ │ ├── Loading
│ │ │ │ ├── Loading.module.css
│ │ │ │ └── Loading.tsx
│ │ │ │ ├── Notification
│ │ │ │ ├── Notification.module.css
│ │ │ │ └── Notification.tsx
│ │ │ │ ├── NotificationManager.tsx
│ │ │ │ ├── Pagination
│ │ │ │ ├── Pagination.module.css
│ │ │ │ └── Pagination.tsx
│ │ │ │ ├── StatusBar
│ │ │ │ ├── StatusBar.module.css
│ │ │ │ └── StatusBar.tsx
│ │ │ │ ├── Tooltip
│ │ │ │ ├── Tooltip.module.css
│ │ │ │ └── Tooltip.tsx
│ │ │ │ └── WaveText
│ │ │ │ ├── WaveText.module.css
│ │ │ │ └── WaveText.tsx
│ │ ├── constants.ts
│ │ ├── index.tsx
│ │ ├── logo.svg
│ │ ├── modules
│ │ │ ├── KanbanBoard
│ │ │ │ ├── KanbanBoard.module.css
│ │ │ │ └── KanbanBoard.tsx
│ │ │ ├── TimeTrack
│ │ │ │ ├── TimeTrack.module.css
│ │ │ │ └── TimeTrack.tsx
│ │ │ └── Todos
│ │ │ │ ├── Todos.module.css
│ │ │ │ └── Todos.tsx
│ │ ├── routes.ts
│ │ ├── services
│ │ │ ├── customProjectService.ts
│ │ │ ├── gitlabService.ts
│ │ │ ├── keyboardShortcutHandler.ts
│ │ │ ├── modalService.ts
│ │ │ ├── notificationService.ts
│ │ │ ├── ruleService.ts
│ │ │ ├── settingsService.ts
│ │ │ ├── taskNavigationService.ts
│ │ │ ├── taskService.ts
│ │ │ ├── timeTrackService.ts
│ │ │ ├── timetrackNavigationService.ts
│ │ │ ├── uiService.ts
│ │ │ └── userService.ts
│ │ ├── shortcutMaps
│ │ │ ├── baseShortcutMap.ts
│ │ │ ├── kanbanShortcutMap.ts
│ │ │ ├── shortcutRegistry.ts
│ │ │ ├── taskDetails.shortcut-map.ts
│ │ │ ├── timetrackShortcutMap.ts
│ │ │ └── types.ts
│ │ ├── sockets
│ │ │ ├── WebSocketHandler.ts
│ │ │ └── taskSockets.ts
│ │ ├── stores
│ │ │ ├── commandStore.ts
│ │ │ ├── discussion.store.ts
│ │ │ ├── keyboardNavigationStore.ts
│ │ │ ├── modalStore.ts
│ │ │ ├── ruleStore.ts
│ │ │ ├── settings.store.ts
│ │ │ ├── taskStore.ts
│ │ │ ├── timeTrackStore.ts
│ │ │ ├── todoStore.ts
│ │ │ └── uiStore.ts
│ │ ├── styles
│ │ │ ├── base.css
│ │ │ ├── notification.css
│ │ │ ├── scrollbar.css
│ │ │ └── variables.css
│ │ └── utils
│ │ │ ├── orderLabels.ts
│ │ │ ├── parseMarkdown.ts
│ │ │ └── scrollIntoView.ts
│ ├── tsconfig.json
│ └── vite.config.ts
├── server
│ ├── background-jobs
│ │ └── closeMergedMergeRequests.ts
│ ├── bunfig.toml
│ ├── clients
│ │ └── gitlab.client.ts
│ ├── controllers
│ │ ├── gitlab.controller.ts
│ │ ├── project.controller.ts
│ │ ├── rule.controller.ts
│ │ ├── settings.controller.ts
│ │ ├── task.controller.ts
│ │ └── timeTrack.controller.ts
│ ├── decorators
│ │ ├── ruleAction.decorator.ts
│ │ ├── ruleEvent.decorator.ts
│ │ └── transactional.decorator.ts
│ ├── dtos
│ │ └── task.dto.ts
│ ├── errors
│ │ ├── gitlab.error.ts
│ │ └── mochi.error.ts
│ ├── events
│ │ ├── eventEmitterHandler.ts
│ │ ├── eventRegistry.ts
│ │ └── eventTypes.ts
│ ├── middlewares
│ │ ├── context.middleware.ts
│ │ └── globalErrorHandler.middleware.ts
│ ├── models
│ │ ├── appState.model.ts
│ │ ├── discussion.model.ts
│ │ ├── event.model.ts
│ │ ├── project.model.ts
│ │ ├── rule.model.ts
│ │ ├── setting.model.ts
│ │ ├── task.model.ts
│ │ ├── timeTrack.model.ts
│ │ └── user.model.ts
│ ├── package.json
│ ├── repositories
│ │ ├── appState.repo.ts
│ │ ├── base.repo.ts
│ │ ├── discussion.repo.ts
│ │ ├── project.repo.ts
│ │ ├── rule.repo.ts
│ │ ├── setting.repo.ts
│ │ ├── task.repo.ts
│ │ ├── timeTrack.repo.ts
│ │ └── user.repo.ts
│ ├── routes
│ │ ├── gitlab.routes.ts
│ │ ├── project.router.ts
│ │ ├── rule.router.ts
│ │ ├── setting.router.ts
│ │ ├── task.router.ts
│ │ └── timeTrack.router.ts
│ ├── server.ts
│ ├── services
│ │ ├── actions
│ │ │ ├── gitlabActionHandler.ts
│ │ │ ├── index.ts
│ │ │ └── taskActionHandler.ts
│ │ ├── appState.service.ts
│ │ ├── base.service.ts
│ │ ├── discussion.service.ts
│ │ ├── emitters
│ │ │ ├── index.ts
│ │ │ └── taskEventEmitter.ts
│ │ ├── gitlab.service.ts
│ │ ├── project.service.ts
│ │ ├── rule.service.ts
│ │ ├── settings.service.ts
│ │ ├── task.service.ts
│ │ ├── timeTrack.service.ts
│ │ └── user.service.ts
│ ├── sockets
│ │ └── index.ts
│ ├── syncs
│ │ ├── gitlab.sync.ts
│ │ └── gitlab
│ │ │ ├── noteCount.processor.ts
│ │ │ └── pipeline.processor.ts
│ ├── tsconfig.json
│ └── utils
│ │ ├── asyncContext.ts
│ │ ├── chunkArray.ts
│ │ ├── fetchAllFromPaginatedApi.ts
│ │ ├── findFile.ts
│ │ ├── logger.ts
│ │ ├── mochiResult.ts
│ │ ├── taskUtils.ts
│ │ └── transformHelpers.ts
└── shared
│ ├── enums
│ ├── index.ts
│ └── settings.ts
│ ├── index.ts
│ ├── package.json
│ ├── types
│ ├── fieldProcessor.ts
│ ├── index.ts
│ ├── pagination.ts
│ ├── request.ts
│ ├── syncer.ts
│ └── task.ts
│ └── utils
│ ├── isEqual.ts
│ └── random.ts
├── scripts
└── setup.sh
└── tsconfig.json
/.github/workflows/webpack.yml:
--------------------------------------------------------------------------------
1 | name: NodeJS with Webpack
2 |
3 | on:
4 | push:
5 | branches: [ "main" ]
6 | pull_request:
7 | branches: [ "main" ]
8 |
9 | jobs:
10 | build:
11 | runs-on: ubuntu-latest
12 |
13 | strategy:
14 | matrix:
15 | node-version: [18.x, 20.x, 22.x]
16 |
17 | steps:
18 | - uses: actions/checkout@v4
19 |
20 | - name: Use Node.js ${{ matrix.node-version }}
21 | uses: actions/setup-node@v4
22 | with:
23 | node-version: ${{ matrix.node-version }}
24 |
25 | - name: Build
26 | run: |
27 | npm install
28 | npm start
29 |
--------------------------------------------------------------------------------
/LICENSE.md:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2024 Maximilian Kriegl
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Mochi - GitLab-Integrated Kanban Board
2 |
3 |
4 |
5 | Mochi is a **keyboard-friendly, GitLab-integrated Kanban board** that makes task management efficient and intuitive. Organize your GitLab issues, handle tasks via keyboard shortcuts, and keep everything in sync with GitLab—all in one place.
6 |
7 |
8 |
9 | ## Features
10 |
11 | - **Kanban**: Sort tasks by state and track progress visually.
12 | - **GitLab Integration**: Sync tasks, issues, merge requests, comments and more.
13 | - **Keyboard-Driven**: Navigate, move tasks, and open details without a mouse.
14 | - **Pipeline Status at a Glance**: Keep up with CI/CD progress easily.
15 |
16 | ## Prerequisites
17 |
18 | - **Node.js**
19 | - **docker**
20 |
21 | ## Installation
22 |
23 | **Clone the repository**:
24 |
25 | ```bash
26 | git clone https://github.com/Coding0tter/GIT-Mochi.git
27 | cd mochi
28 | ```
29 |
30 | 2. **Start Mochi**:
31 |
32 | ```bash
33 | docker-compose up -d
34 | ```
35 |
36 | - Mochi will start at http://localhost:3005
37 |
38 | 3. **Complete the Wizard and you're good to go**
39 |
40 | ## Keyboard Shortcuts
41 |
42 |
43 |
44 | Open the help modal with `?`
45 |
46 | ## GitLab Syncing
47 |
48 | Make sure your **[GitLab Personal Access Token](https://docs.github.com/en/authentication/keeping-your-account-and-data-secure/managing-your-personal-access-tokens)** is set in the `.env` file before syncing. Use `Shift + S` for syncing manually. Git-Mochi syncs data every 5 minutes automatically
49 |
50 | ## Support the Project
51 |
52 | If you find Mochi helpful, please consider [buying me a coffee](https://www.buymeacoffee.com/maxikriegl)!
53 |
54 | [
](https://www.buymeacoffee.com/maxikriegl)
55 |
56 | ## License
57 |
58 | Mochi is released under the [MIT License](https://github.com/Coding0tter/GIT-Mochi/blob/main/LICENSE.md).
59 |
--------------------------------------------------------------------------------
/docker-compose.yml:
--------------------------------------------------------------------------------
1 | services:
2 | client:
3 | build:
4 | context: .
5 | dockerfile: ./docker/client.Dockerfile
6 | ports:
7 | - "3005:3005"
8 | depends_on:
9 | - server
10 | volumes:
11 | - ./packages/client:/app/packages/client
12 | - ./packages/shared:/app/packages/shared
13 |
14 | server:
15 | build:
16 | context: .
17 | dockerfile: ./docker/server.Dockerfile
18 | volumes:
19 | - ./packages/server:/app/packages/server
20 | - ./packages/shared:/app/packages/shared
21 | ports:
22 | - "5000:5000"
23 | - "6499:6499"
24 | depends_on:
25 | - mongo
26 |
27 | mongo:
28 | image: mongo:latest
29 | restart: always
30 | ports:
31 | - "27018:27017"
32 | volumes:
33 | - mongo-data:/data/db
34 |
35 | volumes:
36 | mongo-data:
37 |
--------------------------------------------------------------------------------
/docker/.dockerignore:
--------------------------------------------------------------------------------
1 | node_modules
--------------------------------------------------------------------------------
/docker/client.Dockerfile:
--------------------------------------------------------------------------------
1 | FROM oven/bun:latest
2 |
3 | WORKDIR /app
4 | COPY . .
5 | RUN bun install
6 | WORKDIR /app/packages/client
7 |
8 | CMD ["bunx", "vite"]
9 | EXPOSE 3005
--------------------------------------------------------------------------------
/docker/server.Dockerfile:
--------------------------------------------------------------------------------
1 | FROM oven/bun:latest
2 |
3 | WORKDIR /app
4 | COPY . .
5 | RUN bun install
6 | WORKDIR /app/packages/server
7 |
8 | CMD ["bun", "dev"]
9 |
10 | EXPOSE 6499
11 | EXPOSE 5000
--------------------------------------------------------------------------------
/docs/.babelrc:
--------------------------------------------------------------------------------
1 | {
2 | "presets": ["@babel/preset-env", "@babel/preset-react"]
3 | }
4 |
--------------------------------------------------------------------------------
/docs/dashboard.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Coding0tter/GIT-Mochi/4ad5e729df950c7000eb5fbaab49eac92a47c017/docs/dashboard.png
--------------------------------------------------------------------------------
/docs/grid.svg:
--------------------------------------------------------------------------------
1 |
4 |
5 |
--------------------------------------------------------------------------------
/docs/help.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Coding0tter/GIT-Mochi/4ad5e729df950c7000eb5fbaab49eac92a47c017/docs/help.png
--------------------------------------------------------------------------------
/docs/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "docs",
3 | "version": "1.0.0",
4 | "main": "index.js",
5 | "scripts": {
6 | "start": "webpack serve --mode development --open",
7 | "build": "webpack --mode production",
8 | "deploy": "gh-pages -d dist"
9 | },
10 | "keywords": [],
11 | "author": "",
12 | "license": "ISC",
13 | "description": "",
14 | "dependencies": {
15 | "lucide-react": "^0.468.0",
16 | "react": "^19.0.0",
17 | "react-dom": "^19.0.0"
18 | },
19 | "devDependencies": {
20 | "@babel/core": "^7.26.0",
21 | "@babel/preset-env": "^7.26.0",
22 | "@babel/preset-react": "^7.26.3",
23 | "autoprefixer": "^10.4.20",
24 | "babel-loader": "^9.2.1",
25 | "css-loader": "^7.1.2",
26 | "gh-pages": "^6.2.0",
27 | "html-webpack-plugin": "^5.6.3",
28 | "postcss": "^8.4.49",
29 | "postcss-loader": "^8.1.1",
30 | "style-loader": "^4.0.0",
31 | "tailwindcss": "^3.4.16",
32 | "webpack": "^5.97.1",
33 | "webpack-cli": "^5.1.4",
34 | "webpack-dev-server": "^5.2.0"
35 | }
36 | }
37 |
--------------------------------------------------------------------------------
/docs/postcss.config.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | plugins: {
3 | tailwindcss: {},
4 | autoprefixer: {},
5 | },
6 | };
7 |
--------------------------------------------------------------------------------
/docs/public/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 | React with Webpack
7 |
8 |
9 |
10 |
11 |
12 |
--------------------------------------------------------------------------------
/docs/src/components/Card.js:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import {
3 | Navigation,
4 | ListTodo,
5 | Settings,
6 | Zap,
7 | Filter,
8 | RefreshCw,
9 | } from "lucide-react";
10 | import "../styles.css";
11 |
12 | const iconMap = {
13 | navigation: Navigation,
14 | tasks: ListTodo,
15 | customize: Settings,
16 | zen: Zap,
17 | filter: Filter,
18 | sync: RefreshCw,
19 | };
20 |
21 | const Card = ({ title, description, icon }) => {
22 | const Icon = iconMap[icon] || Navigation;
23 |
24 | return (
25 |
26 |
27 |
28 |
29 |
{title}
30 |
{description}
31 |
32 | );
33 | };
34 |
35 | export default Card;
36 |
--------------------------------------------------------------------------------
/docs/src/components/Mochi.js:
--------------------------------------------------------------------------------
1 | import React, { useEffect, useRef } from "react";
2 |
3 | export function MochiBlob() {
4 | const canvasRef = useRef(null);
5 |
6 | useEffect(() => {
7 | const canvas = canvasRef.current;
8 | if (!canvas) return;
9 |
10 | const ctx = canvas.getContext("2d");
11 | if (!ctx) return;
12 |
13 | canvas.width = window.innerWidth;
14 | canvas.height = window.innerHeight;
15 |
16 | let time = 0;
17 | const color = "#FFC0CB"; // Light pink color
18 |
19 | function animate() {
20 | ctx.clearRect(0, 0, canvas.width, canvas.height);
21 | ctx.fillStyle = color;
22 | ctx.beginPath();
23 |
24 | const w = canvas.width;
25 | const h = canvas.height;
26 | const scale = Math.min(w, h) * 0.3;
27 |
28 | for (let i = 0; i < 360; i++) {
29 | const angle = (i * Math.PI) / 180;
30 | const x =
31 | w / 2 +
32 | Math.cos(angle) * scale * (1 + Math.sin(time + i * 0.05) * 0.1);
33 | const y =
34 | h / 2 +
35 | Math.sin(angle) * scale * (1 + Math.sin(time + i * 0.05) * 0.1);
36 |
37 | if (i === 0) {
38 | ctx.moveTo(x, y);
39 | } else {
40 | ctx.lineTo(x, y);
41 | }
42 | }
43 |
44 | ctx.closePath();
45 | ctx.fill();
46 |
47 | time += 0.01;
48 | requestAnimationFrame(animate);
49 | }
50 |
51 | animate();
52 |
53 | const handleResize = () => {
54 | canvas.width = window.innerWidth;
55 | canvas.height = window.innerHeight;
56 | };
57 |
58 | window.addEventListener("resize", handleResize);
59 |
60 | return () => {
61 | window.removeEventListener("resize", handleResize);
62 | };
63 | }, []);
64 |
65 | return (
66 |
70 | );
71 | }
72 |
--------------------------------------------------------------------------------
/docs/src/index.js:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import { createRoot } from "react-dom/client";
3 | import App from "./App";
4 |
5 | const domNode = document.getElementById("root");
6 | const root = createRoot(domNode);
7 |
8 | root.render();
9 |
--------------------------------------------------------------------------------
/docs/src/styles.css:
--------------------------------------------------------------------------------
1 | @tailwind base;
2 | @tailwind components;
3 | @tailwind utilities;
4 |
--------------------------------------------------------------------------------
/docs/tailwind.config.js:
--------------------------------------------------------------------------------
1 | /** @type {import('tailwindcss').Config} */
2 | module.exports = {
3 | content: ["./src/**/*.{js,jsx,ts,tsx}", "./public/index.html"], // Update paths as needed
4 | theme: {
5 | extend: {},
6 | },
7 | plugins: [],
8 | };
9 |
--------------------------------------------------------------------------------
/docs/webpack.config.js:
--------------------------------------------------------------------------------
1 | const path = require("path");
2 | const HtmlWebpackPlugin = require("html-webpack-plugin");
3 |
4 | module.exports = {
5 | entry: path.join(__dirname, "src", "index.js"),
6 | output: {
7 | path: path.resolve(__dirname, "dist"),
8 | },
9 | plugins: [
10 | new HtmlWebpackPlugin({
11 | template: path.join(__dirname, "public", "index.html"),
12 | }),
13 | ],
14 | module: {
15 | rules: [
16 | {
17 | test: /\.js$/,
18 | exclude: /node_modules/,
19 | use: {
20 | loader: "babel-loader",
21 | options: {
22 | presets: ["@babel/preset-env", "@babel/preset-react"],
23 | },
24 | },
25 | },
26 | {
27 | test: /\.css$/i,
28 | use: [
29 | "style-loader",
30 | "css-loader",
31 | {
32 | loader: "postcss-loader",
33 | options: {
34 | postcssOptions: {
35 | plugins: [require("tailwindcss"), require("autoprefixer")],
36 | },
37 | },
38 | },
39 | ],
40 | },
41 | ],
42 | },
43 | devServer: {
44 | port: 3000,
45 | },
46 | };
47 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "git-mochi",
3 | "private": true,
4 | "workspaces": [
5 | "packages/*"
6 | ]
7 | }
8 |
--------------------------------------------------------------------------------
/packages/client/.gitignore:
--------------------------------------------------------------------------------
1 | node_modules
2 | dist
--------------------------------------------------------------------------------
/packages/client/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
12 | Mochi
13 |
14 |
24 |
25 |
26 |
27 |
28 |
29 |
30 |
31 |
--------------------------------------------------------------------------------
/packages/client/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "author": {
3 | "name": "Coding0tter",
4 | "url": "https://github.com/Coding0tter"
5 | },
6 | "name": "mochi-client",
7 | "version": "0.0.0",
8 | "type": "module",
9 | "scripts": {
10 | "start": "vite",
11 | "dev": "vite",
12 | "build": "vite build",
13 | "serve": "vite preview"
14 | },
15 | "license": "MIT",
16 | "devDependencies": {
17 | "bun-types": "^1.1.38",
18 | "solid-devtools": "^0.29.3",
19 | "typescript": "^5.7.2",
20 | "vite": "^5.4.11",
21 | "vite-plugin-solid": "^2.11.0"
22 | },
23 | "dependencies": {
24 | "shared": "file:../shared",
25 | "@solidjs/router": "^0.15.1",
26 | "@types/dompurify": "^3.2.0",
27 | "@types/lodash": "^4.17.13",
28 | "axios": "^1.7.9",
29 | "dayjs": "^1.11.13",
30 | "dompurify": "^3.2.3",
31 | "fuse.js": "^7.0.0",
32 | "lodash": "^4.17.21",
33 | "marked": "^15.0.3",
34 | "socket.io-client": "^4.8.1",
35 | "solid-js": "^1.9.3",
36 | "solid-motionone": "^1.0.2"
37 | }
38 | }
39 |
--------------------------------------------------------------------------------
/packages/client/src/App.module.css:
--------------------------------------------------------------------------------
1 | .app {
2 | display: flex;
3 | flex-direction: column;
4 | height: 100vh;
5 | }
6 |
7 | .container {
8 | flex-shrink: 0;
9 | }
10 |
11 | .content {
12 | flex-grow: 1;
13 | height: auto;
14 | overflow-y: scroll;
15 | }
16 |
--------------------------------------------------------------------------------
/packages/client/src/App.tsx:
--------------------------------------------------------------------------------
1 | import { HelpModal } from "@client/components/modals";
2 | import Header from "@client/components/shared/Header/Header";
3 | import NotificationManager from "@client/components/shared/NotificationManager";
4 | import StatusBar from "@client/components/shared/StatusBar/StatusBar";
5 | import { handleKeyDown } from "@client/services/keyboardShortcutHandler";
6 | import { getUserAsync } from "@client/services/userService";
7 | import {
8 | setSelectedAppointmentForModal,
9 | modalStore,
10 | ModalType,
11 | handleCloseModal,
12 | } from "@client/stores/modalStore";
13 | import {
14 | fetchRecordingStateAsync,
15 | fetchTimeTrackEntries,
16 | timeTrackStore,
17 | } from "@client/stores/timeTrackStore";
18 | import { useNavigate, useLocation } from "@solidjs/router";
19 | import dayjs from "dayjs";
20 | import { onMount, onCleanup, Show } from "solid-js";
21 | import styles from "./App.module.css";
22 |
23 | import weekday from "dayjs/plugin/weekday";
24 | import { getGitlabUrl } from "./stores/settings.store";
25 |
26 | dayjs.extend(weekday);
27 |
28 | const App = (props: any) => {
29 | const navigator = useNavigate();
30 | const location = useLocation();
31 | const keydownHandler = (event: KeyboardEvent) =>
32 | handleKeyDown(event, navigator, location);
33 |
34 | onMount(async () => {
35 | if (location.pathname === "/setup") {
36 | return;
37 | }
38 |
39 | await getGitlabUrl();
40 | await fetchRecordingStateAsync();
41 | await fetchTimeTrackEntries();
42 | await getUserAsync();
43 | setSelectedAppointmentForModal(timeTrackStore.entries?.at(0) || null);
44 | window.addEventListener("keydown", keydownHandler);
45 | });
46 |
47 | onCleanup(() => {
48 | window.removeEventListener("keydown", keydownHandler);
49 | });
50 |
51 | return (
52 |
53 |
54 |
55 |
56 |
57 |
58 |
59 |
{props.children}
60 |
61 |
62 |
63 |
64 |
65 |
66 | {modalStore.activeModals.includes(ModalType.Help) && (
67 |
68 | )}
69 |
70 | );
71 | };
72 |
73 | export default App;
74 |
--------------------------------------------------------------------------------
/packages/client/src/Home.module.css:
--------------------------------------------------------------------------------
1 | .homeWrapper {
2 | padding-inline: var(--spacing-sm);
3 | height: calc(100% - 30px - var(--spacing-sm));
4 | }
5 |
6 | .home {
7 | color: var(--color-text);
8 | background: var(--color-base);
9 | height: 100%;
10 | border-radius: var(--spacing-md);
11 | display: flex;
12 | flex-direction: column;
13 | padding-top: var(--spacing-xl);
14 | align-items: center;
15 | text-align: center;
16 | }
17 |
18 | .logo {
19 | width: 150px;
20 | height: 150px;
21 | margin-bottom: var(--spacing-lg);
22 | }
23 |
24 | .homeHeader h1 {
25 | font-size: var(--font-size-xxl);
26 | font-weight: var(--font-weight-bold);
27 | margin-bottom: var(--spacing-md);
28 | }
29 |
30 | .homeHeader p {
31 | font-size: var(--font-size-md);
32 | color: var(--color-subtext1);
33 | margin-bottom: var(--spacing-xl);
34 | }
35 |
36 | .features {
37 | display: flex;
38 | gap: var(--spacing-lg);
39 | justify-content: center;
40 | }
41 |
42 | .featureCard {
43 | cursor: pointer;
44 | background: var(--color-surface0);
45 | border-radius: var(--spacing-md);
46 | padding: var(--spacing-lg);
47 | min-width: 300px;
48 | box-shadow: 0px 4px 12px rgba(0, 0, 0, 0.2);
49 | transition: transform 0.3s ease;
50 | }
51 |
52 | .featureCard:hover {
53 | transform: translateY(-5px);
54 | box-shadow: 0px 6px 16px rgba(0, 0, 0, 0.25);
55 | }
56 |
57 | .featureCard h2 {
58 | font-size: var(--font-size-lg);
59 | color: var(--color-lavender);
60 | margin-bottom: var(--spacing-sm);
61 | }
62 |
63 | .featureCard p {
64 | font-size: var(--font-size-sm);
65 | color: var(--color-subtext0);
66 | margin-bottom: var(--spacing-md);
67 | }
68 |
69 | .featureCard ul {
70 | list-style: none;
71 | padding: 0;
72 | margin: 0;
73 | font-size: var(--font-size-xs);
74 | }
75 |
76 | .featureCard ul li {
77 | padding: var(--spacing-xxs) 0;
78 | color: var(--color-text);
79 | }
80 |
81 | .kanban h2 {
82 | color: var(--color-blue);
83 | }
84 |
85 | .time-tracking h2 {
86 | color: var(--color-peach);
87 | }
88 |
89 | .todo h2 {
90 | color: var(--color-green);
91 | }
92 |
--------------------------------------------------------------------------------
/packages/client/src/Home.tsx:
--------------------------------------------------------------------------------
1 | import { useNavigate } from "@solidjs/router";
2 | import styles from "./Home.module.css";
3 | import logo from "./assets/logo.svg";
4 |
5 | const Home = () => {
6 | const navigator = useNavigate();
7 |
8 | const handleClick = (key: string) => {
9 | navigator(key);
10 | };
11 |
12 | return (
13 |
14 |
15 |
16 |

17 |
18 |
19 |
23 |
24 |
25 |
handleClick("/kanban")}
27 | class={`${styles.featureCard} ${styles.kanban}`}
28 | >
29 |
Kanban Board
30 |
Organize and manage tasks visually.
31 |
32 | Press Shift+1 to open the Kanban board.
33 |
34 |
35 |
36 |
handleClick("/timetrack")}
38 | class={`${styles.featureCard} ${styles.timeTracking}`}
39 | >
40 |
Time-Tracking Tool
41 |
Track hours with ease.
42 |
43 | Press Shift+2 to open the Time-Tracking tool.
44 |
45 |
46 |
47 |
handleClick("/todo")}
49 | class={`${styles.featureCard} ${styles.todo}`}
50 | >
51 |
Todo Tool
52 |
Manage your todos.
53 |
54 | Press Shift+3 to open the Todo tool.
55 |
56 |
57 |
58 |
59 |
60 | );
61 | };
62 |
63 | export default Home;
64 |
--------------------------------------------------------------------------------
/packages/client/src/assets/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Coding0tter/GIT-Mochi/4ad5e729df950c7000eb5fbaab49eac92a47c017/packages/client/src/assets/favicon.ico
--------------------------------------------------------------------------------
/packages/client/src/assets/xmas.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Coding0tter/GIT-Mochi/4ad5e729df950c7000eb5fbaab49eac92a47c017/packages/client/src/assets/xmas.png
--------------------------------------------------------------------------------
/packages/client/src/commandPipeline/commandProcessor.ts:
--------------------------------------------------------------------------------
1 | import type { CommandPipeline } from "@client/commandPipeline/types";
2 | import { unfocusInputs } from "@client/services/uiService";
3 | import {
4 | getActiveDropdownValue,
5 | setActiveDropdownIndex,
6 | resetCommandline,
7 | setDropdownValues,
8 | } from "@client/stores/commandStore";
9 | import {
10 | setCommandInputValue,
11 | setCommandPlaceholder,
12 | } from "@client/stores/uiStore";
13 |
14 | export class CommandProcessor {
15 | private currentStepIndex: number = 0;
16 | private pipeline: CommandPipeline;
17 | private resolveInput?: (input: any) => void; // Stores the resolve function to continue execution
18 |
19 | constructor() {
20 | this.pipeline = {
21 | ...getActiveDropdownValue().value,
22 | };
23 | }
24 |
25 | async start() {
26 | await this.executeCurrentStep();
27 | }
28 |
29 | next = () => {
30 | this.currentStepIndex++;
31 | this.executeCurrentStep();
32 | };
33 |
34 | repeat = () => {
35 | this.executeCurrentStep();
36 | };
37 |
38 | goto = (key: string) => {
39 | this.currentStepIndex = this.pipeline.steps.findIndex(
40 | (step) => step.key === key,
41 | );
42 | this.executeCurrentStep();
43 | };
44 |
45 | private async executeCurrentStep() {
46 | setCommandInputValue("");
47 | setActiveDropdownIndex(0);
48 |
49 | if (this.currentStepIndex >= this.pipeline.steps.length) {
50 | resetCommandline();
51 | unfocusInputs();
52 |
53 | return;
54 | }
55 |
56 | const step = this.pipeline.steps[this.currentStepIndex];
57 |
58 | setCommandPlaceholder(step.prompt);
59 | if (step.dropdownValues)
60 | setDropdownValues(
61 | typeof step.dropdownValues === "function"
62 | ? step.dropdownValues()
63 | : step.dropdownValues,
64 | );
65 | else if (step.cleanDropdown) setDropdownValues([]);
66 |
67 | try {
68 | if (step.awaitInput) {
69 | await step.executeAsync({
70 | input: await this.waitForUserInput(),
71 | next: this.next,
72 | repeat: this.repeat,
73 | goto: this.goto,
74 | });
75 | } else {
76 | await step.executeAsync({
77 | next: this.next,
78 | repeat: this.repeat,
79 | goto: this.goto,
80 | });
81 | }
82 | } catch (error: any) {
83 | if (step.onError) {
84 | step.onError(error, this.repeat);
85 | } else {
86 | console.error("Error:", error.message);
87 | }
88 | }
89 | }
90 |
91 | private waitForUserInput(): Promise {
92 | return new Promise((resolve) => {
93 | this.resolveInput = resolve;
94 | });
95 | }
96 |
97 | receiveInput(input: any) {
98 | if (this.resolveInput) {
99 | this.resolveInput(input);
100 | this.resolveInput = undefined;
101 | }
102 | }
103 | }
104 |
--------------------------------------------------------------------------------
/packages/client/src/commandPipeline/commandRegistry.ts:
--------------------------------------------------------------------------------
1 | import { cloneDeep } from "lodash";
2 | import type { CommandPipeline } from "./types";
3 |
4 | const commandRegistry: CommandPipeline[] = [];
5 |
6 | export const registerCommand = (command: CommandPipeline) => {
7 | if (!commandRegistry.some((cmd) => cmd.name === command.name))
8 | commandRegistry.push(cloneDeep(command));
9 | };
10 |
11 | export const getRegisteredCommands = () => {
12 | return [...commandRegistry];
13 | };
14 |
15 | export const getCommandByName = (name: string) => {
16 | return [...commandRegistry].find((cmd) => cmd.name === name);
17 | };
18 |
19 | export const resetCommandRegistry = () => {
20 | commandRegistry.length = 0;
21 | };
22 |
--------------------------------------------------------------------------------
/packages/client/src/commandPipeline/commands/assignTask.command.ts:
--------------------------------------------------------------------------------
1 | import {
2 | assignTaskAsync,
3 | getGitLabUsersAsync,
4 | } from "../../services/gitlabService";
5 | import { addNotification } from "../../services/notificationService";
6 | import {
7 | getActiveDropdownValue,
8 | setDropdownValues,
9 | } from "../../stores/commandStore";
10 | import { keyboardNavigationStore } from "../../stores/keyboardNavigationStore";
11 | import { getColumnTasks } from "../../stores/taskStore";
12 | import { registerCommand } from "../commandRegistry";
13 | import type { CommandPipeline } from "../types";
14 |
15 | const assignTaskComand: CommandPipeline = {
16 | name: "assignTask",
17 | description: "Assign a task to a user",
18 | steps: [
19 | {
20 | prompt: "Loading users...",
21 | executeAsync: async ({ next }) => {
22 | const users = await getGitLabUsersAsync();
23 |
24 | setDropdownValues(
25 | users.map((user: any) => ({
26 | text: user.name,
27 | value: user,
28 | }))
29 | );
30 |
31 | next();
32 | },
33 | },
34 | {
35 | prompt: "Choose a user to assign the task to",
36 | awaitInput: true,
37 | executeAsync: async ({ next }) => {
38 | const user = getActiveDropdownValue().value;
39 | const selectedTask =
40 | getColumnTasks()[keyboardNavigationStore.selectedTaskIndex];
41 |
42 | await assignTaskAsync(selectedTask._id!, user.id);
43 |
44 | addNotification({
45 | title: "Task assigned",
46 | description: "Task has been assigned",
47 | type: "success",
48 | });
49 |
50 | next();
51 | },
52 | },
53 | ],
54 | };
55 |
56 | registerCommand(assignTaskComand);
57 |
--------------------------------------------------------------------------------
/packages/client/src/commandPipeline/commands/createProject.command.ts:
--------------------------------------------------------------------------------
1 | import {
2 | createProjectAsync,
3 | getProjectAsync,
4 | setProjectAsync,
5 | } from "../../services/customProjectService";
6 | import { addNotification } from "../../services/notificationService";
7 | import {
8 | commandStore,
9 | getActiveDropdownValue,
10 | setBuffer,
11 | } from "../../stores/commandStore";
12 | import { fetchTasksAsync } from "../../stores/taskStore";
13 | import { setCurrentProject } from "../../stores/uiStore";
14 | import { registerCommand } from "../commandRegistry";
15 | import { type CommandPipeline } from "../types";
16 |
17 | const createProjectCommand: CommandPipeline = {
18 | name: "createProject",
19 | description: "Create a new project and set it as active",
20 | steps: [
21 | {
22 | awaitInput: true,
23 | cleanDropdown: true,
24 | executeAsync: async ({ input, next, repeat }) => {
25 | if (input === "") {
26 | repeat();
27 | return;
28 | }
29 |
30 | const project = await createProjectAsync(input);
31 | if (project === null) {
32 | addNotification({
33 | title: "Error",
34 | description: "Failed to create project",
35 | type: "error",
36 | });
37 |
38 | repeat();
39 | return;
40 | }
41 | setBuffer(project._id);
42 | next();
43 | },
44 | prompt: "Enter a name for the new project",
45 | },
46 | {
47 | awaitInput: true,
48 | executeAsync: async ({ next }) => {
49 | const choice = getActiveDropdownValue().value;
50 | if (choice === "yes") {
51 | await setProjectAsync("custom_project/" + commandStore.buffer);
52 | setCurrentProject(await getProjectAsync());
53 |
54 | await fetchTasksAsync();
55 |
56 | addNotification({
57 | title: "Success",
58 | description: "Project set successfully",
59 | type: "success",
60 | });
61 |
62 | next();
63 | }
64 | },
65 | prompt: "Do you want to set the new project as active?",
66 | dropdownValues: [
67 | {
68 | text: "Yes",
69 | value: "yes",
70 | },
71 | {
72 | text: "No",
73 | value: "no",
74 | },
75 | ],
76 | },
77 | ],
78 | };
79 |
80 | registerCommand(createProjectCommand);
81 |
--------------------------------------------------------------------------------
/packages/client/src/commandPipeline/commands/createTask.command.ts:
--------------------------------------------------------------------------------
1 | import { openCreateModal } from "../../services/modalService";
2 | import { registerCommand } from "../commandRegistry";
3 | import { type CommandPipeline } from "../types";
4 |
5 | const createTaskCommand: CommandPipeline = {
6 | name: "createTask",
7 | description: "Create a new task",
8 | steps: [
9 | {
10 | cleanDropdown: true,
11 | executeAsync: async () => {
12 | openCreateModal();
13 | },
14 | prompt: "Opening create task modal...",
15 | },
16 | ],
17 | };
18 |
19 | registerCommand(createTaskCommand);
20 |
--------------------------------------------------------------------------------
/packages/client/src/commandPipeline/commands/toggleDraft.command.ts:
--------------------------------------------------------------------------------
1 | import { toggleDraft } from "../../services/gitlabService";
2 | import { keyboardNavigationStore } from "../../stores/keyboardNavigationStore";
3 | import { getColumnTasks } from "../../stores/taskStore";
4 | import { registerCommand } from "../commandRegistry";
5 | import type { CommandPipeline } from "../types";
6 |
7 | const toggleDraftCommand: CommandPipeline = {
8 | name: "toggleDraft",
9 | description: "Toggle the draft flag",
10 | steps: [
11 | {
12 | prompt: "Updating draft flag...",
13 | executeAsync: async ({ next }) => {
14 | const selectedTask =
15 | getColumnTasks()[keyboardNavigationStore.selectedTaskIndex];
16 |
17 | await toggleDraft(selectedTask._id!);
18 |
19 | next();
20 | },
21 | },
22 | ],
23 | };
24 |
25 | registerCommand(toggleDraftCommand);
26 |
--------------------------------------------------------------------------------
/packages/client/src/commandPipeline/index.ts:
--------------------------------------------------------------------------------
1 | const commandModules = import.meta.glob("./commands/*.command.ts");
2 |
3 | for (const path in commandModules) {
4 | await commandModules[path](); // This will load each command file
5 | }
6 |
7 | import { getRegisteredCommands } from "./commandRegistry";
8 | export const COMMANDS = [...getRegisteredCommands()];
9 |
--------------------------------------------------------------------------------
/packages/client/src/commandPipeline/types.ts:
--------------------------------------------------------------------------------
1 | import type { DropdownValue } from "../stores/commandStore";
2 |
3 | export interface CommandStep {
4 | prompt: string;
5 | key?: string;
6 | dropdownValues?: DropdownValue[] | (() => DropdownValue[]);
7 | awaitInput?: boolean;
8 | cleanDropdown?: boolean;
9 | executeAsync: (props: CommandProps) => Promise;
10 | onError?: (error: Error, repeat: () => void) => void;
11 | }
12 |
13 | export interface CommandProps {
14 | input?: any;
15 | next: () => void;
16 | repeat: () => void;
17 | goto: (key: string) => void;
18 | }
19 |
20 | export interface CommandPipeline {
21 | name: string;
22 | description: string;
23 | steps: CommandStep[];
24 | }
25 |
--------------------------------------------------------------------------------
/packages/client/src/components/Kanban/TaskColumn/TaskColumn.module.css:
--------------------------------------------------------------------------------
1 | .column {
2 | flex: 1;
3 | background: var(--color-base);
4 | border-radius: var(--spacing-md);
5 | padding: var(--spacing-sm);
6 | border: 1px solid rgba(255, 255, 255, 0.1);
7 | box-shadow: 0 8px 30px rgba(0, 0, 0, 0.1);
8 | max-width: 100%;
9 | overflow: hidden;
10 | }
11 |
--------------------------------------------------------------------------------
/packages/client/src/components/TimeTrack/Appointment/Appointment.module.css:
--------------------------------------------------------------------------------
1 | .appointment {
2 | background-color: var(--color-teal);
3 | filter: brightness(80%);
4 | color: var(--color-crust);
5 | border-radius: var(--spacing-xs);
6 | text-align: center;
7 | box-shadow: 0 1px 4px rgba(0, 0, 0, 0.1);
8 | z-index: 1;
9 | width: 100%;
10 |
11 | &.selected {
12 | border: 2px solid var(--color-red);
13 | }
14 | }
15 |
--------------------------------------------------------------------------------
/packages/client/src/components/TimeTrack/Appointment/Appointment.tsx:
--------------------------------------------------------------------------------
1 | import { Dayjs } from "dayjs";
2 | import { createSignal, onMount, onCleanup, createEffect } from "solid-js";
3 | import styles from "./Appointment.module.css";
4 | import { CalendarMode, uiStore } from "../../../stores/uiStore";
5 | import { timeTrackStore } from "../../../stores/timeTrackStore";
6 | import { keyboardNavigationStore } from "../../../stores/keyboardNavigationStore";
7 |
8 | const Appointment = ({
9 | length,
10 | title,
11 | start,
12 | index,
13 | }: {
14 | length: number;
15 | title: string;
16 | start: Dayjs;
17 | index: number;
18 | }) => {
19 | const [minuteHeight, setMinuteHeight] = createSignal(0);
20 |
21 | const calculateMinuteHeight = () => {
22 | const quarterElement = document.getElementById("day-0-hour-8-quarter-0");
23 |
24 | if (quarterElement) {
25 | const quarterHeight = quarterElement.getBoundingClientRect().height;
26 | setMinuteHeight(quarterHeight / 15); // Each quarter has 15 minutes
27 | }
28 | };
29 |
30 | onMount(() => {
31 | calculateMinuteHeight();
32 |
33 | // Recalculate minute height on resize
34 | window.addEventListener("resize", calculateMinuteHeight);
35 |
36 | return () => {
37 | window.removeEventListener("resize", calculateMinuteHeight);
38 | };
39 | });
40 |
41 | const getMarginTop = () => {
42 | const calendarStartHour = 8; // The start hour of your calendar
43 | const startHour = start.hour();
44 | const startMinute = start.minute();
45 |
46 | // Calculate total minutes since the calendar's start
47 | const totalMinutes = (startHour - calendarStartHour) * 60 + startMinute;
48 |
49 | // Return the total pixel margin from the top
50 | return totalMinutes * minuteHeight();
51 | };
52 |
53 | return (
54 |
68 | );
69 | };
70 |
71 | export default Appointment;
72 |
--------------------------------------------------------------------------------
/packages/client/src/components/TimeTrack/TimeMarker/TimeMarker.module.css:
--------------------------------------------------------------------------------
1 | .currentTimeMarker {
2 | position: absolute;
3 | left: 0;
4 | right: 0;
5 | height: 2px;
6 | background-color: var(--color-peach);
7 | z-index: 20;
8 | pointer-events: none;
9 | }
10 |
11 | .time {
12 | position: absolute;
13 | top: -25px;
14 | right: 0;
15 | display: inline-block;
16 | padding: var(--spacing-xxs);
17 | background-color: var(--color-sapphire);
18 | border-radius: var(--spacing-xs);
19 | color: var(--color-base);
20 | border-bottom-right-radius: 0;
21 | }
22 |
--------------------------------------------------------------------------------
/packages/client/src/components/TimeTrack/TimeMarker/TimeMarker.tsx:
--------------------------------------------------------------------------------
1 | import { createSignal, onMount, onCleanup, type Component } from "solid-js";
2 | import styles from "./TimeMarker.module.css";
3 |
4 | interface TimeMarkerProps {
5 | startHour: number;
6 | endHour: number;
7 | }
8 |
9 | const TimeMarker: Component = (props) => {
10 | const [currentTimePosition, setCurrentTimePosition] = createSignal(0);
11 | const [currentTime, setCurrentTime] = createSignal(new Date());
12 |
13 | const updateCurrentTimePosition = () => {
14 | const now = new Date();
15 | setCurrentTime(now);
16 | const totalMinutes = (props.endHour - props.startHour + 1) * 60;
17 | const currentMinutes =
18 | (now.getHours() - props.startHour) * 60 + now.getMinutes();
19 |
20 | const position = Math.max(
21 | Math.min((currentMinutes / totalMinutes) * 100, 99),
22 | 0
23 | );
24 |
25 | setCurrentTimePosition(position);
26 | };
27 |
28 | onMount(async () => {
29 | updateCurrentTimePosition();
30 | const interval = setInterval(updateCurrentTimePosition, 60000);
31 | onCleanup(() => clearInterval(interval));
32 | });
33 |
34 | return (
35 |
41 |
42 | {" "}
43 | {currentTime().toLocaleTimeString([], {
44 | hour: "2-digit",
45 | minute: "2-digit",
46 | hour12: false,
47 | })}
48 |
49 |
50 | );
51 | };
52 |
53 | export default TimeMarker;
54 |
--------------------------------------------------------------------------------
/packages/client/src/components/TimeTrack/WeekCalendar/WeekCalendar.module.css:
--------------------------------------------------------------------------------
1 | .weekCalendar {
2 | width: 100%;
3 | height: 100%;
4 | background: var(--color-mantle);
5 | border: 1px solid rgba(255, 255, 255, 0.1);
6 | border-radius: var(--spacing-md);
7 | position: relative;
8 | display: flex;
9 | flex-direction: column;
10 | overflow-y: auto;
11 | box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
12 | flex-grow: 1; /* Ensure it fills available height */
13 | }
14 |
--------------------------------------------------------------------------------
/packages/client/src/components/TimeTrack/WeekCalendar/WeekCalendar.tsx:
--------------------------------------------------------------------------------
1 | import { createSignal, onMount } from "solid-js";
2 | import WeekCalendarBody from "../WeekCalendarBody/WeekCalendarBody";
3 | import WeekCalendarHeader from "../WeekCalendarHeader/WeekCalendarHeader";
4 | import styles from "./WeekCalendar.module.css";
5 | import {
6 | setSelectedDayIndex,
7 | setSelectedHourIndex,
8 | setSelectedQuarterHourIndex,
9 | setSelectedQuarterHourIndexes,
10 | } from "../../../stores/keyboardNavigationStore";
11 | import { setCalendarHeight } from "../../../stores/uiStore";
12 |
13 | interface WeekCalendarProps {
14 | startHour?: number;
15 | endHour?: number;
16 | }
17 |
18 | function WeekCalendar(props: WeekCalendarProps) {
19 | const startHour = props.startHour ?? 0;
20 | const endHour = props.endHour ?? 23;
21 |
22 | // Signal for the current date
23 | const [currentDay, setCurrentDay] = createSignal(new Date());
24 |
25 | // Calculate the dates for the current week starting from Monday
26 | const [weekDates, setWeekDates] = createSignal([]);
27 |
28 | onMount(() => {
29 | const today = new Date();
30 | const dayOfWeek = (today.getDay() + 6) % 7;
31 | const monday = new Date(today);
32 | monday.setDate(today.getDate() - dayOfWeek);
33 |
34 | const dates = Array.from({ length: 7 }, (_, i) => {
35 | const date = new Date(monday);
36 | date.setDate(monday.getDate() + i);
37 | return date;
38 | });
39 |
40 | const calendarHeight = document
41 | .getElementById("calendar-body")
42 | ?.getBoundingClientRect()
43 | .height.toFixed(4) as unknown as number;
44 |
45 | setCalendarHeight(calendarHeight);
46 |
47 | setSelectedDayIndex(dayOfWeek);
48 | setSelectedHourIndex(today.getHours());
49 | setSelectedQuarterHourIndex(Math.floor(today.getMinutes() / 15));
50 | setSelectedQuarterHourIndexes([
51 | today.getHours() * 4 + Math.floor(today.getMinutes() / 15),
52 | ]);
53 | setWeekDates(dates);
54 | });
55 |
56 | return (
57 |
58 |
59 |
65 |
66 | );
67 | }
68 |
69 | export default WeekCalendar;
70 |
--------------------------------------------------------------------------------
/packages/client/src/components/TimeTrack/WeekCalendarBody/WeekCalendarBody.module.css:
--------------------------------------------------------------------------------
1 | .weekCalendarBody {
2 | flex: 1; /* Allow it to grow within the .weekCalendar */
3 | display: flex;
4 | flex-direction: column;
5 | height: 100%; /* Ensure it takes the full height of .weekCalendar */
6 | position: relative;
7 | }
8 |
--------------------------------------------------------------------------------
/packages/client/src/components/TimeTrack/WeekCalendarBody/WeekCalendarBody.tsx:
--------------------------------------------------------------------------------
1 | import { type Component } from "solid-js";
2 | import styles from "./WeekCalendarBody.module.css";
3 | import TimeMarker from "../TimeMarker/TimeMarker";
4 | import WeekCalendarHourRow from "../WeekCalendarHourRow/WeekCalendarHourRow";
5 |
6 | interface WeekCalendarBodyProps {
7 | weekDates: Date[];
8 | startHour: number;
9 | endHour: number;
10 | currentDay: Date;
11 | }
12 |
13 | const WeekCalendarBody: Component = (props) => {
14 | return (
15 |
16 | {Array.from(
17 | { length: props.endHour - props.startHour + 1 },
18 | (_, idx) => props.startHour + idx
19 | ).map((hour) => (
20 | <>
21 |
26 | >
27 | ))}
28 |
29 |
30 | );
31 | };
32 |
33 | export default WeekCalendarBody;
34 |
--------------------------------------------------------------------------------
/packages/client/src/components/TimeTrack/WeekCalendarHeader/WeekCalendarHeader.module.css:
--------------------------------------------------------------------------------
1 | .weekCalendarHeader {
2 | flex-shrink: 0; /* Prevents it from shrinking in flex layout */
3 | display: flex;
4 | background-color: var(--color-surface1);
5 | border-bottom: 1px solid var(--color-overlay1);
6 | border-top-left-radius: var(--spacing-md);
7 | border-top-right-radius: var(--spacing-md);
8 | box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
9 | }
10 |
11 | .weekCalendarHeaderTimeLabel {
12 | width: 80px;
13 | min-width: 80px;
14 | background-color: var(--color-surface1);
15 | border-right: 1px solid var(--color-overlay1);
16 | box-sizing: border-box;
17 | color: var(--color-subtext1);
18 | display: flex;
19 | align-items: center;
20 | justify-content: center;
21 | font-weight: var(--font-weight-medium);
22 | }
23 |
24 | .weekCalendarHeaderDay {
25 | flex: 1;
26 | padding: var(--spacing-md);
27 | text-align: center;
28 | border-right: 1px solid var(--color-overlay1);
29 | color: var(--color-text);
30 | font-weight: var(--font-weight-semibold);
31 | box-sizing: border-box;
32 | }
33 |
34 | .weekCalendarHeaderDay:last-child {
35 | border-right: none;
36 | }
37 |
38 | .currentDay {
39 | background-color: var(--color-mantle) !important;
40 | }
41 |
42 | .weekCalendarDayName {
43 | font-size: var(--font-size-lg);
44 | font-weight: var(--font-weight-bold);
45 | color: var(--color-peach);
46 | }
47 |
48 | .weekCalendarDate {
49 | font-size: var(--font-size-sm);
50 | color: var(--color-flamingo);
51 | }
52 |
--------------------------------------------------------------------------------
/packages/client/src/components/TimeTrack/WeekCalendarHeader/WeekCalendarHeader.tsx:
--------------------------------------------------------------------------------
1 | import { type Component } from "solid-js";
2 | import styles from "./WeekCalendarHeader.module.css";
3 |
4 | interface WeekCalendarHeaderProps {
5 | weekDates: Date[];
6 | currentDay: Date;
7 | }
8 |
9 | const WeekCalendarHeader: Component = (props) => {
10 | const daysOfWeek = [
11 | "Monday",
12 | "Tuesday",
13 | "Wednesday",
14 | "Thursday",
15 | "Friday",
16 | "Saturday",
17 | "Sunday",
18 | ];
19 |
20 | return (
21 |
38 | );
39 | };
40 |
41 | export default WeekCalendarHeader;
42 |
--------------------------------------------------------------------------------
/packages/client/src/components/TimeTrack/WeekCalendarHourRow/WeekCalendarHourRow.module.css:
--------------------------------------------------------------------------------
1 | .weekCalendarHourRow {
2 | display: flex;
3 | border-bottom: 1px solid var(--color-overlay0);
4 | flex-grow: 1; /* Allows rows to expand and fill remaining space */
5 | }
6 |
7 | .weekCalendarHourLabel {
8 | width: 80px;
9 | min-width: 80px;
10 | padding: var(--spacing-xxs) var(--spacing-xs);
11 | background-color: var(--color-surface2);
12 | border-right: 1px solid var(--color-overlay0);
13 | text-align: right;
14 | font-size: var(--font-size-sm);
15 | color: var(--color-crust);
16 | font-weight: var(--font-weight-bold);
17 | font-size: var(--font-size-sm);
18 | box-sizing: border-box;
19 | display: flex;
20 | justify-content: flex-end;
21 | }
22 |
23 | .weekCalendarDayColumn {
24 | flex: 1;
25 | display: flex;
26 | position: relative;
27 | flex-direction: column;
28 | border-right: 1px solid var(--color-overlay0);
29 | }
30 |
31 | .weekCalendarQuarterHourCell {
32 | flex: 1;
33 | border-bottom: 1px solid rgba(255, 255, 255, 0.05);
34 | box-sizing: border-box;
35 | transition: background-color 0.2s;
36 | }
37 |
38 | .weekCalendarQuarterHourCell:last-child {
39 | border-bottom: none;
40 | }
41 |
42 | .weekCalendarQuarterHourCell:hover {
43 | background-color: var(--color-surface1) !important;
44 | }
45 |
46 | .weekCalendarHourCell.currentHour {
47 | background-color: var(--color-surface1);
48 | }
49 |
50 | .weekCalendarHourCell:hover {
51 | background-color: var(--color-surface1) !important;
52 | }
53 |
54 | .weekCalendarHourCell:last-child {
55 | border-right: none;
56 | }
57 |
58 | .weekCalendarHourRow:last-child {
59 | border-bottom: none;
60 | }
61 |
62 | .selectedCell {
63 | background-color: var(--color-blue) !important;
64 | opacity: 0.5;
65 | z-index: 19;
66 | }
67 |
68 | .activeCell {
69 | border: 2px solid var(--color-red) !important;
70 | backdrop-filter: brightness(80%) !important;
71 | z-index: 20;
72 | margin: -1px;
73 | }
74 |
75 | .currentDay {
76 | background-color: var(--color-crust);
77 | background-clip: content-box;
78 | }
79 |
80 | .appointmentWrapper {
81 | width: 100%;
82 | position: absolute;
83 | top: 0;
84 | display: flex;
85 | flex-direction: row;
86 | flex: 1;
87 | z-index: 21;
88 | gap: var(--spacing-xxs);
89 | }
90 |
--------------------------------------------------------------------------------
/packages/client/src/components/modals/BaseModal/BaseModal.module.css:
--------------------------------------------------------------------------------
1 | .modalWrapper {
2 | background-color: var(--color-base);
3 | border-radius: var(--spacing-sm);
4 | padding: var(--spacing-sm);
5 | box-shadow: 0 4px 12px rgba(0, 0, 0, 1);
6 | display: flex;
7 | flex-direction: column;
8 | gap: var(--spacing-sm);
9 | }
10 |
11 | .modalWrapper.hidden {
12 | animation: modalFadeOut 0.3s forwards;
13 | }
14 |
15 | .modalContent {
16 | color: var(--color-text);
17 | border-radius: var(--spacing-sm);
18 | opacity: 0;
19 | animation: modalFadeIn 0.3s forwards;
20 | }
21 |
22 | .modal.hidden {
23 | animation: modalFadeOut 0.3s forwards;
24 | }
25 |
26 | .modalButtons {
27 | display: flex;
28 | justify-content: flex-end;
29 | gap: var(--spacing-sm);
30 | }
31 |
32 | .modal {
33 | position: fixed;
34 | z-index: 1000;
35 | top: 0;
36 | left: 0;
37 | width: 100%;
38 | height: 100%;
39 | box-sizing: border-box;
40 | padding: 200px;
41 | background-color: rgba(0, 0, 0, 0.5);
42 | display: flex;
43 | align-items: center;
44 | justify-content: center;
45 | opacity: 0;
46 | animation: modalFadeIn 0.3s ease forwards;
47 | }
48 |
49 | .modal header {
50 | font-size: var(--font-size-xl);
51 | font-weight: var(--font-weight-semibold);
52 | color: var(--color-text);
53 | margin-bottom: var(--spacing-md);
54 | }
55 |
56 | .modal p {
57 | font-size: var(--font-size-md);
58 | font-weight: var(--font-weight-regular);
59 | color: var(--color-subtext1);
60 | }
61 |
62 | /* Keyframe Animations */
63 | @keyframes modalFadeIn {
64 | from {
65 | opacity: 0;
66 | }
67 | to {
68 | opacity: 1;
69 | }
70 | }
71 |
72 | @keyframes modalFadeOut {
73 | from {
74 | opacity: 1;
75 | }
76 | to {
77 | opacity: 0;
78 | }
79 | }
80 |
--------------------------------------------------------------------------------
/packages/client/src/components/modals/BaseModal/BaseModal.tsx:
--------------------------------------------------------------------------------
1 | import {
2 | createEffect,
3 | createSignal,
4 | onMount,
5 | type JSXElement,
6 | type ParentComponent,
7 | } from "solid-js";
8 | import { modalStore } from "../../../stores/modalStore";
9 | import styles from "./BaseModal.module.css";
10 | import Button from "../../shared/Button/Button";
11 |
12 | export interface BaseModalProps {
13 | onClose?: () => void;
14 | closeText?: string;
15 | onSubmit?: () => void;
16 | submitText?: string | (() => string);
17 | }
18 |
19 | const BaseModal: ParentComponent = (props): JSXElement => {
20 | const [index, setIndex] = createSignal(0);
21 | const [fadeOut, setFadeOut] = createSignal(false);
22 |
23 | onMount(() => {
24 | setIndex(modalStore.activeModals.length - 1);
25 | });
26 |
27 | createEffect(() => {
28 | if (modalStore.closing && index() === modalStore.activeModals.length - 1) {
29 | setFadeOut(true);
30 | }
31 | }, [modalStore.closing]);
32 |
33 | return (
34 |
35 |
36 |
{props.children}
37 |
38 | {props.submitText !== undefined && (
39 |
44 | )}
45 | {props.closeText !== undefined && (
46 |
49 | )}
50 |
51 |
52 |
53 | );
54 | };
55 |
56 | export default BaseModal;
57 |
--------------------------------------------------------------------------------
/packages/client/src/components/modals/BranchNameModal/BranchNameModal.module.css:
--------------------------------------------------------------------------------
1 | .form {
2 | display: flex;
3 | flex-direction: column;
4 | gap: var(--spacing-sm);
5 | width: 400px;
6 | }
7 |
--------------------------------------------------------------------------------
/packages/client/src/components/modals/DeleteModal/DeleteModal.tsx:
--------------------------------------------------------------------------------
1 | import { type JSXElement, onCleanup, onMount } from "solid-js";
2 | import BaseModal, { type BaseModalProps } from "../BaseModal/BaseModal";
3 | import { modalStore } from "../../../stores/modalStore";
4 |
5 | interface DeleteModalProps extends BaseModalProps {}
6 |
7 | const DeleteModal = (props: DeleteModalProps): JSXElement => {
8 | const handleKeyDown = (event: KeyboardEvent) => {
9 | if (event.key === "Y" || event.key === "y") {
10 | props.onSubmit!();
11 | } else if (event.key === "N" || event.key === "n") {
12 | props.onClose!();
13 | }
14 | };
15 |
16 | onMount(() => {
17 | window.addEventListener("keydown", handleKeyDown);
18 |
19 | onCleanup(() => {
20 | window.removeEventListener("keydown", handleKeyDown);
21 | });
22 | });
23 |
24 | return (
25 |
26 | Delete Task
27 | Are you sure you want to delete selected tasks?
28 | {modalStore.selectedTask?.title}
29 |
30 | );
31 | };
32 |
33 | export default DeleteModal;
34 |
--------------------------------------------------------------------------------
/packages/client/src/components/modals/EditAppointmentModal/EditAppointmentModal.module.css:
--------------------------------------------------------------------------------
1 | .form {
2 | display: flex;
3 | flex-direction: column;
4 | gap: 1rem;
5 |
6 | label {
7 | font-weight: 500;
8 | line-height: 1;
9 | }
10 | }
11 |
--------------------------------------------------------------------------------
/packages/client/src/components/modals/EditOrCreateTaskModal/EditOrCreateTaskModal.module.css:
--------------------------------------------------------------------------------
1 | .form {
2 | display: flex;
3 | flex-direction: column;
4 | gap: var(--spacing-sm);
5 |
6 | width: 400px;
7 |
8 | select {
9 | width: unset;
10 | }
11 | }
12 |
--------------------------------------------------------------------------------
/packages/client/src/components/modals/EditOrCreateTaskModal/EditOrCreateTaskModal.tsx:
--------------------------------------------------------------------------------
1 | import { type JSXElement, onMount } from "solid-js";
2 | import { STATES } from "../../../constants";
3 | import { modalStore, setSelectedTaskValue } from "../../../stores/modalStore";
4 | import BaseModal, { type BaseModalProps } from "../BaseModal/BaseModal";
5 | import styles from "./EditOrCreateTaskModal.module.css";
6 |
7 | interface EditTaskModalProps extends BaseModalProps {}
8 |
9 | const EditOrCreateTaskModal = (props: EditTaskModalProps): JSXElement => {
10 | let inputRef: HTMLInputElement | null = null;
11 |
12 | onMount(() => {
13 | if (inputRef) {
14 | setTimeout(() => inputRef?.focus(), 0);
15 | }
16 | });
17 |
18 | return (
19 |
20 |
21 | {modalStore.selectedTask?._id !== undefined ? "Edit" : "Create"} Custom
22 | Task
23 |
24 |
25 | {
29 | if (e.key === "Enter") {
30 | props.onSubmit!();
31 | } else if (e.key === "Escape") {
32 | props.onClose!();
33 | }
34 | }}
35 | ref={(el) => (inputRef = el)}
36 | onInput={(e) => setSelectedTaskValue("title", e.currentTarget.value)}
37 | placeholder="Task Title"
38 | />
39 |
49 |
61 |
62 | );
63 | };
64 |
65 | export default EditOrCreateTaskModal;
66 |
--------------------------------------------------------------------------------
/packages/client/src/components/modals/HelpModal/HelpModal.module.css:
--------------------------------------------------------------------------------
1 | .helpWrapper {
2 | display: flex;
3 | flex-direction: row;
4 | gap: var(--spacing-md);
5 | align-items: flex-start;
6 | }
7 |
8 | .helpSection {
9 | background-color: var(--color-surface0);
10 | padding: var(--spacing-sm);
11 | border-radius: var(--spacing-xs);
12 | }
13 |
14 | .subHeading {
15 | margin-bottom: var(--spacing-md);
16 | }
17 |
18 | .keybindingsWrapper {
19 | display: flex;
20 | flex-direction: row;
21 | flex-wrap: wrap;
22 | gap: var(--spacing-md);
23 | }
24 |
25 | .keybindings-list {
26 | list-style: none;
27 | padding: 0;
28 | margin: var(--spacing-md) 0;
29 | color: var(--color-subtext1);
30 | }
31 |
32 | .keybindings-list li {
33 | display: flex;
34 | align-items: center;
35 | margin-bottom: var(--spacing-xs);
36 | font-size: var(--font-size-md);
37 | color: var(--color-text);
38 | }
39 |
40 | .keybindings-list li strong {
41 | color: var(--color-blue);
42 | font-weight: var(--font-weight-medium);
43 | margin-right: var(--spacing-xs);
44 | }
45 |
46 | .keybindings-list li kbd {
47 | display: inline-block;
48 | background-color: var(--color-base);
49 | color: var(--color-text);
50 | padding: var(--spacing-xxs) var(--spacing-xs);
51 | padding-bottom: 2px;
52 | border-radius: var(--spacing-xxs);
53 | margin-right: var(--spacing-xxs);
54 | font-size: var(--font-size-xs);
55 | font-weight: var(--font-weight-semibold);
56 | box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
57 | }
58 |
--------------------------------------------------------------------------------
/packages/client/src/components/modals/PipelineModal/PipelineModal.module.css:
--------------------------------------------------------------------------------
1 | .content {
2 | max-width: 60vw;
3 | display: flex;
4 | gap: var(--spacing-md);
5 | flex-direction: column;
6 | align-items: flex-start;
7 | }
8 |
9 | .title {
10 | font-size: var(--font-size-xl);
11 | font-weight: var(--font-weight-bold);
12 | }
13 |
14 | .description {
15 | font-size: var(--font-size-sm);
16 | color: var(--color-subtext1);
17 |
18 | a {
19 | color: var(--color-crust);
20 | }
21 |
22 | img {
23 | max-height: 200px;
24 | }
25 | }
26 |
27 | .card {
28 | background-color: var(--color-surface1);
29 | box-shadow: 0 0 10px 0 rgba(0, 0, 0, 0.1);
30 | padding: var(--spacing-xs);
31 | border-radius: var(--spacing-xs);
32 |
33 | &.selected {
34 | border: 2px solid var(--color-blue);
35 | }
36 | }
37 |
38 | .pipelineState {
39 | display: flex;
40 | align-items: center;
41 | gap: var(--spacing-xs);
42 | padding: var(--spacing-xs);
43 | border-radius: var(--spacing-xs);
44 |
45 | &.success {
46 | color: var(--color-green);
47 | border: 1px solid var(--color-green);
48 | background-color: var(--color-surface1);
49 | }
50 |
51 | &.failed {
52 | color: var(--color-red);
53 | border: 1px solid var(--color-red);
54 | background-color: var(--color-surface1);
55 | }
56 |
57 | &.pending,
58 | &.running {
59 | color: var(--color-blue);
60 | border: 1px solid var(--color-blue);
61 | background-color: var(--color-surface1);
62 | }
63 |
64 | &.canceled {
65 | color: var(--color-yellow);
66 | border: 1px solid var(--color-yellow);
67 | background-color: var(--color-surface1);
68 | }
69 |
70 | &.skipped {
71 | color: var(--color-peach);
72 | border: 1px solid var(--color-peach);
73 | background-color: var(--color-surface1);
74 | }
75 | }
76 |
77 | .divider {
78 | border-top: 2px solid var(--color-overlay2);
79 | border-radius: var(--spacing-xs);
80 | width: 100%;
81 | }
82 |
83 | .reports {
84 | display: flex;
85 | flex-direction: column;
86 | gap: var(--spacing-sm);
87 | max-height: 50vh;
88 | overflow-y: auto;
89 | align-items: flex-start;
90 |
91 | .pipelineReportTitle {
92 | font-size: var(--font-size-md);
93 | font-weight: var(--font-weight-bold);
94 | }
95 |
96 | img {
97 | border-radius: var(--spacing-xs);
98 | margin-top: var(--spacing-xs);
99 | width: 100%;
100 | }
101 | }
102 |
--------------------------------------------------------------------------------
/packages/client/src/components/modals/ReplyModal/ReplyModal.module.css:
--------------------------------------------------------------------------------
1 | .container {
2 | width: 100%;
3 | display: flex;
4 | flex-direction: column;
5 | gap: var(--spacing-md);
6 | }
7 |
8 | .card {
9 | background-color: var(--color-surface1);
10 | box-shadow: 0 0 10px 0 rgba(0, 0, 0, 0.1);
11 | padding: var(--spacing-xs);
12 | border-radius: var(--spacing-xs);
13 |
14 | &.selected {
15 | border: 2px solid var(--color-blue);
16 | }
17 | }
18 |
19 | .card.system {
20 | background-color: var(--color-surface0) !important;
21 | min-width: 300px;
22 | }
23 |
24 | .card.user {
25 | background-color: var(--color-surface1) !important;
26 | min-width: 300px;
27 | }
28 |
29 | .card.resolved {
30 | border-left: 4px solid var(--color-green);
31 | min-width: 300px;
32 | }
33 |
34 | .commentHeader {
35 | display: flex;
36 | align-items: center;
37 | gap: var(--spacing-xs);
38 | }
39 |
40 | .commentAvatar img {
41 | width: var(--spacing-xxl);
42 | height: var(--spacing-xxl);
43 | border-radius: 50%;
44 | image-rendering: optimizeQuality;
45 | background-color: var(--color-overlay2);
46 | }
47 |
48 | .commentAuthor {
49 | font-weight: var(--font-weight-medium);
50 | }
51 |
52 | .commentText {
53 | font-size: var(--font-size-sm);
54 |
55 | a {
56 | color: var(--color-crust);
57 | }
58 |
59 | img {
60 | max-height: 200px;
61 | border-radius: var(--spacing-xxs);
62 | }
63 |
64 | video {
65 | border-radius: var(--spacing-xxs);
66 | }
67 | }
68 |
69 | .commentImages {
70 | margin-top: var(--spacing-xs);
71 | display: flex;
72 | flex-wrap: wrap;
73 | gap: var(--spacing-xs);
74 | }
75 |
76 | .commentImage {
77 | border-radius: var(--spacing-xxs);
78 | object-fit: cover;
79 | max-width: 100%;
80 | }
81 |
82 | .replyArea {
83 | width: calc(100% - (var(--spacing-sm) + var(--spacing-xs) * 2));
84 | background-color: var(--color-crust);
85 | color: var(--color-text) !important;
86 | }
87 |
--------------------------------------------------------------------------------
/packages/client/src/components/modals/ReplyModal/ReplyModal.tsx:
--------------------------------------------------------------------------------
1 | import DiscussionCard from "@client/components/shared/DiscussionCard/DiscussionCard";
2 | import { replyToDiscussionAsync } from "@client/services/gitlabService";
3 | import { createSignal, onCleanup, onMount } from "solid-js";
4 | import { closeModal, modalStore, ModalType } from "../../../stores/modalStore";
5 | import BaseModal, { type BaseModalProps } from "../BaseModal/BaseModal";
6 | import styles from "./ReplyModal.module.css";
7 | import { closeTopModal } from "@client/services/modalService";
8 |
9 | interface ReplyModalProps extends BaseModalProps {}
10 |
11 | const ReplyModal = (props: ReplyModalProps) => {
12 | const { selectedDiscussion: discussion } = modalStore;
13 | const [reply, setReply] = createSignal("");
14 | let inputRef: HTMLTextAreaElement | null = null;
15 |
16 | onMount(() => {
17 | if (inputRef) {
18 | setTimeout(() => inputRef?.focus(), 0);
19 | }
20 |
21 | const handleKeydown = (event: KeyboardEvent) => {
22 | if (event.key === "Escape") {
23 | closeTopModal();
24 | } else if (event.key === "Enter" && event.ctrlKey) {
25 | replyToDiscussionAsync(discussion!, reply());
26 | closeModal(ModalType.Reply);
27 | }
28 | };
29 |
30 | window.addEventListener("keydown", handleKeydown);
31 |
32 | onCleanup(() => {
33 | window.removeEventListener("keydown", handleKeydown);
34 | });
35 | });
36 |
37 | const handleInput = (e: InputEvent) => {
38 | const target = e.currentTarget as HTMLTextAreaElement;
39 | setReply(target.value);
40 | adjustHeight(target);
41 | };
42 |
43 | const adjustHeight = (textarea: HTMLTextAreaElement) => {
44 | textarea.style.height = "auto";
45 | textarea.style.height = `${textarea.scrollHeight}px`;
46 | };
47 |
48 | return (
49 |
50 |
51 |
Reply
52 |
false}
54 | discussion={discussion!}
55 | selected={() => false}
56 | id="reply"
57 | />
58 |
59 |
66 |
67 |
68 |
69 | );
70 | };
71 |
72 | export default ReplyModal;
73 |
--------------------------------------------------------------------------------
/packages/client/src/components/modals/TaskDetailsModal/TaskDetailsModal.module.css:
--------------------------------------------------------------------------------
1 | .dialogContent {
2 | max-width: 70vw;
3 | display: flex;
4 | flex-direction: column;
5 | gap: var(--spacing-md);
6 | }
7 |
8 | .dialogTitle {
9 | font-size: var(--font-size-xl);
10 | font-weight: var(--font-weight-bold);
11 | }
12 |
13 | .contentContainer {
14 | display: flex;
15 | flex-direction: column;
16 | gap: var(--spacing-md);
17 | }
18 |
19 | .badgeContainer {
20 | display: flex;
21 | align-items: center;
22 | gap: var(--spacing-xs);
23 | }
24 |
25 | .badgeMarginLeft {
26 | margin-left: var(--spacing-xs);
27 | }
28 |
29 | .descriptionText {
30 | max-height: 400px;
31 | overflow: scroll;
32 |
33 | font-size: var(--font-size-sm);
34 | color: var(--color-subtext1);
35 |
36 | a {
37 | color: var(--color-crust);
38 | }
39 |
40 | img {
41 | max-height: 200px;
42 | }
43 | }
44 |
45 | .labelsContainer {
46 | display: flex;
47 | flex-wrap: wrap;
48 | gap: var(--spacing-xs);
49 | }
50 |
51 | .branchInfo,
52 | .externalLink {
53 | display: flex;
54 | align-items: center;
55 | font-size: var(--font-size-sm);
56 | color: var(--color-subtext1);
57 | text-decoration: none;
58 | gap: var(--spacing-xs);
59 | }
60 |
61 | .branchInfo .icon,
62 | .externalLink .icon {
63 | width: var(--spacing-md);
64 | height: var(--spacing-md);
65 | margin-right: var(--spacing-xs);
66 | }
67 |
68 | .externalLink {
69 | color: var(--color-crust);
70 | cursor: pointer;
71 | }
72 |
73 | .externalLink:hover {
74 | text-decoration: underline;
75 | }
76 |
77 | .divider {
78 | border-top: 2px solid var(--color-overlay2);
79 | border-radius: var(--spacing-xs);
80 | }
81 |
82 | .discussionsHeader {
83 | font-size: var(--font-size-lg);
84 | font-weight: var(--font-weight-semibold);
85 | }
86 |
87 | .discussionsContainer {
88 | display: flex;
89 | flex-direction: column;
90 | gap: var(--spacing-sm);
91 | max-height: 50vh;
92 | overflow-y: auto;
93 | }
94 |
95 | .mainSection {
96 | display: flex;
97 | gap: var(--spacing-md);
98 | flex-wrap: wrap;
99 | align-items: flex-start;
100 | }
101 |
102 | .card {
103 | background-color: var(--color-surface1);
104 | box-shadow: 0 0 10px 0 rgba(0, 0, 0, 0.1);
105 | padding: var(--spacing-xs);
106 | border-radius: var(--spacing-xs);
107 |
108 | &.selected {
109 | border: 2px solid var(--color-blue);
110 | }
111 | }
112 |
113 | .card.system {
114 | background-color: var(--color-surface0) !important;
115 | min-width: 300px;
116 | }
117 |
118 | .card.user {
119 | background-color: var(--color-surface1) !important;
120 | min-width: 300px;
121 | }
122 |
123 | .card.resolved {
124 | border-left: 4px solid var(--color-green);
125 | min-width: 300px;
126 | }
127 |
--------------------------------------------------------------------------------
/packages/client/src/components/modals/index.ts:
--------------------------------------------------------------------------------
1 | import BaseModal from "./BaseModal/BaseModal";
2 | import HelpModal from "./HelpModal/HelpModal";
3 | import DeleteModal from "./DeleteModal/DeleteModal";
4 | import EditOrCreateTaskModal from "./EditOrCreateTaskModal/EditOrCreateTaskModal";
5 | import BranchNameModal from "./BranchNameModal/BranchNameModal";
6 |
7 | export {
8 | BaseModal,
9 | HelpModal,
10 | DeleteModal,
11 | EditOrCreateTaskModal,
12 | BranchNameModal,
13 | };
14 |
--------------------------------------------------------------------------------
/packages/client/src/components/shared/Badge/Badge.module.css:
--------------------------------------------------------------------------------
1 | .badge {
2 | white-space: nowrap;
3 | overflow: hidden;
4 | text-overflow: ellipsis;
5 | color: var(--color-crust);
6 | padding: var(--spacing-xxs) var(--spacing-xs);
7 | border-radius: var(--spacing-xxs);
8 | font-size: var(--font-size-xs);
9 | display: inline-block;
10 | margin-top: var(--spacing-xxs);
11 | margin-right: var(--spacing-xxs);
12 |
13 | &.maxLength {
14 | max-width: 100px;
15 | }
16 | }
17 |
18 | .badge.default {
19 | background-color: var(--color-lavender);
20 | color: var(--color-crust);
21 | }
22 |
23 | .badge.secondary {
24 | background-color: var(--color-surface);
25 | color: var(--color-text);
26 | }
27 |
28 | .badge.outline {
29 | background-color: var(--color-surface);
30 | color: var(--color-text);
31 | border: 1px solid var(--color-crust);
32 | }
33 |
34 | .badge.none {
35 | background-color: var(--color-crust);
36 | color: var(--color-text);
37 | }
38 |
39 | .badge.medium {
40 | background-color: var(--color-yellow);
41 | }
42 |
43 | .badge.low {
44 | background-color: var(--color-green);
45 | }
46 |
47 | .badge.high,
48 | .badge.staging,
49 | .badge.intermediate {
50 | background-color: var(--color-red);
51 | color: var(--color-crust);
52 | animation:
53 | pulsate 1.5s infinite,
54 | borderGlow 1.5s linear infinite;
55 | padding: calc(var(--spacing-xxs) - 2px) calc(var(--spacing-xs) - 2px);
56 | border: 2px solid transparent;
57 | position: relative;
58 | text-transform: uppercase;
59 | font-weight: var(--font-weight-bold);
60 | }
61 |
62 | /* Pulsating effect */
63 | @keyframes pulsate {
64 | 0%,
65 | 100% {
66 | transform: scale(1);
67 | }
68 | 50% {
69 | transform: scale(1);
70 | }
71 | }
72 |
73 | /* Border animation */
74 | @keyframes borderGlow {
75 | 0% {
76 | border-color: var(--color-red);
77 | }
78 | 25% {
79 | border-color: var(--color-green);
80 | }
81 | 50% {
82 | border-color: var(--color-yellow);
83 | }
84 | 75% {
85 | border-color: var(--color-green);
86 | }
87 | 100% {
88 | border-color: var(--color-red);
89 | }
90 | }
91 |
92 | .badge.deleted {
93 | background-color: var(--color-red);
94 | color: var(--color-base);
95 | }
96 |
97 | .badge.issue {
98 | background-color: var(--color-green);
99 | color: var(--color-base);
100 | }
101 |
102 | .badge.custom {
103 | background-color: var(--color-peach);
104 | color: var(--color-crust);
105 | }
106 |
107 | .badge.mergeRequest {
108 | background-color: var(--color-blue);
109 | color: var(--color-crust);
110 | }
111 |
--------------------------------------------------------------------------------
/packages/client/src/components/shared/Badge/Badge.tsx:
--------------------------------------------------------------------------------
1 | import type { JSXElement } from "solid-js";
2 | import styles from "./Badge.module.css";
3 | import { addNotification } from "@client/services/notificationService";
4 | import Tooltip from "@client/components/shared/Tooltip/Tooltip";
5 |
6 | type BadgeProps = {
7 | type?:
8 | | "issue"
9 | | "merge_request"
10 | | "custom"
11 | | "intermediate"
12 | | "staging"
13 | | "high"
14 | | "medium"
15 | | "low"
16 | | "outline"
17 | | string;
18 | cutOffText?: boolean;
19 | clipBoardText?: string;
20 | hasTooltip?: boolean;
21 | children: JSXElement;
22 | onClick?: (event: MouseEvent) => void;
23 | };
24 |
25 | const Badge = ({
26 | type = "custom",
27 | children,
28 | cutOffText,
29 | clipBoardText,
30 | onClick,
31 | hasTooltip,
32 | }: BadgeProps) => {
33 | const copyToClipboard = (event: MouseEvent) => {
34 | event.stopPropagation();
35 | event.preventDefault();
36 |
37 | if (!clipBoardText) return;
38 | navigator.clipboard.writeText(clipBoardText);
39 |
40 | addNotification({
41 | type: "success",
42 | title: "Copied to clipboard",
43 | duration: 1000,
44 | });
45 | };
46 |
47 | return (
48 |
54 | {hasTooltip ? (
55 |
56 |
61 | {children}
62 |
63 |
64 | ) : (
65 |
70 | {children}
71 |
72 | )}
73 |
74 | );
75 | };
76 |
77 | export default Badge;
78 |
--------------------------------------------------------------------------------
/packages/client/src/components/shared/Button/Button.module.css:
--------------------------------------------------------------------------------
1 | .mochiButton {
2 | background-color: var(--color-blue);
3 | color: var(--color-crust);
4 | border: none;
5 | padding: var(--spacing-xs) var(--spacing-sm);
6 | border-radius: var(--spacing-xs);
7 | cursor: pointer;
8 | font-size: var(--font-size-sm);
9 | transition: background-color 0.3s ease, transform 0.2s;
10 |
11 | &.primary {
12 | background-color: var(--color-blue);
13 | }
14 |
15 | &.secondary {
16 | background-color: var(--color-surface1);
17 |
18 | &:hover {
19 | background-color: var(--color-surface0);
20 | }
21 | }
22 | }
23 |
24 | .mochiButton:hover {
25 | background-color: var(--color-sapphire);
26 | }
27 |
28 | .mochiButton:active {
29 | transform: translateY(0);
30 | }
31 |
--------------------------------------------------------------------------------
/packages/client/src/components/shared/Button/Button.tsx:
--------------------------------------------------------------------------------
1 | import type { JSXElement } from "solid-js";
2 | import styles from "./Button.module.css";
3 |
4 | type ButtonProps = {
5 | children: JSXElement | string;
6 | disabled?: boolean;
7 | type?: "primary" | "secondary" | "default";
8 | onClick: (e: MouseEvent) => void;
9 | style?: any;
10 | };
11 |
12 | const Button = ({
13 | children,
14 | onClick,
15 | disabled,
16 | type = "default",
17 | style,
18 | }: ButtonProps) => {
19 | return (
20 |
28 | );
29 | };
30 |
31 | export default Button;
32 |
--------------------------------------------------------------------------------
/packages/client/src/components/shared/Card/Card.module.css:
--------------------------------------------------------------------------------
1 | .card {
2 | background-color: var(--color-surface1);
3 | box-shadow: 0 0 10px 0 rgba(0, 0, 0, 0.1);
4 | padding: var(--spacing-xs);
5 | border-radius: var(--spacing-xs);
6 | position: relative;
7 |
8 | &.blue {
9 | border: 2px solid var(--color-blue);
10 | }
11 |
12 | &.green {
13 | border: 2px solid var(--color-green);
14 | }
15 | }
16 |
--------------------------------------------------------------------------------
/packages/client/src/components/shared/Card/Card.tsx:
--------------------------------------------------------------------------------
1 | import styles from "./Card.module.css";
2 |
3 | interface CardProps {
4 | children: any;
5 | class?: string;
6 | selectedBorderColor?: string;
7 | selected: () => boolean;
8 | }
9 |
10 | const Card = (props: CardProps) => {
11 | return (
12 |
17 | {props.children}
18 |
19 | );
20 | };
21 |
22 | export default Card;
23 |
--------------------------------------------------------------------------------
/packages/client/src/components/shared/CommandLine/CommandLine.module.css:
--------------------------------------------------------------------------------
1 | .commandline {
2 | flex-grow: 1;
3 | margin: 0 var(--spacing-sm);
4 |
5 | .commandlineInput {
6 | display: flex;
7 | align-items: center;
8 | position: relative;
9 | }
10 |
11 | i {
12 | color: var(--color-text);
13 | font-size: var(--font-size-md);
14 | border: 1px solid var(--color-blue);
15 | border-radius: var(--spacing-xs);
16 | padding: var(--spacing-xs);
17 | padding-right: 0;
18 | border-right: none;
19 | border-top-right-radius: 0;
20 | border-bottom-right-radius: 0;
21 | background-color: var(--color-mantle);
22 | }
23 |
24 | input {
25 | width: 100%;
26 | padding: var(--spacing-xs);
27 | border-radius: var(--spacing-xs);
28 | border: 1px solid var(--color-blue);
29 | background-color: var(--color-mantle);
30 | color: var(--color-text);
31 | font-size: var(--font-size-sm);
32 | }
33 |
34 | .placeholder {
35 | position: absolute;
36 | top: 50%;
37 | left: var(--spacing-sm);
38 | transform: translateY(-50%);
39 | color: var(--color-text);
40 | }
41 |
42 | input:focus {
43 | outline: none;
44 | border-color: var(--color-blue) !important;
45 | }
46 |
47 | &.active {
48 | input {
49 | border-left: none;
50 | border-top-left-radius: 0;
51 | border-bottom-left-radius: 0;
52 | border-color: var(--color-peach) !important;
53 | }
54 |
55 | i {
56 | border-color: var(--color-peach) !important;
57 | }
58 |
59 | .placeholder {
60 | /* add cursor width */
61 | left: calc(var(--spacing-xl) + 5px);
62 | }
63 | }
64 |
65 | .dropdown {
66 | position: absolute;
67 | background-color: var(--color-mantle);
68 | color: var(--color-text);
69 | border: 1px solid var(--color-peach);
70 | border-radius: var(--spacing-xs);
71 | padding: var(--spacing-xxs);
72 | max-height: 500px;
73 | min-width: 200px;
74 | overflow-y: auto;
75 | margin-top: var(--spacing-xxs);
76 | z-index: 999;
77 | }
78 |
79 | .dropdownItem {
80 | padding: var(--spacing-xs);
81 | font-size: var(--font-size-sm);
82 | cursor: pointer;
83 | }
84 |
85 | .dropdownItem:hover {
86 | background-color: var(--color-sapphire);
87 | color: var(--color-text);
88 | }
89 | }
90 |
--------------------------------------------------------------------------------
/packages/client/src/components/shared/Comment/Comment.module.css:
--------------------------------------------------------------------------------
1 | .card {
2 | background-color: var(--color-surface1);
3 | box-shadow: 0 0 10px 0 rgba(0, 0, 0, 0.1);
4 | padding: var(--spacing-xs);
5 | border-radius: var(--spacing-xs);
6 |
7 | &.selected {
8 | border: 2px solid var(--color-blue);
9 | }
10 | }
11 |
12 | .card.system {
13 | background-color: var(--color-surface0) !important;
14 | min-width: 300px;
15 | }
16 |
17 | .card.user {
18 | background-color: var(--color-surface1) !important;
19 | min-width: 300px;
20 | }
21 |
22 | .card.resolved {
23 | border-left: 4px solid var(--color-green);
24 | min-width: 300px;
25 | }
26 |
27 | .commentHeader {
28 | display: flex;
29 | align-items: center;
30 | gap: var(--spacing-xs);
31 | }
32 |
33 | .commentAvatar img {
34 | width: var(--spacing-xxl);
35 | height: var(--spacing-xxl);
36 | border-radius: 50%;
37 | image-rendering: optimizeQuality;
38 | background-color: var(--color-overlay2);
39 | }
40 |
41 | .commentAuthor {
42 | font-weight: var(--font-weight-medium);
43 | }
44 |
45 | .commentText {
46 | font-size: var(--font-size-sm);
47 |
48 | a {
49 | color: var(--color-crust);
50 | }
51 |
52 | img {
53 | max-height: 200px;
54 | }
55 | }
56 |
57 | .commentText.clamped {
58 | display: -webkit-box;
59 | -webkit-line-clamp: 3;
60 | -webkit-box-orient: vertical;
61 | overflow: hidden;
62 | }
63 |
64 | .expandButton {
65 | font-size: var(--font-size-sm);
66 | color: var(--color-blue);
67 | margin-top: var(--spacing-xxs);
68 | background: none;
69 | border: none;
70 | padding: 0;
71 | cursor: pointer;
72 | }
73 |
74 | .expandButton:hover {
75 | text-decoration: underline;
76 | }
77 |
78 | .commentImages {
79 | margin-top: var(--spacing-xs);
80 | display: flex;
81 | flex-wrap: wrap;
82 | gap: var(--spacing-xs);
83 | }
84 |
85 | .commentImage {
86 | border-radius: var(--spacing-xxs);
87 | object-fit: cover;
88 | max-width: 100%;
89 | }
90 |
--------------------------------------------------------------------------------
/packages/client/src/components/shared/Comment/Comment.tsx:
--------------------------------------------------------------------------------
1 | import { createSignal } from "solid-js";
2 | import { parseMarkdown } from "../../../utils/parseMarkdown";
3 | import Badge from "../Badge/Badge";
4 | import styles from "./Comment.module.css";
5 | import type { IComment } from "shared/types/task";
6 | import { settingsStore } from "@client/stores/settings.store";
7 |
8 | interface CommentProps {
9 | comment: IComment;
10 | selected: () => boolean;
11 | id: string;
12 | }
13 |
14 | const CommentCard = ({ comment, selected, id }: CommentProps) => {
15 | const [expand, setExpand] = createSignal(false);
16 |
17 | return (
18 |
24 |
50 |
51 |
55 | {comment.body.length > 150 && (
56 |
62 | )}
63 |
64 |
65 | );
66 | };
67 |
68 | export default CommentCard;
69 |
--------------------------------------------------------------------------------
/packages/client/src/components/shared/DiscussionCard/DiscussionCard.module.css:
--------------------------------------------------------------------------------
1 | .discussionHeader {
2 | display: flex;
3 | align-items: center;
4 | gap: var(--spacing-xs);
5 | }
6 |
7 | .discussionAvatar i,
8 | .discussionAvatar img {
9 | display: flex;
10 | justify-content: center;
11 | align-items: center;
12 | color: var(--color-crust);
13 | width: var(--spacing-xxl);
14 | height: var(--spacing-xxl);
15 | border-radius: 50%;
16 | image-rendering: optimizeQuality;
17 | background-color: var(--color-overlay2);
18 | }
19 |
20 | .discussionAuthor {
21 | font-weight: var(--font-weight-medium);
22 | }
23 |
24 | .discussionText {
25 | font-size: var(--font-size-sm);
26 |
27 | a {
28 | color: var(--color-crust);
29 | }
30 |
31 | img {
32 | max-height: 200px;
33 | }
34 | }
35 |
36 | .discussionText.clamped {
37 | display: -webkit-box;
38 | -webkit-line-clamp: 3;
39 | line-clamp: 3;
40 | -webkit-box-orient: vertical;
41 | overflow: hidden;
42 | }
43 |
44 | .expandButton {
45 | font-size: var(--font-size-sm);
46 | color: var(--color-blue);
47 | margin-top: var(--spacing-xxs);
48 | background: none;
49 | border: none;
50 | padding: 0;
51 | cursor: pointer;
52 | }
53 |
54 | .expandButton:hover {
55 | text-decoration: underline;
56 | }
57 |
58 | .discussionImages {
59 | margin-top: var(--spacing-xs);
60 | display: flex;
61 | flex-wrap: wrap;
62 | gap: var(--spacing-xs);
63 | }
64 |
65 | .discussionImage {
66 | border-radius: var(--spacing-xxs);
67 | object-fit: cover;
68 | max-width: 100%;
69 | }
70 |
71 | .discussionThread {
72 | display: flex;
73 | flex-direction: column;
74 | gap: var(--spacing-xs);
75 | padding-left: var(--spacing-md);
76 | border-top: 1px solid var(--color-overlay1);
77 | padding-top: var(--spacing-xs);
78 | }
79 |
80 | .discussionThreadNote {
81 | position: relative;
82 | padding: var(--spacing-xxs) var(--spacing-xs);
83 | border-radius: var(--spacing-xs);
84 | background-color: var(--color-surface0);
85 | box-shadow: 0 0 10px 0 rgba(0, 0, 0, 0.1);
86 |
87 | &.selected {
88 | border: 2px solid var(--color-blue);
89 | }
90 | }
91 |
92 | .discussionThreadNoteHeader {
93 | display: flex;
94 | align-items: center;
95 | gap: var(--spacing-xs);
96 |
97 | img {
98 | width: var(--spacing-xl);
99 | height: var(--spacing-xl);
100 | }
101 | }
102 |
103 | .resolvedBadge {
104 | position: absolute;
105 | top: 0;
106 | right: 0;
107 | }
108 |
109 | .resolvedCard {
110 | border-left: 4px solid var(--color-green);
111 | }
112 |
--------------------------------------------------------------------------------
/packages/client/src/components/shared/Header/Header.tsx:
--------------------------------------------------------------------------------
1 | import { createSignal, type JSXElement, onMount } from "solid-js";
2 | import logo from "../../../assets/logo.svg";
3 | import { getProjectAsync } from "../../../services/customProjectService";
4 | import { setCurrentProject, uiStore } from "../../../stores/uiStore";
5 | import CommandLine from "../CommandLine/CommandLine";
6 | import styles from "./Header.module.css";
7 | import { random } from "lodash";
8 | import { WaveText } from "../WaveText/WaveText";
9 | import dayjs from "dayjs";
10 |
11 | const slogans = ["vim-like", "keyboard-first", "mice-are-for-cats", "h-j-k-l"];
12 |
13 | const Header = (): JSXElement => {
14 | const [slogan, setSlogan] = createSignal("");
15 | const isJune = dayjs().month() === 5;
16 |
17 | onMount(async () => {
18 | const randomIndex = random(slogans.length);
19 | setSlogan(slogans.at(randomIndex) || "happy-little-accidents");
20 |
21 | setCurrentProject(await getProjectAsync());
22 | });
23 |
24 | return (
25 |
45 | );
46 | };
47 |
48 | export default Header;
49 |
--------------------------------------------------------------------------------
/packages/client/src/components/shared/Loading/Loading.module.css:
--------------------------------------------------------------------------------
1 | .loading {
2 | display: flex;
3 | position: absolute;
4 | top: 0;
5 | left: 0;
6 | width: 100vw;
7 | height: 100vh;
8 | overflow: hidden;
9 | justify-content: center;
10 | align-items: center;
11 | font-size: var(--font-size-lg);
12 | }
13 |
14 | .dots {
15 | display: flex;
16 | gap: var(--spacing-xs);
17 | }
18 |
19 | .dot {
20 | width: var(--spacing-md);
21 | height: var(--spacing-md);
22 | background-color: var(--color-blue);
23 | border-radius: 50%;
24 | animation: bounce 1s infinite ease-in-out;
25 | }
26 |
27 | .dot:nth-child(2) {
28 | animation-delay: 0.2s;
29 | }
30 |
31 | .dot:nth-child(3) {
32 | animation-delay: 0.4s;
33 | }
34 |
35 | @keyframes bounce {
36 | 0%,
37 | 100% {
38 | transform: translateY(0);
39 | }
40 | 50% {
41 | transform: translateY(-20px); /* Adjust height for bounce */
42 | }
43 | }
44 |
--------------------------------------------------------------------------------
/packages/client/src/components/shared/Loading/Loading.tsx:
--------------------------------------------------------------------------------
1 | import styles from "./Loading.module.css";
2 |
3 | const Loading = () => {
4 | return (
5 |
12 | );
13 | };
14 |
15 | export default Loading;
16 |
--------------------------------------------------------------------------------
/packages/client/src/components/shared/Notification/Notification.module.css:
--------------------------------------------------------------------------------
1 | .notification {
2 | min-width: 300px;
3 | color: var(--color-text);
4 | padding: var(--spacing-md) var(--spacing-lg);
5 | border-radius: var(--spacing-xs);
6 | box-shadow: 0 8px 15px rgba(0, 0, 0, 0.2);
7 | transform: translateY(20px);
8 | opacity: 0;
9 | transition: opacity 0.3s ease-in-out, transform 0.3s ease-in-out;
10 | display: flex;
11 | flex-direction: column;
12 | gap: var(--spacing-xxs);
13 | background-color: var(--color-surface1); /* Solid background color */
14 | }
15 |
16 | .notification.success {
17 | border-left: 10px solid var(--color-green);
18 | }
19 |
20 | .notification.error {
21 | border-left: 10px solid var(--color-red);
22 | }
23 |
24 | .notification.warning {
25 | border-left: 10px solid var(--color-yellow);
26 | }
27 |
28 | .notification strong {
29 | font-size: var(--font-size-md);
30 | font-weight: var(--font-weight-semibold);
31 | }
32 |
33 | .notification p {
34 | margin: 0;
35 | font-size: var(--font-size-sm);
36 | line-height: 1.4;
37 | }
38 |
--------------------------------------------------------------------------------
/packages/client/src/components/shared/Notification/Notification.tsx:
--------------------------------------------------------------------------------
1 | import { createEffect, onCleanup } from "solid-js";
2 | import type { Notification } from "@client/services/notificationService";
3 | import styles from "./Notification.module.css";
4 |
5 | const NotificationComponent = (props: { notification: Notification }) => {
6 | let notificationRef: HTMLElement | null = null;
7 |
8 | createEffect(() => {
9 | setTimeout(() => {
10 | if (notificationRef) {
11 | notificationRef.style.opacity = "1";
12 | notificationRef.style.transform = "translateY(0)";
13 | }
14 | }, 0);
15 |
16 | const timeoutId = setTimeout(() => {
17 | if (notificationRef) {
18 | notificationRef.style.opacity = "0";
19 | notificationRef.style.transform = "translateX(20px)";
20 | }
21 | }, props.notification.duration - 300);
22 |
23 | onCleanup(() => clearTimeout(timeoutId));
24 | });
25 |
26 | return (
27 | (notificationRef = el)}
29 | class={`${styles.notification} ${styles[props.notification.type]}`}
30 | style={{
31 | opacity: 0,
32 | transform: "translateY(20px)",
33 | transition: "opacity 0.3s ease-in-out, transform 0.3s ease-in-out",
34 | }}
35 | >
36 |
{props.notification.title}
37 |
{props.notification.description}
38 |
39 | );
40 | };
41 |
42 | export default NotificationComponent;
43 |
--------------------------------------------------------------------------------
/packages/client/src/components/shared/NotificationManager.tsx:
--------------------------------------------------------------------------------
1 | import { For } from "solid-js";
2 | import { useNotifications } from "../../services/notificationService";
3 | import NotificationComponent from "./Notification/Notification";
4 |
5 | const NotificationManager = () => {
6 | const notifications = useNotifications();
7 |
8 | return (
9 |
10 |
11 | {(notification) => (
12 |
13 | )}
14 |
15 |
16 | );
17 | };
18 |
19 | export default NotificationManager;
20 |
--------------------------------------------------------------------------------
/packages/client/src/components/shared/Pagination/Pagination.module.css:
--------------------------------------------------------------------------------
1 | .pagination {
2 | display: flex;
3 | justify-content: center;
4 | margin-top: 20px;
5 | button {
6 | padding: 5px 10px;
7 | margin: 0 5px;
8 | border: 1px solid #ccc;
9 | border-radius: 5px;
10 | cursor: pointer;
11 | &:hover {
12 | background-color: #f0f0f0;
13 | }
14 | &.active {
15 | background-color: #007bff;
16 | color: #fff;
17 | }
18 | }
19 | }
20 |
--------------------------------------------------------------------------------
/packages/client/src/components/shared/Pagination/Pagination.tsx:
--------------------------------------------------------------------------------
1 | import type { IPagination } from "@mochi-shared/types/pagination";
2 | import styles from "./Pagination.module.css";
3 | import { createEffect, createSignal } from "solid-js";
4 |
5 | interface PaginationProps {
6 | pagination: () => IPagination;
7 | }
8 |
9 | const Pagination = (props: PaginationProps) => {
10 | const [pages, setPages] = createSignal([]);
11 |
12 | createEffect(() => {
13 | const pages = Array.from(
14 | { length: props.pagination().totalPages },
15 | (_, i) => i + 1
16 | );
17 |
18 | setPages(pages);
19 | }, [props.pagination()]);
20 |
21 | return (
22 |
31 | );
32 | };
33 |
34 | export default Pagination;
35 |
--------------------------------------------------------------------------------
/packages/client/src/components/shared/StatusBar/StatusBar.module.css:
--------------------------------------------------------------------------------
1 | .statusBar {
2 | position: fixed;
3 | bottom: 0;
4 | left: 0;
5 | width: calc(100%);
6 | background-color: var(--color-mantle);
7 | color: var(--color-text);
8 | display: flex;
9 | align-items: center;
10 | justify-content: space-between;
11 | font-size: var(--font-size-sm);
12 | z-index: 1000;
13 | box-shadow: 0 -2px 4px rgba(0, 0, 0, 0.1);
14 | }
15 | .calendarMode {
16 | white-space: nowrap;
17 | overflow: hidden;
18 | text-overflow: ellipsis;
19 | color: var(--color-crust);
20 | padding: var(--spacing-xxs) var(--spacing-xs);
21 | border-radius: var(--spacing-xxs);
22 | font-size: var(--font-size-xs);
23 | display: inline-block;
24 | margin-top: var(--spacing-xxs);
25 | margin-right: var(--spacing-xxs);
26 | margin-left: var(--spacing-xxs);
27 |
28 | &.time {
29 | background-color: var(--color-green);
30 | }
31 |
32 | &.appointment {
33 | background-color: var(--color-blue);
34 | }
35 | }
36 |
--------------------------------------------------------------------------------
/packages/client/src/components/shared/Tooltip/Tooltip.module.css:
--------------------------------------------------------------------------------
1 | .tooltip {
2 | position: relative;
3 | display: inline-block;
4 | cursor: pointer;
5 | }
6 |
7 | .tooltipText {
8 | visibility: hidden;
9 | opacity: 0;
10 | width: max-content;
11 | max-width: 200px;
12 | background-color: var(--color-lavender); /* Brighter background */
13 | color: var(--color-crust); /* Bright text color */
14 | text-align: center;
15 | padding: var(--spacing-xs);
16 | border-radius: var(--spacing-md); /* Increased border radius */
17 | box-shadow: 0 4px 10px rgba(0, 0, 0, 0.2);
18 | font-size: var(--font-size-sm);
19 | position: absolute;
20 | bottom: 125%; /* Position tooltip above the element */
21 | left: 50%;
22 | transform: translateX(-50%);
23 | transition: opacity 0.3s ease, visibility 0.3s ease;
24 | z-index: 10;
25 | }
26 |
27 | .tooltipText::after {
28 | content: "";
29 | position: absolute;
30 | top: 100%; /* Arrow at the bottom of the tooltip */
31 | left: 50%;
32 | margin-left: -5px;
33 | border-width: 5px;
34 | border-style: solid;
35 | border-color: var(--color-lavender) transparent transparent transparent; /* Match background color */
36 | }
37 |
38 | .tooltip:hover .tooltipText {
39 | visibility: visible;
40 | opacity: 1;
41 | }
42 |
--------------------------------------------------------------------------------
/packages/client/src/components/shared/Tooltip/Tooltip.tsx:
--------------------------------------------------------------------------------
1 | import type { JSXElement } from "solid-js";
2 | import styles from "./Tooltip.module.css";
3 |
4 | type TooltipProps = {
5 | children: JSXElement;
6 | text: string | JSXElement;
7 | };
8 |
9 | const Tooltip = ({ children, text }: TooltipProps) => {
10 | return (
11 |
12 | {children}
13 | {text}
14 |
15 | );
16 | };
17 |
18 | export default Tooltip;
19 |
--------------------------------------------------------------------------------
/packages/client/src/components/shared/WaveText/WaveText.module.css:
--------------------------------------------------------------------------------
1 | .waveText {
2 | display: flex;
3 | overflow: hidden;
4 |
5 | --color-start: var(--color-blue);
6 | --color-end: var(--color-sapphire);
7 |
8 | &.pride {
9 | --color-start: var(--color-crust);
10 | --color-end: var(--color-yellow);
11 | }
12 | }
13 |
14 | .waveText span {
15 | display: inline-block;
16 | animation:
17 | wave 2s ease-in-out infinite,
18 | waveColor 2s ease-in-out infinite;
19 | }
20 |
21 | @keyframes wave {
22 | 0%,
23 | 100% {
24 | transform: translateY(0);
25 | }
26 | 50% {
27 | transform: translateY(-0.2em);
28 | }
29 | }
30 |
31 | @keyframes waveColor {
32 | 0%,
33 | 100% {
34 | color: var(--color-start);
35 | }
36 | 50% {
37 | color: var(--color-end);
38 | }
39 | }
40 |
--------------------------------------------------------------------------------
/packages/client/src/components/shared/WaveText/WaveText.tsx:
--------------------------------------------------------------------------------
1 | import { For } from "solid-js";
2 | import styles from "./WaveText.module.css";
3 | import dayjs from "dayjs";
4 |
5 | interface WaveTextProps {
6 | text: () => string;
7 | class?: string;
8 | }
9 |
10 | export function WaveText(props: WaveTextProps) {
11 | const isJune = dayjs().month() === 5;
12 |
13 | return (
14 |
17 |
18 | {(char, index) => (
19 |
24 | {char === " " ? "\u00A0" : char}
25 |
26 | )}
27 |
28 |
29 | );
30 | }
31 |
--------------------------------------------------------------------------------
/packages/client/src/constants.ts:
--------------------------------------------------------------------------------
1 | export const STATES = [
2 | { display_name: "Backlog", id: "opened" },
3 | { display_name: "In Progress", id: "inprogress" },
4 | { display_name: "Done", id: "done" },
5 | { display_name: "Review", id: "review" },
6 | { display_name: "Closed", id: "closed" },
7 | ];
8 |
9 | export const BACKEND_URL = "http://localhost:5000";
10 | export const SOCKET_URL = "ws://localhost:5000";
11 |
--------------------------------------------------------------------------------
/packages/client/src/index.tsx:
--------------------------------------------------------------------------------
1 | import { render } from "solid-js/web";
2 |
3 | import "./styles/variables.css";
4 | import "./styles/base.css";
5 | import "./styles/scrollbar.css";
6 | import "./styles/notification.css";
7 |
8 | import App from "./App";
9 | import axios from "axios";
10 | import WebSocketHandler from "./sockets/WebSocketHandler";
11 | import { Route, Router } from "@solidjs/router";
12 | import KanbanBoard from "./modules/KanbanBoard/KanbanBoard";
13 | import TimeTrack from "./modules/TimeTrack/TimeTrack";
14 | import Home from "./Home";
15 | import ShortcutRegistry from "./shortcutMaps/shortcutRegistry";
16 | import { BACKEND_URL, SOCKET_URL } from "./constants";
17 | import Todos from "@client/modules/Todos/Todos";
18 | import { settingsService } from "./services/settingsService";
19 | import SetupWizard from "./components/SetupWizard/setupWizard";
20 |
21 | const root = document.getElementById("root");
22 |
23 | axios.defaults.baseURL = `${BACKEND_URL}/api`;
24 | WebSocketHandler.getInstance().init(SOCKET_URL);
25 |
26 | if (import.meta.env.DEV && !(root instanceof HTMLElement)) {
27 | throw new Error(
28 | "Root element not found. Did you forget to add it to your index.html? Or maybe the id attribute got misspelled?",
29 | );
30 | }
31 |
32 | await ShortcutRegistry.getInstance().initializeAsync();
33 |
34 | const setupStatus = await settingsService.getSetupStatus();
35 | if (!setupStatus.isComplete && window.location.pathname !== "/setup") {
36 | window.location.href = "/setup";
37 | }
38 |
39 | render(
40 | () => (
41 |
42 |
43 |
44 |
45 |
46 |
47 |
48 | ),
49 | root!,
50 | );
51 |
--------------------------------------------------------------------------------
/packages/client/src/logo.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/packages/client/src/modules/KanbanBoard/KanbanBoard.module.css:
--------------------------------------------------------------------------------
1 | .kanban {
2 | padding-inline: var(--spacing-sm);
3 | display: flex;
4 | gap: var(--spacing-md);
5 | justify-content: space-between;
6 | align-items: flex-start;
7 | padding-bottom: 30px;
8 | padding-top: var(--spacing-sm);
9 | }
10 |
11 | .noProject {
12 | display: flex;
13 | flex-direction: column;
14 | align-items: center;
15 | gap: var(--spacing-md);
16 | width: 100%;
17 | padding: var(--spacing-md);
18 |
19 | font-size: var(--font-size-lg);
20 | }
21 |
--------------------------------------------------------------------------------
/packages/client/src/modules/TimeTrack/TimeTrack.module.css:
--------------------------------------------------------------------------------
1 | .timetrack {
2 | padding-inline: var(--spacing-sm);
3 | display: flex;
4 | gap: var(--spacing-md);
5 | justify-content: space-between;
6 | align-items: flex-start;
7 | height: calc(100% - 30px - var(--spacing-sm));
8 | }
9 |
--------------------------------------------------------------------------------
/packages/client/src/modules/TimeTrack/TimeTrack.tsx:
--------------------------------------------------------------------------------
1 | import EditAppointmentModal from "../../components/modals/EditAppointmentModal/EditAppointmentModal";
2 | import WeekCalendar from "../../components/TimeTrack/WeekCalendar/WeekCalendar";
3 | import { updateTimeTrackEntryAsync } from "../../services/timeTrackService";
4 | import { unfocusInputs } from "../../services/uiService";
5 | import {
6 | handleCloseModal,
7 | modalStore,
8 | ModalType,
9 | } from "../../stores/modalStore";
10 | import styles from "./TimeTrack.module.css";
11 |
12 | const TimeTrack = () => {
13 | const handleEditAppointment = async () => {
14 | const timeTrackEntry = modalStore.selectedAppointment;
15 | if (!timeTrackEntry) return;
16 |
17 | await updateTimeTrackEntryAsync(timeTrackEntry);
18 | unfocusInputs();
19 | };
20 |
21 | return (
22 | <>
23 |
24 |
25 |
26 |
27 | {modalStore.activeModals.includes(ModalType.EditAppointment) && (
28 |
32 | )}
33 | >
34 | );
35 | };
36 |
37 | export default TimeTrack;
38 |
--------------------------------------------------------------------------------
/packages/client/src/routes.ts:
--------------------------------------------------------------------------------
1 | import { lazy } from "solid-js";
2 |
3 | export const routes = [
4 | {
5 | path: "/",
6 | component: lazy(() => import("./modules/KanbanBoard/KanbanBoard")),
7 | },
8 | {
9 | path: "/kanban",
10 | component: lazy(() => import("./modules/KanbanBoard/KanbanBoard")),
11 | },
12 | {
13 | path: "/timetrack",
14 | component: lazy(() => import("./modules/TimeTrack/TimeTrack")),
15 | },
16 | ];
17 |
--------------------------------------------------------------------------------
/packages/client/src/services/customProjectService.ts:
--------------------------------------------------------------------------------
1 | import { addNotification } from "@client/services/notificationService";
2 | import type { Project } from "@client/stores/uiStore";
3 | import axios, { AxiosError } from "axios";
4 |
5 | export const createProjectAsync = async (name: string) => {
6 | try {
7 | const response = await axios.post("/projects", { name });
8 | if (response.status === 200) {
9 | addNotification({
10 | title: "Project created",
11 | description: `Project ${name} has been created`,
12 | type: "success",
13 | });
14 | }
15 |
16 | return response.data;
17 | } catch (error) {
18 | if (error instanceof AxiosError)
19 | addNotification({
20 | title: "Error",
21 | description: error.message,
22 | type: "error",
23 | });
24 | }
25 | };
26 |
27 | export const deleteProjectAsync = async (projectId: string) => {
28 | try {
29 | const response = await axios.delete(`/projects/${projectId}`);
30 | if (response.status === 200) {
31 | addNotification({
32 | title: "Project deleted",
33 | description: `Project has been deleted`,
34 | type: "success",
35 | });
36 | }
37 | } catch (error) {
38 | if (error instanceof AxiosError)
39 | addNotification({
40 | title: "Error",
41 | description: error.message,
42 | type: "error",
43 | });
44 | }
45 | };
46 |
47 | export const loadCustomProjectsAsync = async () => {
48 | try {
49 | const response = await axios.get("/projects");
50 | return response.data.map((project: Project) => ({
51 | ...project,
52 | custom: true,
53 | id: project._id,
54 | }));
55 | } catch (error) {
56 | if (error instanceof AxiosError)
57 | addNotification({
58 | title: "Error",
59 | description: error.message,
60 | type: "error",
61 | });
62 | return [];
63 | }
64 | };
65 |
66 | export const setProjectAsync = async (projectId: string) => {
67 | try {
68 | const response = await axios.patch("/projects", { projectId });
69 | return response;
70 | } catch (error) {
71 | return;
72 | }
73 | };
74 |
75 | export const getProjectAsync = async () => {
76 | try {
77 | const response = await axios.get("/projects/current");
78 | return {
79 | ...response.data,
80 | name: response.data?.name_with_namespace || response.data?.name,
81 | };
82 | } catch (error) {
83 | return null;
84 | }
85 | };
86 |
--------------------------------------------------------------------------------
/packages/client/src/services/keyboardShortcutHandler.ts:
--------------------------------------------------------------------------------
1 | import type { Location, Navigator } from "@solidjs/router";
2 | import ShortcutRegistry from "../shortcutMaps/shortcutRegistry";
3 | import { resetCommandline } from "../stores/commandStore";
4 | import { modalStore } from "../stores/modalStore";
5 | import {
6 | CalendarMode,
7 | InputMode,
8 | setCalendarMode,
9 | uiStore,
10 | } from "../stores/uiStore";
11 | import { closeTopModal, getTopModal, openHelpModal } from "./modalService";
12 | import { unfocusInputs } from "./uiService";
13 |
14 | export const handleKeyDown = async (
15 | event: KeyboardEvent,
16 | navigator: Navigator,
17 | location: Location,
18 | ) => {
19 | const key = location.pathname.split("/")[1];
20 |
21 | if (
22 | (event.key === "Escape" || event.key === "q") &&
23 | !modalStore.activeModals.length
24 | ) {
25 | unfocusInputs();
26 | setCalendarMode(CalendarMode.Time);
27 |
28 | if (
29 | uiStore.inputMode === InputMode.Commandline ||
30 | (uiStore.inputMode === InputMode.Search &&
31 | uiStore.commandInputValue === "")
32 | ) {
33 | resetCommandline();
34 | }
35 | return;
36 | }
37 |
38 | if (
39 | document.activeElement?.tagName === "INPUT" ||
40 | document.activeElement?.tagName === "TEXTAREA" ||
41 | document.activeElement?.tagName === "SELECT"
42 | ) {
43 | return;
44 | }
45 |
46 | if (event.shiftKey) {
47 | switch (event.code) {
48 | case "Digit1":
49 | navigator("/kanban");
50 | return;
51 | case "Digit2":
52 | navigator("/timetrack");
53 | return;
54 | case "Digit3":
55 | navigator("/todo");
56 | return;
57 | case "Digit0":
58 | navigator("/");
59 | return;
60 | }
61 | }
62 |
63 | if (event.shiftKey && event.key === "?") {
64 | openHelpModal();
65 | return;
66 | }
67 |
68 | if (!modalStore.activeModals.length) {
69 | ShortcutRegistry.getInstance().executeShortcut(key, event);
70 | } else {
71 | const topModal = getTopModal();
72 | ShortcutRegistry.getInstance().executeShortcut(topModal, event);
73 | }
74 | };
75 |
--------------------------------------------------------------------------------
/packages/client/src/services/modalService.ts:
--------------------------------------------------------------------------------
1 | import { STATES } from "../constants";
2 | import { keyboardNavigationStore } from "../stores/keyboardNavigationStore";
3 | import {
4 | closeModal,
5 | handleCloseModal,
6 | modalStore,
7 | ModalType,
8 | openModal,
9 | setSelectedAppointmentForModal,
10 | setSelectedTaskForModal,
11 | } from "../stores/modalStore";
12 | import { getColumnTasks } from "../stores/taskStore";
13 | import { timeTrackStore } from "../stores/timeTrackStore";
14 | import type { ITask } from "shared/types/task";
15 |
16 | export const openHelpModal = () => {
17 | openModal(ModalType.Help);
18 | };
19 |
20 | export const openDeleteModal = () => {
21 | openModal(ModalType.DeleteTask);
22 | setSelectedTaskForModal(
23 | getColumnTasks()[keyboardNavigationStore.selectedTaskIndex],
24 | );
25 | };
26 |
27 | export const openCreateModal = () => {
28 | openModal(ModalType.CreateTask);
29 | setSelectedTaskForModal({
30 | title: "",
31 | description: "",
32 | status: STATES.at(0)!.id,
33 | });
34 | };
35 |
36 | export const openEditTaskModal = () => {
37 | openModal(ModalType.CreateTask);
38 | setSelectedTaskForModal(
39 | getColumnTasks()[keyboardNavigationStore.selectedTaskIndex],
40 | );
41 | };
42 |
43 | export const openEditAppointmentModal = () => {
44 | openModal(ModalType.EditAppointment);
45 | setSelectedAppointmentForModal(
46 | timeTrackStore.entries[keyboardNavigationStore.selectedAppointmentIndex],
47 | );
48 | };
49 |
50 | export const openDetailsModal = () => {
51 | setSelectedTaskForModal(
52 | getColumnTasks()[keyboardNavigationStore.selectedTaskIndex],
53 | );
54 |
55 | openModal(ModalType.TaskDetails);
56 | };
57 |
58 | export const openPipelineModal = () => {
59 | setSelectedTaskForModal(
60 | getColumnTasks()[keyboardNavigationStore.selectedTaskIndex],
61 | );
62 |
63 | openModal(ModalType.Pipeline);
64 | };
65 |
66 | export const openBranchNameModal = (task: ITask) => {
67 | setSelectedTaskForModal(task);
68 | openModal(ModalType.BranchName);
69 | };
70 |
71 | export const getTopModal = () => {
72 | return modalStore.activeModals?.at(-1) ?? ModalType.None;
73 | };
74 |
75 | export const closeTopModal = () => {
76 | handleCloseModal();
77 | };
78 |
--------------------------------------------------------------------------------
/packages/client/src/services/notificationService.ts:
--------------------------------------------------------------------------------
1 | import { createSignal } from "solid-js";
2 |
3 | export interface Notification {
4 | id: number;
5 | title: string;
6 | description: string;
7 | type: "success" | "error" | "warning";
8 | duration: number;
9 | }
10 |
11 | const [notifications, setNotifications] = createSignal([]);
12 |
13 | export const addNotification = (
14 | notification: Omit, "id">
15 | ) => {
16 | const id = new Date().getTime();
17 | const newNotification = {
18 | ...notification,
19 | id,
20 | duration: notification.duration || 3000,
21 | };
22 | setNotifications([...notifications(), newNotification as Notification]);
23 |
24 | setTimeout(() => {
25 | setNotifications((prev) => prev.filter((n) => n.id !== id));
26 | }, notification.duration || 3000);
27 | };
28 |
29 | export const clearNotifications = () => setNotifications([]);
30 |
31 | export const useNotifications = () => notifications;
32 |
--------------------------------------------------------------------------------
/packages/client/src/services/settingsService.ts:
--------------------------------------------------------------------------------
1 | import axios from "axios";
2 | import { addNotification } from "./notificationService";
3 |
4 | export interface SetupStatus {
5 | isComplete: boolean;
6 | currentStep?: string;
7 | }
8 |
9 | export interface GitlabValidationResult {
10 | isValid: boolean;
11 | user?: any;
12 | error?: string;
13 | }
14 |
15 | class SettingsService {
16 | async getSetupStatus(): Promise {
17 | try {
18 | const response = await axios.get("/settings/setup-status");
19 | return response.data;
20 | } catch (error) {
21 | return { isComplete: false };
22 | }
23 | }
24 |
25 | async validateGitLabConnection(
26 | url: string,
27 | token: string,
28 | ): Promise {
29 | try {
30 | const response = await axios.post("/settings/validate-gitlab", {
31 | url,
32 | token,
33 | });
34 | return response.data;
35 | } catch (error) {
36 | return {
37 | isValid: false,
38 | error: "Failed to validate connection",
39 | };
40 | }
41 | }
42 |
43 | async saveGitLabConfig(url: string, token: string): Promise {
44 | try {
45 | await axios.post("/settings/gitlab-config", { url, token });
46 | } catch (error) {
47 | throw new Error("Failed to save GitLab configuration");
48 | }
49 | }
50 |
51 | async completeSetup(): Promise {
52 | try {
53 | await axios.post("/settings/complete-setup");
54 | } catch (error) {
55 | throw new Error("Failed to complete setup");
56 | }
57 | }
58 |
59 | async updateSetting(key: string, value: any): Promise {
60 | try {
61 | await axios.put(`/settings/${key}`, { value });
62 | addNotification({
63 | title: "Success",
64 | description: "Setting updated successfully",
65 | type: "success",
66 | });
67 | } catch (error) {
68 | addNotification({
69 | title: "Error",
70 | description: "Failed to update setting",
71 | type: "error",
72 | });
73 | throw error;
74 | }
75 | }
76 | }
77 |
78 | export const settingsService = new SettingsService();
79 |
--------------------------------------------------------------------------------
/packages/client/src/services/taskNavigationService.ts:
--------------------------------------------------------------------------------
1 | import { STATES } from "../constants";
2 | import {
3 | keyboardNavigationStore,
4 | setSelectedColumnIndex,
5 | setSelectedTaskIndex,
6 | setSelectedTaskIndexes,
7 | } from "../stores/keyboardNavigationStore";
8 | import { filteredTasks, getColumnTasks } from "../stores/taskStore";
9 |
10 | export enum Direction {
11 | Up,
12 | Down,
13 | Left,
14 | Right,
15 | }
16 |
17 | export const moveSelection = (direction: Direction) => {
18 | const columnTasks = getColumnTasks();
19 | switch (direction) {
20 | case Direction.Up:
21 | setSelectedTaskIndex(
22 | (prev) => (prev - 1 + columnTasks.length) % columnTasks.length,
23 | );
24 | setSelectedTaskIndexes([keyboardNavigationStore.selectedTaskIndex]);
25 | break;
26 | case Direction.Down:
27 | setSelectedTaskIndex((prev) => (prev + 1) % columnTasks.length);
28 | setSelectedTaskIndexes([keyboardNavigationStore.selectedTaskIndex]);
29 |
30 | break;
31 | case Direction.Left:
32 | do {
33 | setSelectedColumnIndex(
34 | (prev) => (prev - 1 + STATES.length) % STATES.length,
35 | );
36 | } while (filteredTasks().length > 0 && getColumnTasks().length === 0);
37 | setSelectedTaskIndexes([0]);
38 | setSelectedTaskIndex(0);
39 | break;
40 | case Direction.Right:
41 | do {
42 | setSelectedColumnIndex((prev) => (prev + 1) % STATES.length);
43 | } while (filteredTasks().length > 0 && getColumnTasks().length === 0);
44 | setSelectedTaskIndex(0);
45 | setSelectedTaskIndexes([0]);
46 | break;
47 | }
48 | };
49 |
50 | export const moveSelectionToTop = () => {
51 | setSelectedTaskIndex(0);
52 | setSelectedTaskIndexes([0]);
53 | };
54 |
55 | export const moveSelectionToBottom = () => {
56 | setSelectedTaskIndex(getColumnTasks().length - 1);
57 | setSelectedTaskIndexes([getColumnTasks().length - 1]);
58 | };
59 |
60 | export const addToSelection = (direction: Direction) => {
61 | const columnTasks = getColumnTasks();
62 | switch (direction) {
63 | case Direction.Up:
64 | setSelectedTaskIndex((prev) => Math.max(prev - 1, 0));
65 |
66 | setSelectedTaskIndexes((prev) => [
67 | ...prev.filter(
68 | (index) =>
69 | index <= prev.at(0)! ||
70 | index < keyboardNavigationStore.selectedTaskIndex,
71 | ),
72 | keyboardNavigationStore.selectedTaskIndex,
73 | ]);
74 | break;
75 | case Direction.Down:
76 | setSelectedTaskIndex((prev) =>
77 | Math.min(prev + 1, columnTasks.length - 1),
78 | );
79 |
80 | setSelectedTaskIndexes((prev) => [
81 | ...prev.filter(
82 | (index) =>
83 | index >= prev.at(0)! ||
84 | index > keyboardNavigationStore.selectedTaskIndex,
85 | ),
86 | keyboardNavigationStore.selectedTaskIndex,
87 | ]);
88 | break;
89 | }
90 | };
91 |
--------------------------------------------------------------------------------
/packages/client/src/services/timeTrackService.ts:
--------------------------------------------------------------------------------
1 | import axios from "axios";
2 | import { type TimeTrackEntry } from "../stores/timeTrackStore";
3 | import { addNotification } from "./notificationService";
4 |
5 | export const updateTimeTrackEntryAsync = async (
6 | timeTrackEntry: TimeTrackEntry
7 | ) => {
8 | try {
9 | const timeTrackEntryId = timeTrackEntry._id;
10 | const res = await axios.put(
11 | `/timetrack/${timeTrackEntryId}`,
12 | timeTrackEntry
13 | );
14 |
15 | addNotification({
16 | title: "Success",
17 | description: "Appointment updated successfully",
18 | type: "success",
19 | });
20 |
21 | return res;
22 | } catch (error) {
23 | addNotification({
24 | title: "Error",
25 | description: "Failed to update appointment",
26 | type: "error",
27 | });
28 | }
29 | };
30 |
--------------------------------------------------------------------------------
/packages/client/src/services/uiService.ts:
--------------------------------------------------------------------------------
1 | import { handleCloseModal } from "../stores/modalStore";
2 | import { InputMode, setInputMode, uiStore } from "../stores/uiStore";
3 |
4 | export const focusInput = (inputMode: InputMode) => {
5 | if (uiStore.commandInputRef === null) {
6 | return;
7 | }
8 | setInputMode(inputMode);
9 |
10 | setTimeout(() => {
11 | uiStore.commandInputRef!.select();
12 | uiStore.commandInputRef!.focus();
13 | }, 0);
14 | };
15 |
16 | export const unfocusInputs = () => {
17 | setTimeout(() => {
18 | const activeElement = document.activeElement as HTMLElement;
19 | if (activeElement) {
20 | activeElement.blur();
21 | }
22 | }, 0);
23 | };
24 |
--------------------------------------------------------------------------------
/packages/client/src/services/userService.ts:
--------------------------------------------------------------------------------
1 | import axios from "axios";
2 | import { setUser } from "../stores/uiStore";
3 |
4 | export const getUserAsync = async () => {
5 | try {
6 | const userResponse = await axios.get(`/git/user`);
7 | setUser(userResponse.data);
8 | } catch (error) {
9 | return null;
10 | }
11 | };
12 |
--------------------------------------------------------------------------------
/packages/client/src/shortcutMaps/baseShortcutMap.ts:
--------------------------------------------------------------------------------
1 | import { closeTopModal, openHelpModal } from "../services/modalService";
2 | import { focusInput } from "../services/uiService";
3 | import { InputMode } from "../stores/uiStore";
4 | import ShortcutRegistry from "./shortcutRegistry";
5 | import { type KeyboardShortcutMap, KeyboardShortcutCategory } from "./types";
6 |
7 | const shortcuts: KeyboardShortcutMap = {
8 | key: "base",
9 | shortcuts: [
10 | {
11 | key: ["q", "Escape"],
12 | action: () => closeTopModal(),
13 | category: KeyboardShortcutCategory.Commands,
14 | description: "Close modal",
15 | },
16 | {
17 | key: ":",
18 | action: () => focusInput(InputMode.Commandline),
19 | category: KeyboardShortcutCategory.Commands,
20 | description: "Open commandline",
21 | },
22 | {
23 | key: "/",
24 | action: () => focusInput(InputMode.Search),
25 | category: KeyboardShortcutCategory.Commands,
26 | description: "Open search",
27 | },
28 | {
29 | shiftKey: true,
30 | key: ":",
31 | action: () => focusInput(InputMode.Commandline),
32 | category: KeyboardShortcutCategory.Commands,
33 | description: "Open commandline",
34 | },
35 | {
36 | shiftKey: true,
37 | key: "?",
38 | action: () => openHelpModal(),
39 | category: KeyboardShortcutCategory.Commands,
40 | description: "Open help",
41 | },
42 |
43 | {
44 | ctrlKey: true,
45 | key: "p",
46 | action: () => focusInput(InputMode.Commandline),
47 | category: KeyboardShortcutCategory.Commands,
48 | description: "Focus commandline",
49 | },
50 | {
51 | ctrlKey: true,
52 | key: "f",
53 | action: () => focusInput(InputMode.Search),
54 | category: KeyboardShortcutCategory.Commands,
55 | description: "Focus search",
56 | },
57 | ],
58 | };
59 |
60 | ShortcutRegistry.getInstance().registerShortcut(shortcuts);
61 |
--------------------------------------------------------------------------------
/packages/client/src/shortcutMaps/shortcutRegistry.ts:
--------------------------------------------------------------------------------
1 | import type { KeyboardShortcutMap, Shortcut } from "./types";
2 |
3 | class ShortcutRegistry {
4 | private static instance: ShortcutRegistry;
5 |
6 | private static shortcutMaps: KeyboardShortcutMap[] = [];
7 |
8 | private constructor() {}
9 |
10 | public static getInstance(): ShortcutRegistry {
11 | if (!ShortcutRegistry.instance) {
12 | ShortcutRegistry.instance = new ShortcutRegistry();
13 | }
14 |
15 | return ShortcutRegistry.instance;
16 | }
17 |
18 | public registerShortcut(shortcutMap: KeyboardShortcutMap) {
19 | if (!ShortcutRegistry.shortcutMaps.some((sc) => sc.key === shortcutMap.key))
20 | ShortcutRegistry.shortcutMaps.push(shortcutMap);
21 | }
22 |
23 | public async initializeAsync() {
24 | const shortcutModules = import.meta.glob([
25 | "./*ShortcutMap.ts",
26 | "./*shortcut-map.ts",
27 | ]);
28 |
29 | for (const path in shortcutModules) {
30 | await shortcutModules[path](); // This will load each command file
31 | }
32 | }
33 |
34 | public getShortcutsByKey(key: string) {
35 | return ShortcutRegistry.shortcutMaps.find((sc) => sc.key === key);
36 | }
37 |
38 | public resetShortcutRegistry() {
39 | ShortcutRegistry.shortcutMaps.length = 0;
40 | }
41 |
42 | public executeShortcut(mapKey: string, event: KeyboardEvent) {
43 | const { key, shiftKey, ctrlKey, altKey } = event;
44 |
45 | const baseMap = this.getShortcutsByKey("base");
46 | const map = this.getShortcutsByKey(mapKey);
47 |
48 | const shortCuts = (map?.shortcuts || []).concat(baseMap?.shortcuts || []);
49 |
50 | if (shortCuts === undefined || shortCuts?.length === 0) return;
51 |
52 | const shortcut = shortCuts.find((sc: Shortcut) => {
53 | if (Array.isArray(sc.key)) {
54 | return (
55 | sc.key.includes(key) &&
56 | ((sc.shiftKey === undefined && !shiftKey) ||
57 | sc.shiftKey === shiftKey) &&
58 | ((sc.ctrlKey === undefined && !ctrlKey) || sc.ctrlKey === ctrlKey) &&
59 | ((sc.altKey === undefined && !altKey) || sc.altKey === altKey)
60 | );
61 | } else {
62 | return (
63 | sc.key === key &&
64 | ((sc.shiftKey === undefined && !shiftKey) ||
65 | sc.shiftKey === shiftKey) &&
66 | ((sc.ctrlKey === undefined && !ctrlKey) || sc.ctrlKey === ctrlKey) &&
67 | ((sc.altKey === undefined && !altKey) || sc.altKey === altKey)
68 | );
69 | }
70 | });
71 |
72 | if (shortcut) {
73 | event.preventDefault();
74 | event.stopPropagation();
75 | shortcut.action();
76 | }
77 | }
78 | }
79 |
80 | export default ShortcutRegistry;
81 |
--------------------------------------------------------------------------------
/packages/client/src/shortcutMaps/taskDetails.shortcut-map.ts:
--------------------------------------------------------------------------------
1 | import { modalStore, ModalType } from "@client/stores/modalStore";
2 | import ShortcutRegistry from "./shortcutRegistry";
3 | import { type KeyboardShortcutMap, KeyboardShortcutCategory } from "./types";
4 | import { TaskDetailsModalService } from "@client/components/modals/TaskDetailsModal/task-details-modal.store";
5 | import { Direction } from "@client/services/taskNavigationService";
6 | import { closeTopModal } from "@client/services/modalService";
7 |
8 | const shortcuts: KeyboardShortcutMap = {
9 | key: ModalType.TaskDetails,
10 | shortcuts: [
11 | {
12 | key: ["q", "Escape"],
13 | action: () => closeTopModal(),
14 | category: KeyboardShortcutCategory.TaskDetails,
15 | description: "Close modal",
16 | },
17 | {
18 | key: ["j", "ArrowDown"],
19 | action: () => TaskDetailsModalService.moveSelection(Direction.Down),
20 | category: KeyboardShortcutCategory.TaskDetails,
21 | description: "Move selection down",
22 | },
23 | {
24 | key: ["k", "ArrowUp"],
25 | action: () => TaskDetailsModalService.moveSelection(Direction.Up),
26 | category: KeyboardShortcutCategory.TaskDetails,
27 | description: "Move selection up",
28 | },
29 | {
30 | key: "r",
31 | action: () => TaskDetailsModalService.reply(),
32 | category: KeyboardShortcutCategory.TaskDetails,
33 | description: "Reply to discussion",
34 | },
35 | {
36 | key: "R",
37 | shiftKey: true,
38 | action: () => TaskDetailsModalService.resolveThread(),
39 | category: KeyboardShortcutCategory.TaskDetails,
40 | description: "Mark discussion as resolved",
41 | },
42 | {
43 | key: "s",
44 | action: () => TaskDetailsModalService.toggleSystemDiscussions(),
45 | category: KeyboardShortcutCategory.TaskDetails,
46 | description: "Toggles system notes",
47 | },
48 | {
49 | key: "t",
50 | action: () => TaskDetailsModalService.toggleResolvedDiscussions(),
51 | category: KeyboardShortcutCategory.TaskDetails,
52 | description: "Toggles resolved discussions",
53 | },
54 | {
55 | key: "O",
56 | shiftKey: true,
57 | action: () => window.open(modalStore.selectedTask!.web_url, "_blank"),
58 | category: KeyboardShortcutCategory.TaskDetails,
59 | description: "Open task in new tab",
60 | },
61 | ],
62 | };
63 |
64 | ShortcutRegistry.getInstance().registerShortcut(shortcuts);
65 |
--------------------------------------------------------------------------------
/packages/client/src/shortcutMaps/types.ts:
--------------------------------------------------------------------------------
1 | export enum KeyboardShortcutCategory {
2 | Navigation = "Navigation",
3 | TaskManagement = "Task Management",
4 | GitlabAction = "Gitlab Action",
5 | CalendarManagement = "Calendar Management",
6 | Commands = "Commands",
7 |
8 | TaskDetails = "Task Details",
9 | }
10 |
11 | export type KeyboardShortcutMap = {
12 | key: string;
13 | shortcuts: Shortcut[];
14 | };
15 |
16 | export type Shortcut = {
17 | action: () => any;
18 | key: string[] | string;
19 | ctrlKey?: boolean;
20 | shiftKey?: boolean;
21 | altKey?: boolean;
22 | category: KeyboardShortcutCategory;
23 | description: string;
24 | };
25 |
--------------------------------------------------------------------------------
/packages/client/src/sockets/taskSockets.ts:
--------------------------------------------------------------------------------
1 | import {
2 | updateComments,
3 | updateTasks,
4 | updateDiscussions,
5 | } from "@client/stores/taskStore";
6 | import { debounce } from "lodash";
7 | import type { ITask } from "shared/types/task";
8 | import { Socket } from "socket.io-client";
9 |
10 | export class TaskSockets {
11 | private activeCalls: { [key: string]: any[] } = {
12 | comments: [],
13 | tasks: [],
14 | discussions: [],
15 | };
16 |
17 | public addListeners(io: Socket) {
18 | io.on("updateComments", (data: { taskId: string; comments: Comment[] }) => {
19 | this.handleUpdateComments(data);
20 | });
21 |
22 | io.on(
23 | "updateDiscussions",
24 | (data: { taskId: string; discussions: any[] }) => {
25 | this.handleUpdateDiscussions(data);
26 | }
27 | );
28 |
29 | io.on("updateTasks", (data: ITask[]) => {
30 | this.handleUpdateTasks(data);
31 | });
32 | }
33 |
34 | private handleUpdateComments = (data: {
35 | taskId: string;
36 | comments: Comment[];
37 | }) => {
38 | this.activeCalls["comments"].push(data);
39 |
40 | this.debouncedUpdate();
41 | };
42 |
43 | private handleUpdateDiscussions = (data: {
44 | taskId: string;
45 | discussions: any[];
46 | }) => {
47 | this.activeCalls["discussions"].push(data);
48 |
49 | this.debouncedUpdate();
50 | };
51 |
52 | private handleUpdateTasks = (data: ITask[]) => {
53 | this.activeCalls["tasks"].push(...data);
54 |
55 | this.debouncedUpdate();
56 | };
57 |
58 | private debouncedUpdate = debounce(() => {
59 | if (this.activeCalls["comments"].length > 0) {
60 | updateComments(this.activeCalls["comments"]);
61 | }
62 |
63 | if (this.activeCalls["tasks"].length > 0) {
64 | updateTasks(this.activeCalls["tasks"]);
65 | }
66 |
67 | if (this.activeCalls["discussions"].length > 0) {
68 | updateDiscussions(this.activeCalls["discussions"]);
69 | }
70 |
71 | this.activeCalls["comments"] = [];
72 | this.activeCalls["tasks"] = [];
73 | this.activeCalls["discussions"] = [];
74 | }, 1000);
75 | }
76 |
--------------------------------------------------------------------------------
/packages/client/src/stores/discussion.store.ts:
--------------------------------------------------------------------------------
1 | import axios from "axios";
2 | import type { IDiscussion } from "shared/types/task";
3 | import { createStore } from "solid-js/store";
4 | import { LoadingTarget, setLoading, uiStore } from "./uiStore";
5 |
6 | interface DiscussionStore {
7 | discussions: IDiscussion[];
8 | }
9 |
10 | export const [discussionStore, setDiscussionStore] =
11 | createStore({
12 | discussions: [],
13 | });
14 |
15 | export const fetchDiscussions = async (id: number, type: string) => {
16 | setLoading(LoadingTarget.LoadDiscussions);
17 | const res = await axios.get(`/tasks/discussions?id=${id}&type=${type}`);
18 |
19 | setDiscussionStore("discussions", res.data);
20 | setLoading(LoadingTarget.None);
21 | };
22 |
--------------------------------------------------------------------------------
/packages/client/src/stores/keyboardNavigationStore.ts:
--------------------------------------------------------------------------------
1 | import { createStore } from "solid-js/store";
2 |
3 | export enum NavigationKeys {
4 | Discussion,
5 | }
6 |
7 | export const [keyboardNavigationStore, setKeyboardNavigationStore] =
8 | createStore({
9 | selectedColumnIndex: 0,
10 | selectedTaskIndex: 0,
11 | selectedTaskIndexes: [0] as number[],
12 | selectedDayIndex: 0,
13 | selectedHourIndex: 0,
14 | selectedQuarterHourIndex: 0,
15 | selectedQuarterHourIndexes: [0] as number[],
16 | selectedAppointmentIndex: 0,
17 | selectedIndices: [] as { key: number; value: number }[],
18 | });
19 |
20 | const updateStoreIndex = (
21 | key: keyof typeof keyboardNavigationStore,
22 | updater: T | ((prev: T) => T),
23 | ) => {
24 | const prevValue = keyboardNavigationStore[key];
25 | const newValue =
26 | typeof updater === "function" ? (updater as Function)(prevValue) : updater;
27 |
28 | // Validate only if the value is a single number and check for NaN
29 | if (typeof newValue === "number" && isNaN(newValue)) return;
30 |
31 | setKeyboardNavigationStore(key, newValue);
32 | };
33 |
34 | export const setNavIndex = (key: number, index: number) => {
35 | setKeyboardNavigationStore("selectedIndices", (prev) => {
36 | if (prev.some((item) => item.key === key)) {
37 | return prev.map((item) =>
38 | item.key === key ? { key, value: index } : item,
39 | );
40 | }
41 |
42 | return [...prev, { key, value: index }];
43 | });
44 | };
45 |
46 | export const getNavIndex = (key: number): number => {
47 | return (
48 | keyboardNavigationStore.selectedIndices.find((item) => item.key === key)
49 | ?.value ?? 0
50 | );
51 | };
52 |
53 | // Individual setter functions using the generic update function
54 | export const setSelectedColumnIndex = (
55 | updater: number | ((prev: number) => number),
56 | ) => updateStoreIndex("selectedColumnIndex", updater);
57 |
58 | export const setSelectedTaskIndex = (
59 | updater: number | ((prev: number) => number),
60 | ) => updateStoreIndex("selectedTaskIndex", updater);
61 |
62 | export const setSelectedTaskIndexes = (
63 | updater: number[] | ((prev: number[]) => number[]),
64 | ) => updateStoreIndex("selectedTaskIndexes", updater);
65 |
66 | export const setSelectedDayIndex = (
67 | updater: number | ((prev: number) => number),
68 | ) => updateStoreIndex("selectedDayIndex", updater);
69 |
70 | export const setSelectedHourIndex = (
71 | updater: number | ((prev: number) => number),
72 | ) => updateStoreIndex("selectedHourIndex", updater);
73 |
74 | export const setSelectedQuarterHourIndex = (
75 | updater: number | ((prev: number) => number),
76 | ) => updateStoreIndex("selectedQuarterHourIndex", updater);
77 |
78 | export const setSelectedQuarterHourIndexes = (
79 | updater: number[] | ((prev: number[]) => number[]),
80 | ) => updateStoreIndex("selectedQuarterHourIndexes", updater);
81 |
82 | export const setSelectedAppointmentIndex = (
83 | updater: number | ((prev: number) => number),
84 | ) => updateStoreIndex("selectedAppointmentIndex", updater);
85 |
--------------------------------------------------------------------------------
/packages/client/src/stores/ruleStore.ts:
--------------------------------------------------------------------------------
1 | import { createStore } from "solid-js/store";
2 |
3 | export interface Emitter {
4 | className: string;
5 | eventNamespace: string;
6 | eventType: string;
7 | methodName: string;
8 | }
9 |
10 | export interface Listener {
11 | className: string;
12 | eventNamespace: string;
13 | eventType: string;
14 | methodName: string;
15 | hasParams: boolean;
16 | }
17 |
18 | export type Rule = {
19 | _id: string;
20 | name: string;
21 | eventType: string;
22 | conditions: Condition[];
23 | actions: Action[];
24 | enabled: boolean;
25 | };
26 |
27 | export type Condition = {
28 | fieldPath: string;
29 | operator: "==" | "!=" | ">" | "<";
30 | value: any;
31 | };
32 |
33 | export type Action = {
34 | targetPath: string;
35 | value: any;
36 | };
37 |
38 | export const [ruleStore, setRuleStore] = createStore({
39 | emitters: [] as Emitter[],
40 | listeners: [] as Listener[],
41 | rules: [] as Rule[],
42 | });
43 |
--------------------------------------------------------------------------------
/packages/client/src/stores/settings.store.ts:
--------------------------------------------------------------------------------
1 | import axios from "axios";
2 | import { createStore } from "solid-js/store";
3 |
4 | export const [settingsStore, setSettingsStore] = createStore({
5 | gitlab_url: "",
6 | });
7 |
8 | export const getGitlabUrl = async () => {
9 | try {
10 | const res = await axios.get(`/settings/gitlab-url`);
11 | const response = res.data;
12 |
13 | setSettingsStore("gitlab_url", response);
14 | } catch (error) {
15 | console.error("Failed to fetch GitLab URL:", error);
16 | }
17 | };
18 |
--------------------------------------------------------------------------------
/packages/client/src/stores/timeTrackStore.ts:
--------------------------------------------------------------------------------
1 | import axios from "axios";
2 | import { createStore } from "solid-js/store";
3 | import { addNotification } from "../services/notificationService";
4 |
5 | export type TimeTrackEntry = {
6 | _id: string;
7 | start: Date;
8 | end: Date;
9 | };
10 |
11 | export const [timeTrackStore, setTimeTrackStore] = createStore({
12 | entries: [] as TimeTrackEntry[],
13 | recording: false,
14 | });
15 |
16 | export const fetchTimeTrackEntries = async () => {
17 | const res = await axios.get(`/timetrack`);
18 | const response = res.data;
19 |
20 | if (response.error) {
21 | addNotification({
22 | title: "Error",
23 | description: response.error,
24 | type: "error",
25 | duration: 5000,
26 | });
27 | }
28 |
29 | setTimeTrackStore("entries", response);
30 |
31 | return response;
32 | };
33 |
34 | export const fetchRecordingStateAsync = async () => {
35 | const res = await axios.get(`/timetrack/recording`);
36 | const response = res.data;
37 | if (response.error) {
38 | addNotification({
39 | title: "Error",
40 | description: response.error,
41 | type: "error",
42 | duration: 5000,
43 | });
44 | }
45 | setTimeTrackStore("recording", response);
46 | return response;
47 | };
48 |
49 | export const toggleTimetrackAsync = async () => {
50 | const res = await axios.put(`/timetrack/recording`);
51 | const response = res.data;
52 |
53 | if (response.error) {
54 | addNotification({
55 | title: "Error",
56 | description: response.error,
57 | type: "error",
58 | duration: 5000,
59 | });
60 | }
61 |
62 | setTimeTrackStore("recording", response);
63 | return response;
64 | };
65 |
--------------------------------------------------------------------------------
/packages/client/src/styles/base.css:
--------------------------------------------------------------------------------
1 | html,
2 | body,
3 | #root {
4 | height: 100%;
5 | }
6 |
7 | body {
8 | margin: 0;
9 | font-family: "sans-serif";
10 | background-color: var(--color-mantle);
11 | color: var(--color-text);
12 | -webkit-font-smoothing: antialiased;
13 | -moz-osx-font-smoothing: grayscale;
14 | }
15 |
16 | h1,
17 | h2,
18 | h3 {
19 | color: var(--color-rosewater);
20 | margin: 0;
21 | }
22 |
23 | h1 {
24 | font-size: var(--font-size-xxl);
25 | }
26 |
27 | h2 {
28 | font-size: var(--font-size-xl);
29 | margin-bottom: var(--spacing-sm);
30 | border-bottom: 1px solid rgba(255, 255, 255, 0.1);
31 | padding-bottom: var(--spacing-sm);
32 | }
33 |
34 | p {
35 | margin: 0;
36 | line-height: 1.5;
37 | }
38 |
39 | .divider {
40 | border-bottom: 1px solid rgba(255, 255, 255, 0.1);
41 | margin: var(--spacing-sm) 0;
42 | }
43 |
44 | input,
45 | textarea,
46 | select {
47 | background-color: var(--color-surface1);
48 | color: var(--color-text);
49 | border: 1px solid var(--color-sky);
50 | border-radius: var(--spacing-xs);
51 | padding: var(--spacing-sm);
52 | font-size: var(--font-size-sm);
53 | }
54 |
55 | input::placeholder {
56 | color: var(--color-text);
57 | }
58 |
59 | select {
60 | padding: var(--spacing-sm);
61 | }
62 |
63 | input:focus,
64 | select:focus,
65 | textarea:focus {
66 | outline: none;
67 | border-color: var(--color-sky) !important;
68 | }
69 |
70 | kbd {
71 | background: var(--color-surface1);
72 | border: 1px solid var(--color-sky);
73 | border-radius: var(--spacing-xxs);
74 | color: var(--color-text);
75 | padding: var(--spacing-xxs) var(--spacing-xs);
76 | font-size: var(--font-size-sm);
77 | font-family: "Courier New", Courier, monospace !important;
78 | }
79 |
80 | section {
81 | min-height: 200px;
82 | padding: var(--spacing-sm);
83 | padding-bottom: 0px;
84 | border-radius: var(--spacing-md);
85 | border: 2px dashed rgba(200, 200, 255, 0.3);
86 | }
87 |
88 | /* Wave Text Animation */
89 | .wave-text {
90 | display: inline-flex;
91 | gap: 2px;
92 | }
93 |
94 | .wave-char {
95 | display: inline-block;
96 | position: relative;
97 | animation: wave 3s cubic-bezier(0.39, 0, 0.565, 1) infinite;
98 | animation-delay: calc(var(--index) * 0.05s);
99 | }
100 |
101 | @keyframes wave {
102 | 0% {
103 | transform: translateY(4px);
104 | color: var(--color-text);
105 | }
106 | 50% {
107 | transform: translateY(-4px);
108 | color: var(--color-teal);
109 | }
110 | 100% {
111 | transform: translateY(4px);
112 | color: var(--color-sky);
113 | }
114 | }
115 |
116 | .mention {
117 | display: inline-block;
118 | color: var(--color-teal);
119 | font-weight: bold;
120 | }
121 |
--------------------------------------------------------------------------------
/packages/client/src/styles/notification.css:
--------------------------------------------------------------------------------
1 | .notification-manager {
2 | position: fixed;
3 | top: 20px;
4 | right: 0;
5 | display: flex;
6 | flex-direction: column;
7 | gap: 15px;
8 | z-index: 1000;
9 | padding-right: 20px;
10 | }
11 |
--------------------------------------------------------------------------------
/packages/client/src/styles/scrollbar.css:
--------------------------------------------------------------------------------
1 | *::-webkit-scrollbar {
2 | width: 5px;
3 | }
4 |
5 | *::-webkit-scrollbar-thumb {
6 | background-color: var(--color-sky);
7 | border-radius: 10px;
8 | }
9 |
10 | *::-webkit-scrollbar-track {
11 | background-color: var(--color-mantle);
12 | }
13 |
14 | *::-webkit-scrollbar-thumb:hover {
15 | background-color: var(--color-teal);
16 | }
17 |
18 | *::-webkit-scrollbar-thumb:active {
19 | background-color: var(--color-sky);
20 | }
21 |
22 | *::-webkit-scrollbar-corner {
23 | background-color: var(--color-mantle);
24 | }
25 |
26 | *::-webkit-scrollbar-button {
27 | display: none;
28 | }
29 |
30 | *::-webkit-scrollbar-track-piece {
31 | background-color: var(--color-mantle);
32 | }
33 |
--------------------------------------------------------------------------------
/packages/client/src/styles/variables.css:
--------------------------------------------------------------------------------
1 | :root {
2 | /* Color Variables */
3 | --color-rosewater: #f4dbd6;
4 | --color-flamingo: #f0c6c6;
5 | --color-pink: #f5bde6;
6 | --color-mauve: #c6a0f6;
7 | --color-red: #ed8796;
8 | --color-maroon: #ee99a0;
9 | --color-peach: #f5a97f;
10 | --color-yellow: #eed49f;
11 | --color-green: #a6da95;
12 | --color-teal: #8bd5ca;
13 | --color-sky: #91d7e3;
14 | --color-sapphire: #7dc4e4;
15 | --color-blue: #8aadf4;
16 | --color-lavender: #b7bdf8;
17 | --color-text: #cad3f5;
18 | --color-subtext1: #b8c0e0;
19 | --color-subtext0: #a5adcb;
20 | --color-overlay2: #939ab7;
21 | --color-overlay1: #8087a2;
22 | --color-overlay0: #6e738d;
23 | --color-surface2: #5b6078;
24 | --color-surface1: #494d64;
25 | --color-surface0: #363a4f;
26 | --color-base: #24273a;
27 | --color-mantle: #1e2030;
28 | --color-crust: #181926;
29 |
30 | /* Spacing Variables */
31 | --spacing-xxs: 4px;
32 | --spacing-xs: 8px;
33 | --spacing-sm: 12px;
34 | --spacing-md: 16px;
35 | --spacing-lg: 24px;
36 | --spacing-xl: 32px;
37 | --spacing-xxl: 48px;
38 |
39 | /* Font Size Variables */
40 | --font-size-xs: 12px;
41 | --font-size-sm: 14px;
42 | --font-size-md: 16px;
43 | --font-size-lg: 20px;
44 | --font-size-xl: 24px;
45 | --font-size-xxl: 32px;
46 | --font-size-xxxl: 40px;
47 |
48 | /* Font Weight Variables */
49 | --font-weight-regular: 400;
50 | --font-weight-medium: 500;
51 | --font-weight-semibold: 600;
52 | --font-weight-bold: 700;
53 | }
54 |
--------------------------------------------------------------------------------
/packages/client/src/utils/orderLabels.ts:
--------------------------------------------------------------------------------
1 | import { sortBy } from "lodash";
2 |
3 | const priorityOrder = [
4 | "priority/INTERMEDIATE",
5 | "priority/STAGING",
6 | "priority/HIGH",
7 | "priority/MEDIUM",
8 | "priority/LOW",
9 | ];
10 |
11 | export const orderPriorityLabels = (labels: string[]) =>
12 | sortBy(labels ?? [], (label) => {
13 | const idx = priorityOrder.indexOf(label);
14 | return idx === -1 ? priorityOrder.length : idx;
15 | }).filter((item: string) => item.toLowerCase().includes("priority"));
16 |
--------------------------------------------------------------------------------
/packages/client/src/utils/parseMarkdown.ts:
--------------------------------------------------------------------------------
1 | import { marked } from "marked";
2 | import { uiStore } from "../stores/uiStore";
3 | import DOMPurify from "dompurify";
4 | import { settingsStore } from "@client/stores/settings.store";
5 |
6 | export const parseMarkdown = (input: string) => {
7 | const sanitized = DOMPurify.sanitize(input);
8 | const markdown = marked(sanitized, { async: false });
9 | const user = uiStore.user?.name;
10 |
11 | return markdown
12 | .replaceAll(
13 | 'src="/',
14 | `src="${settingsStore.gitlab_url}/-/project/${uiStore.currentProject?.id}/`,
15 | )
16 | .replaceAll(
17 | /
/g,
18 | '',
19 | )
20 | .replaceAll(
21 | /
/g,
22 | '',
23 | )
24 | .replaceAll(/@(\w+)/g, (match, mentionedUser) =>
25 | mentionedUser === user ? `${match}
` : match,
26 | );
27 | };
28 |
--------------------------------------------------------------------------------
/packages/client/src/utils/scrollIntoView.ts:
--------------------------------------------------------------------------------
1 | import { debounce } from "lodash";
2 |
3 | export const scrollIntoView = debounce((element: HTMLElement | null) => {
4 | if (element) {
5 | element.scrollIntoView({
6 | behavior: "smooth",
7 | block: "center",
8 | });
9 | }
10 | }, 100);
11 |
--------------------------------------------------------------------------------
/packages/client/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "../../tsconfig.json",
3 | "compilerOptions": {
4 | "allowSyntheticDefaultImports": true,
5 | "esModuleInterop": true,
6 | "jsx": "preserve",
7 | "jsxImportSource": "solid-js",
8 | "types": ["vite/client", "bun-types"],
9 | "noEmit": true,
10 | "isolatedModules": true,
11 |
12 | "baseUrl": "./src",
13 | "paths": {
14 | "@client/*": ["*"]
15 | }
16 | }
17 | }
18 |
--------------------------------------------------------------------------------
/packages/client/vite.config.ts:
--------------------------------------------------------------------------------
1 | import { defineConfig } from "vite";
2 | import solidPlugin from "vite-plugin-solid";
3 | import path from "path";
4 |
5 | // import devtools from 'solid-devtools/vite';
6 |
7 | export default defineConfig({
8 | plugins: [
9 | /*
10 | Uncomment the following line to enable solid-devtools.
11 | For more info see https://github.com/thetarnav/solid-devtools/tree/main/packages/extension#readme
12 | */
13 | // devtools(),
14 | solidPlugin(),
15 | ],
16 | server: {
17 | watch: {
18 | usePolling: true,
19 | },
20 | host: true,
21 | port: 3005,
22 | },
23 | build: {
24 | target: "esnext",
25 | },
26 | resolve: {
27 | alias: {
28 | "@client": path.resolve(__dirname, "./src"),
29 | },
30 | },
31 | });
32 |
--------------------------------------------------------------------------------
/packages/server/background-jobs/closeMergedMergeRequests.ts:
--------------------------------------------------------------------------------
1 | import { GitlabService } from "../services/gitlab.service";
2 | import { SocketHandler } from "../sockets";
3 | import { MochiError } from "../errors/mochi.error";
4 | import { logError, logInfo } from "../utils/logger";
5 |
6 | const closeMergedMRJob = async () => {
7 | try {
8 | logInfo("Close merged MergeRequests...");
9 |
10 | const gitlabService = new GitlabService();
11 |
12 | const changes = await gitlabService.closeMergedMergeRequestsAsync();
13 |
14 | SocketHandler.getInstance().getIO().emit("updateTasks", changes);
15 |
16 | logInfo("Closed merged MergeRequests synced!");
17 | } catch (error) {
18 | logError(
19 | new MochiError("Failed to close merge requests", 500, error as Error),
20 | );
21 | }
22 | };
23 |
24 | export { closeMergedMRJob };
25 |
--------------------------------------------------------------------------------
/packages/server/bunfig.toml:
--------------------------------------------------------------------------------
1 | [test]
2 | coverageSkipTestFiles = true
--------------------------------------------------------------------------------
/packages/server/controllers/rule.controller.ts:
--------------------------------------------------------------------------------
1 | import type { Request, Response, NextFunction } from "express";
2 | import { EventRegistry } from "../events/eventRegistry";
3 | import { RuleService } from "../services/rule.service";
4 | import { handleControllerError } from "../errors/mochi.error";
5 |
6 | export class RuleController {
7 | private ruleService: RuleService;
8 |
9 | constructor() {
10 | this.ruleService = new RuleService();
11 | }
12 |
13 | getEmittersAsync = async (
14 | req: Request,
15 | res: Response,
16 | next: NextFunction,
17 | ) => {
18 | try {
19 | res.json(EventRegistry.getInstance().getEmitters());
20 | } catch (error) {
21 | next(error);
22 | }
23 | };
24 |
25 | getListenersAsync = async (
26 | req: Request,
27 | res: Response,
28 | next: NextFunction,
29 | ) => {
30 | try {
31 | res.json(EventRegistry.getInstance().getListeners());
32 | } catch (error) {
33 | next(error);
34 | }
35 | };
36 |
37 | createRuleAsync = async (req: Request, res: Response, next: NextFunction) => {
38 | try {
39 | const newRule = await this.ruleService.createRuleAsync(req.body);
40 | res.status(201).send(newRule);
41 | } catch (error) {
42 | handleControllerError(error, next);
43 | }
44 | };
45 |
46 | getAllRulesAsync = async (
47 | req: Request,
48 | res: Response,
49 | next: NextFunction,
50 | ) => {
51 | try {
52 | const rules = await this.ruleService.getAllAsync();
53 | res.json(rules);
54 | } catch (error) {
55 | handleControllerError(error, next);
56 | }
57 | };
58 |
59 | deleteRuleAsync = async (req: Request, res: Response, next: NextFunction) => {
60 | try {
61 | await this.ruleService.deleteRuleAsync(req.params.id);
62 | res.sendStatus(204);
63 | } catch (error) {
64 | handleControllerError(error, next);
65 | }
66 | };
67 |
68 | toggleRuleAsync = async (req: Request, res: Response, next: NextFunction) => {
69 | try {
70 | const rule = await this.ruleService.toggleRuleAsync(req.params.id);
71 | res.json(rule);
72 | } catch (error) {
73 | handleControllerError(error, next);
74 | }
75 | };
76 | }
77 |
--------------------------------------------------------------------------------
/packages/server/controllers/settings.controller.ts:
--------------------------------------------------------------------------------
1 | import { handleControllerError } from "@server/errors/mochi.error";
2 | import { SettingsService } from "@server/services/settings.service";
3 | import type { NextFunction, Request, Response } from "express";
4 |
5 | export class SettingsController {
6 | private service = new SettingsService();
7 |
8 | getSetupStatus = async (req: Request, res: Response, next: NextFunction) => {
9 | try {
10 | const status = await this.service.getSetupStatus();
11 | res.status(200).json(status);
12 | } catch (error) {
13 | handleControllerError(error, next);
14 | }
15 | };
16 |
17 | getGitlabUrl = async (req: Request, res: Response, next: NextFunction) => {
18 | try {
19 | const config = await this.service.getGitlabConfig();
20 |
21 | if (!config) {
22 | res.status(404).json({
23 | error: "GitLab configuration not found",
24 | });
25 | return;
26 | }
27 |
28 | res.status(200).json(config.url);
29 | } catch (error) {
30 | handleControllerError(error, next);
31 | }
32 | };
33 |
34 | validateGitlabConnection = async (
35 | req: Request,
36 | res: Response,
37 | next: NextFunction,
38 | ) => {
39 | try {
40 | const { url, token } = req.body;
41 |
42 | if (!url || !token) {
43 | res.status(400).json({
44 | error: "URL and token are required for validation",
45 | });
46 | return;
47 | }
48 |
49 | const result = await this.service.validateGitlabConnection({
50 | url,
51 | token,
52 | });
53 |
54 | res.status(200).json(result);
55 | } catch (error) {
56 | handleControllerError(error, next);
57 | }
58 | };
59 |
60 | saveGitlabConfig = async (
61 | req: Request,
62 | res: Response,
63 | next: NextFunction,
64 | ) => {
65 | try {
66 | const { url, token } = req.body;
67 |
68 | if (!url || !token) {
69 | res.status(400).json({
70 | error: "URL and token are required to save GitLab configuration",
71 | });
72 | return;
73 | }
74 |
75 | await this.service.saveGitlabConfig({ url, token });
76 | res
77 | .status(200)
78 | .json({ message: "GitLab configuration saved successfully" });
79 | } catch (error) {
80 | handleControllerError(error, next);
81 | }
82 | };
83 |
84 | completeSetup = async (req: Request, res: Response, next: NextFunction) => {
85 | try {
86 | await this.service.completeSetup();
87 | res.status(200).json({ message: "Setup completed successfully" });
88 | } catch (error) {
89 | handleControllerError(error, next);
90 | }
91 | };
92 | }
93 |
--------------------------------------------------------------------------------
/packages/server/controllers/timeTrack.controller.ts:
--------------------------------------------------------------------------------
1 | import { handleControllerError } from "../errors/mochi.error";
2 | import TimeTrackService from "../services/timeTrack.service";
3 | import type { Request, Response, NextFunction } from "express";
4 |
5 | export class TimeTrackController {
6 | private timeTrackService: TimeTrackService;
7 |
8 | constructor() {
9 | this.timeTrackService = new TimeTrackService();
10 | }
11 |
12 | getTimeTrackEntriesAsync = async (
13 | req: Request,
14 | res: Response,
15 | next: NextFunction,
16 | ) => {
17 | try {
18 | const entries = await this.timeTrackService.getTimetrackEntriesAsync();
19 | res.status(200).json(entries);
20 | } catch (error) {
21 | handleControllerError(error, next);
22 | }
23 | };
24 |
25 | updateTimeTrackEntryAsync = async (
26 | req: Request,
27 | res: Response,
28 | next: NextFunction,
29 | ) => {
30 | try {
31 | const { id, entry } = req.body;
32 | const result = await this.timeTrackService.updateTimeTrackEntryAsync(
33 | id,
34 | entry,
35 | );
36 | res.status(200).json(result);
37 | } catch (error) {
38 | handleControllerError(error, next);
39 | }
40 | };
41 |
42 | getRecordingStateAsync = async (
43 | req: Request,
44 | res: Response,
45 | next: NextFunction,
46 | ) => {
47 | try {
48 | const recording = await this.timeTrackService.getRecordingStateAsync();
49 | res.status(200).json(recording);
50 | } catch (error) {
51 | handleControllerError(error, next);
52 | }
53 | };
54 |
55 | toggleRecordingAsync = async (
56 | req: Request,
57 | res: Response,
58 | next: NextFunction,
59 | ) => {
60 | try {
61 | const state = await this.timeTrackService.toggleRecordingAsync();
62 | res.status(200).send(state);
63 | } catch (error) {
64 | handleControllerError(error, next);
65 | }
66 | };
67 | }
68 |
--------------------------------------------------------------------------------
/packages/server/decorators/ruleAction.decorator.ts:
--------------------------------------------------------------------------------
1 | import { EventRegistry } from "../events/eventRegistry";
2 |
3 | type RuleActionDecoratorProps = {
4 | eventNamespace: string;
5 | eventName: string;
6 | hasParams?: boolean;
7 | };
8 |
9 | export function ruleAction(props: RuleActionDecoratorProps) {
10 | return function (
11 | target: any,
12 | propertyKey: string,
13 | descriptor: PropertyDescriptor
14 | ) {
15 | const { eventNamespace, eventName, hasParams } = props;
16 | const className = target.constructor.name;
17 |
18 | // Register the decorated method in the EventRegistry immediately
19 | EventRegistry.getInstance().registerListener(
20 | eventName,
21 | eventNamespace,
22 | className,
23 | propertyKey,
24 | hasParams ?? false
25 | );
26 |
27 | // No need to wrap descriptor.value; just return it as is
28 | return descriptor;
29 | };
30 | }
31 |
--------------------------------------------------------------------------------
/packages/server/decorators/ruleEvent.decorator.ts:
--------------------------------------------------------------------------------
1 | import { MochiResult } from "../utils/mochiResult";
2 | import { MochiError } from "../errors/mochi.error";
3 | import { EventEmitterHandler } from "../events/eventEmitterHandler";
4 | import { logError } from "../utils/logger";
5 | import { EventRegistry } from "../events/eventRegistry";
6 | import "reflect-metadata";
7 |
8 | export function ruleEvent(eventNamespace: string, eventName: string) {
9 | return function (
10 | target: any,
11 | propertyKey: string,
12 | descriptor: PropertyDescriptor,
13 | ) {
14 | const originalMethod = descriptor.value;
15 | const className = target.constructor.name;
16 |
17 | EventRegistry.getInstance().registerEmitter(
18 | eventName,
19 | eventNamespace,
20 | className,
21 | propertyKey,
22 | );
23 |
24 | descriptor.value = async function (...args: any[]) {
25 | try {
26 | const result = await originalMethod.apply(this, args);
27 |
28 | const eventType = `${eventNamespace}.${eventName}`;
29 |
30 | const resultData =
31 | result instanceof MochiResult
32 | ? result
33 | : new MochiResult(eventType, result);
34 |
35 | EventEmitterHandler.getEmitter().emit(eventType, resultData);
36 |
37 | return result;
38 | } catch (error: any) {
39 | logError(new MochiError(`Error in ${propertyKey}`, 500, error));
40 | throw error;
41 | }
42 | };
43 | };
44 | }
45 |
--------------------------------------------------------------------------------
/packages/server/decorators/transactional.decorator.ts:
--------------------------------------------------------------------------------
1 | import mongoose from "mongoose";
2 | import { logError } from "../utils/logger";
3 | import { MochiError } from "../errors/mochi.error";
4 |
5 | export function transactional(
6 | target: any,
7 | propertyKey: string,
8 | descriptor: PropertyDescriptor,
9 | ) {
10 | const originalMethod = descriptor.value;
11 |
12 | descriptor.value = async function (...args: any[]) {
13 | const session = await mongoose.startSession();
14 |
15 | session.startTransaction();
16 |
17 | try {
18 | const result = await originalMethod.apply(this, [...args, session]);
19 |
20 | await session.commitTransaction();
21 | session.endSession();
22 |
23 | return result;
24 | } catch (error: any) {
25 | await session.abortTransaction();
26 | session.endSession();
27 |
28 | logError(new MochiError(`Error in ${propertyKey}`, 500, error));
29 | throw error;
30 | }
31 | };
32 | }
33 |
--------------------------------------------------------------------------------
/packages/server/dtos/task.dto.ts:
--------------------------------------------------------------------------------
1 | export interface TaskDto {
2 | title: string;
3 | status: string;
4 | description: string;
5 | custom: boolean;
6 | }
7 |
--------------------------------------------------------------------------------
/packages/server/errors/gitlab.error.ts:
--------------------------------------------------------------------------------
1 | export class GitlabError extends Error {
2 | public statusCode: number;
3 | public originalError?: Error;
4 |
5 | constructor(
6 | message: string,
7 | statusCode: number = 500,
8 | originalError?: Error
9 | ) {
10 | super(message);
11 | this.name = "GitlabError";
12 | this.statusCode = statusCode;
13 | this.originalError = originalError;
14 | }
15 | }
16 |
--------------------------------------------------------------------------------
/packages/server/errors/mochi.error.ts:
--------------------------------------------------------------------------------
1 | import type { NextFunction } from "express";
2 |
3 | export class MochiError extends Error {
4 | public statusCode: number;
5 | public data: any;
6 |
7 | constructor(
8 | message: string,
9 | statusCode: number = 500,
10 | error?: Error,
11 | data?: any,
12 | ) {
13 | super(message + (error ? `: ${error.message}` : ""));
14 | this.statusCode = statusCode;
15 | this.data = data;
16 | Error.captureStackTrace(this, this.constructor);
17 | }
18 | }
19 |
20 | export const handleControllerError = (error: unknown, next: NextFunction) => {
21 | if (error instanceof MochiError) {
22 | next(error);
23 | } else {
24 | next(new MochiError("Unexpected error occurred", 500, error as Error));
25 | }
26 | };
27 |
--------------------------------------------------------------------------------
/packages/server/events/eventRegistry.ts:
--------------------------------------------------------------------------------
1 | import { logInfo } from "../utils/logger";
2 |
3 | type EventMetadata = {
4 | eventType: string;
5 | eventNamespace: string;
6 | className: string;
7 | methodName: string;
8 | hasParams?: boolean;
9 | };
10 |
11 | export class EventRegistry {
12 | private static instance: EventRegistry;
13 | private emitters: EventMetadata[] = [];
14 | private listeners: EventMetadata[] = [];
15 |
16 | private constructor() {}
17 |
18 | static getInstance() {
19 | if (!EventRegistry.instance) {
20 | EventRegistry.instance = new EventRegistry();
21 | }
22 |
23 | return EventRegistry.instance;
24 | }
25 |
26 | registerEmitter(
27 | eventType: string,
28 | eventNamespace: string,
29 | className: string,
30 | methodName: string
31 | ) {
32 | this.emitters.push({ eventType, className, methodName, eventNamespace });
33 | }
34 |
35 | registerListener(
36 | eventType: string,
37 | eventNamespace: string,
38 | className: string,
39 | methodName: string,
40 | hasParams: boolean
41 | ) {
42 | this.listeners.push({
43 | eventType,
44 | className,
45 | methodName,
46 | eventNamespace,
47 | hasParams,
48 | });
49 | }
50 |
51 | getEmitters() {
52 | return this.emitters;
53 | }
54 |
55 | getListeners() {
56 | return this.listeners;
57 | }
58 |
59 | getListenerByEvent(event: string) {
60 | const [eventNamespace, eventType] = event.split(".");
61 | return this.listeners.find(
62 | (listener) =>
63 | listener.eventNamespace == eventNamespace &&
64 | listener.eventType === eventType
65 | );
66 | }
67 | }
68 |
--------------------------------------------------------------------------------
/packages/server/events/eventTypes.ts:
--------------------------------------------------------------------------------
1 | export enum EventTypes {
2 | Created = "created",
3 | Updated = "updated",
4 | Deleted = "deleted",
5 | Moved = "moved",
6 | CreateBranch = "createBranch",
7 | }
8 |
9 | export enum ActionTypes {
10 | // Task actions
11 | Move = "move",
12 | Restore = "restore",
13 | Delete = "delete",
14 |
15 | // GitLab actions
16 | UpdateAssignee = "updateAssignee",
17 | }
18 |
19 | export enum EventNamespaces {
20 | GitLab = "gitlab",
21 | Task = "task",
22 | }
23 |
--------------------------------------------------------------------------------
/packages/server/middlewares/context.middleware.ts:
--------------------------------------------------------------------------------
1 | import type { NextFunction, Request, Response } from "express";
2 | import { ProjectService } from "../services/project.service";
3 | import {
4 | asyncLocalStorage,
5 | ContextKeys,
6 | setContext,
7 | } from "../utils/asyncContext";
8 |
9 | export const contextMiddleware = (
10 | req: Request,
11 | res: Response,
12 | next: NextFunction,
13 | ) => {
14 | asyncLocalStorage.run(new Map(), async () => {
15 | try {
16 | const projectService = new ProjectService();
17 | const currentProject = await projectService.getCurrentProjectAsync();
18 |
19 | setContext(ContextKeys.Project, currentProject);
20 | } finally {
21 | next();
22 | }
23 | });
24 | };
25 |
--------------------------------------------------------------------------------
/packages/server/middlewares/globalErrorHandler.middleware.ts:
--------------------------------------------------------------------------------
1 | import { MochiError } from "../errors/mochi.error.js";
2 | import { logError } from "../utils/logger.js";
3 | import type { Request, Response, NextFunction } from "express";
4 |
5 | export const globalErrorHandler = (
6 | err: MochiError | Error,
7 | req: Request,
8 | res: Response,
9 | next: NextFunction,
10 | ) => {
11 | if (err instanceof MochiError) {
12 | res.status(err.statusCode).json({
13 | status: "error",
14 | message: err.message,
15 | });
16 | logError(err);
17 | } else {
18 | res.status(500).json({
19 | status: "error",
20 | message: "Unexpected error occurred",
21 | });
22 | logError(new MochiError("unhandled error", 500));
23 | }
24 | };
25 |
--------------------------------------------------------------------------------
/packages/server/models/appState.model.ts:
--------------------------------------------------------------------------------
1 | import { Schema, model, Document } from "mongoose";
2 |
3 | export enum AppStateKey {
4 | Recording = "recording",
5 | }
6 |
7 | export interface IAppStateEntry extends Document {
8 | key: string;
9 | value: string;
10 | }
11 |
12 | const AppStateSchema = new Schema({
13 | key: { type: String, unique: true },
14 | value: String,
15 | });
16 |
17 | export const AppState = model("AppState", AppStateSchema);
18 |
--------------------------------------------------------------------------------
/packages/server/models/discussion.model.ts:
--------------------------------------------------------------------------------
1 | import { Schema, model } from "mongoose";
2 | import type { IDiscussion } from "shared/types/task";
3 |
4 | const DiscussionSchema = new Schema({
5 | taskId: { type: String, required: true, ref: "Task" },
6 | discussionId: { type: String, required: true },
7 | individual_note: { type: Boolean, default: false },
8 | notes: [
9 | {
10 | author: {
11 | authorId: Number,
12 | name: String,
13 | username: String,
14 | avatar_url: String,
15 | },
16 | body: { type: String, required: true },
17 | created_at: { type: String, required: true },
18 | noteId: { type: String, required: true },
19 | resolvable: { type: Boolean, default: false },
20 | resolved: { type: Boolean, default: false },
21 | resolved_at: { type: String, default: null },
22 | resolved_by: {
23 | authorId: Number,
24 | name: String,
25 | username: String,
26 | avatar_url: String,
27 | },
28 | system: { type: Boolean, default: false },
29 | },
30 | ],
31 | });
32 |
33 | export const Discussion = model("Discussion", DiscussionSchema);
34 |
--------------------------------------------------------------------------------
/packages/server/models/event.model.ts:
--------------------------------------------------------------------------------
1 | import { model, Schema } from "mongoose";
2 |
3 | export interface IEvent extends Document {
4 | eventType: string;
5 | timestamp: Date;
6 | data: Record;
7 | }
8 |
9 | const EventSchema = new Schema({
10 | eventType: { type: String, required: true },
11 | timestamp: { type: Date, default: Date.now },
12 | data: Schema.Types.Mixed,
13 | });
14 |
15 | export const Event = model("Event", EventSchema);
16 |
--------------------------------------------------------------------------------
/packages/server/models/project.model.ts:
--------------------------------------------------------------------------------
1 | import { Schema, model, Document } from "mongoose";
2 |
3 | export interface IProject extends Document {
4 | name: string;
5 | deleted: boolean;
6 | }
7 |
8 | const ProjectSchema = new Schema({
9 | name: { type: String, unique: true },
10 | deleted: { type: Boolean, default: false },
11 | });
12 |
13 | export const Project = model("Project", ProjectSchema);
14 |
--------------------------------------------------------------------------------
/packages/server/models/rule.model.ts:
--------------------------------------------------------------------------------
1 | import { model, Schema, Document } from "mongoose";
2 |
3 | // Define Condition interface and schema
4 | export interface ICondition {
5 | fieldPath: string;
6 | operator: "==" | "!=" | ">" | "<";
7 | value: any;
8 | }
9 |
10 | const ConditionSchema = new Schema({
11 | fieldPath: { type: String, required: true },
12 | operator: { type: String, enum: ["==", "!=", ">", "<"], required: true },
13 | value: { type: Schema.Types.Mixed, required: true },
14 | });
15 |
16 | // Define Action interface and schema
17 | export interface IAction {
18 | targetPath: string;
19 | value: any;
20 | }
21 |
22 | const ActionSchema = new Schema({
23 | targetPath: { type: String, required: true },
24 | value: { type: Schema.Types.Mixed, required: false },
25 | });
26 |
27 | // Define Rule interface and schema
28 | export interface IRule extends Document {
29 | name: string;
30 | eventType: string;
31 | conditions?: ICondition[];
32 | actions: IAction[];
33 | enabled: boolean;
34 | }
35 |
36 | const RuleSchema = new Schema({
37 | name: { type: String, required: true },
38 | eventType: { type: String, required: true },
39 | conditions: [ConditionSchema],
40 | actions: [ActionSchema],
41 | enabled: { type: Boolean, default: true },
42 | });
43 |
44 | // Export Rule model
45 | export const Rule = model("Rule", RuleSchema);
46 |
--------------------------------------------------------------------------------
/packages/server/models/setting.model.ts:
--------------------------------------------------------------------------------
1 | import { Schema, model, Document } from "mongoose";
2 |
3 | export interface ISetting extends Document {
4 | key: string;
5 | value: string;
6 | }
7 |
8 | const SettingSchema = new Schema({
9 | key: { type: String, unique: true },
10 | value: String,
11 | });
12 |
13 | export const Setting = model("Setting", SettingSchema);
14 |
--------------------------------------------------------------------------------
/packages/server/models/task.model.ts:
--------------------------------------------------------------------------------
1 | import { Schema, model } from "mongoose";
2 | import type { ITask } from "shared/types/task";
3 |
4 | const TaskSchema = new Schema({
5 | gitlabIid: { type: Number, unique: false },
6 | gitlabId: { type: Number, unique: true, sparse: true },
7 | web_url: String,
8 | title: { type: String, required: true },
9 | description: String,
10 | draft: Boolean,
11 | milestoneId: Number,
12 | milestoneName: String,
13 | assignee: {
14 | authorId: Number,
15 | name: String,
16 | username: String,
17 | avatar_url: String,
18 | },
19 | labels: [String],
20 | branch: String,
21 | status: String,
22 | type: String,
23 | relevantDiscussionCount: { type: Number, default: 0 },
24 | projectId: String,
25 | custom: { type: Boolean, default: false },
26 | deleted: { type: Boolean, default: false },
27 | order: Number,
28 | latestPipelineId: Number,
29 | pipelineStatus: String,
30 | pipelineReports: [
31 | {
32 | name: String,
33 | classname: String,
34 | attachment_url: String,
35 | },
36 | ],
37 | });
38 |
39 | export const Task = model("Task", TaskSchema);
40 |
--------------------------------------------------------------------------------
/packages/server/models/timeTrack.model.ts:
--------------------------------------------------------------------------------
1 | import { model, Schema, type Document } from "mongoose";
2 |
3 | export interface ITimeTrackEntry extends Document {
4 | start: Date;
5 | end: Date;
6 | break: number;
7 | }
8 |
9 | const TimeTrackSchema = new Schema({
10 | start: { type: Date, required: true },
11 | end: { type: Date, required: false },
12 | break: { type: Number, required: false },
13 | })
14 |
15 | export const TimeTrack = model("TimeTrack", TimeTrackSchema);
16 |
--------------------------------------------------------------------------------
/packages/server/models/user.model.ts:
--------------------------------------------------------------------------------
1 | import { Schema, model, Document } from "mongoose";
2 |
3 | export interface IUser extends Document {
4 | gitlabId: number;
5 | username: string;
6 | email: string;
7 | name: string;
8 | avatar_url: string;
9 | }
10 |
11 | const UserSchema = new Schema({
12 | gitlabId: { type: Number, required: true, unique: true },
13 | username: { type: String, required: true },
14 | email: { type: String, required: true },
15 | name: { type: String },
16 | avatar_url: { type: String },
17 | });
18 |
19 | export const User = model("User", UserSchema);
20 |
--------------------------------------------------------------------------------
/packages/server/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "author": {
3 | "name": "Coding0tter",
4 | "url": "https://github.com/Coding0tter"
5 | },
6 | "scripts": {
7 | "dev": "bun run --watch server.ts"
8 | },
9 | "name": "mochi-backend",
10 | "module": "index.ts",
11 | "type": "module",
12 | "devDependencies": {
13 | "@types/bun": "latest"
14 | },
15 | "peerDependencies": {
16 | "typescript": "^5.0.0"
17 | },
18 | "dependencies": {
19 | "shared": "file:../shared",
20 | "@types/cors": "^2.8.17",
21 | "@types/express": "^5.0.0",
22 | "@types/xmldom": "^0.1.34",
23 | "async_hooks": "^1.0.0",
24 | "axios": "^1.7.7",
25 | "cors": "^2.8.5",
26 | "eventemitter2": "^6.4.9",
27 | "express": "^4.21.0",
28 | "lodash": "^4.17.21",
29 | "mongoose": "^8.6.3",
30 | "reflect-metadata": "^0.2.2",
31 | "socket.io": "^4.8.0",
32 | "xmldom": "^0.6.0"
33 | }
34 | }
35 |
--------------------------------------------------------------------------------
/packages/server/repositories/appState.repo.ts:
--------------------------------------------------------------------------------
1 | import { type IAppStateEntry, AppState } from "../models/appState.model";
2 | import BaseRepo from "./base.repo";
3 |
4 | export class AppStateRepo extends BaseRepo {
5 | constructor() {
6 | super(AppState);
7 | }
8 | }
9 |
--------------------------------------------------------------------------------
/packages/server/repositories/base.repo.ts:
--------------------------------------------------------------------------------
1 | class BaseRepo {
2 | constructor(public model: any) {}
3 |
4 | public async getAllAsync(
5 | filter?: Record | null,
6 | ): Promise {
7 | if (filter) {
8 | return await this.model.find(filter);
9 | }
10 |
11 | return await this.model.find();
12 | }
13 |
14 | public async findOneAsync(filter: Record): Promise {
15 | return await this.model.findOne(filter);
16 | }
17 |
18 | public async getByIdAsync(id: string): Promise {
19 | return await this.model.findById(id);
20 | }
21 |
22 | public async createAsync(data: Partial): Promise {
23 | return await this.model.create(data);
24 | }
25 |
26 | public async updateAsync(id: string, data: Partial): Promise {
27 | return await this.model.findByIdAndUpdate(id, data, { new: true });
28 | }
29 |
30 | public async deleteAsync(id: string): Promise {
31 | return await this.model.findByIdAndDelete(id, { new: true });
32 | }
33 | }
34 |
35 | export default BaseRepo;
36 |
--------------------------------------------------------------------------------
/packages/server/repositories/discussion.repo.ts:
--------------------------------------------------------------------------------
1 | import { Discussion } from "@server/models/discussion.model";
2 | import type { IDiscussion } from "shared/types/task";
3 | import BaseRepo from "./base.repo";
4 |
5 | export class DiscussionRepo extends BaseRepo {
6 | constructor() {
7 | super(Discussion);
8 | }
9 | }
10 |
--------------------------------------------------------------------------------
/packages/server/repositories/project.repo.ts:
--------------------------------------------------------------------------------
1 | import BaseRepo from "./base.repo.js";
2 | import { Project, type IProject } from "../models/project.model.js";
3 |
4 | export class ProjectRepo extends BaseRepo {
5 | constructor() {
6 | super(Project);
7 | }
8 | }
9 |
--------------------------------------------------------------------------------
/packages/server/repositories/rule.repo.ts:
--------------------------------------------------------------------------------
1 | import { Rule, type IRule } from "../models/rule.model";
2 | import BaseRepo from "./base.repo";
3 |
4 | export class RuleRepo extends BaseRepo {
5 | constructor() {
6 | super(Rule);
7 | }
8 | }
9 |
--------------------------------------------------------------------------------
/packages/server/repositories/setting.repo.ts:
--------------------------------------------------------------------------------
1 | import BaseRepo from "./base.repo.js";
2 | import { Setting, type ISetting } from "../models/setting.model.js";
3 | import { MochiError } from "../errors/mochi.error.js";
4 |
5 | export class SettingRepo extends BaseRepo {
6 | constructor() {
7 | super(Setting);
8 | }
9 |
10 | async getByKeyAsync(key: string): Promise {
11 | try {
12 | return this.model.findOne({ key });
13 | } catch (error) {
14 | throw new MochiError(
15 | `Failed to get setting by key ${key}`,
16 | 500,
17 | error as Error,
18 | );
19 | }
20 | }
21 |
22 | async setByKeyAsync(key: string, value: string): Promise {
23 | try {
24 | const setting = await this.model.findOneAndUpdate(
25 | { key },
26 | { value },
27 | { new: true },
28 | );
29 |
30 | if (!setting) {
31 | return super.createAsync({ key, value });
32 | }
33 |
34 | return setting;
35 | } catch (error) {
36 | throw new MochiError(
37 | `Failed to set setting by key ${key}`,
38 | 500,
39 | error as Error,
40 | );
41 | }
42 | }
43 | }
44 |
--------------------------------------------------------------------------------
/packages/server/repositories/task.repo.ts:
--------------------------------------------------------------------------------
1 | import { Task } from "@server/models/task.model";
2 | import type { ITask } from "shared/types/task";
3 | import BaseRepo from "./base.repo";
4 |
5 | export class TaskRepo extends BaseRepo {
6 | constructor() {
7 | super(Task);
8 | }
9 | }
10 |
--------------------------------------------------------------------------------
/packages/server/repositories/timeTrack.repo.ts:
--------------------------------------------------------------------------------
1 | import { type ITimeTrackEntry, TimeTrack } from "../models/timeTrack.model";
2 | import BaseRepo from "./base.repo";
3 |
4 | export class TimeTrackRepo extends BaseRepo {
5 | constructor() {
6 | super(TimeTrack);
7 | }
8 | }
9 |
--------------------------------------------------------------------------------
/packages/server/repositories/user.repo.ts:
--------------------------------------------------------------------------------
1 | import BaseRepo from "./base.repo.js";
2 | import { User, type IUser } from "../models/user.model.js";
3 |
4 | export class UserRepo extends BaseRepo {
5 | constructor() {
6 | super(User);
7 | }
8 | }
9 |
--------------------------------------------------------------------------------
/packages/server/routes/gitlab.routes.ts:
--------------------------------------------------------------------------------
1 | import express from "express";
2 | import { GitlabController } from "../controllers/gitlab.controller";
3 |
4 | const router = express.Router();
5 | const gitlabController = new GitlabController();
6 |
7 | router.post("/sync", gitlabController.syncGitLabAsync);
8 | router.post("/create-merge-request", gitlabController.createMergeRequestAsync);
9 | router.post("/assign", gitlabController.assignToUserAsync);
10 | router.post("/comment", gitlabController.commentOnTaskAsync);
11 | router.post("/resolve", gitlabController.resolveThreadAsync);
12 | router.post("/toggle-draft", gitlabController.toggleDraft);
13 | router.post("/mark_as_done", gitlabController.markTodoAsDone);
14 | router.get("/user", gitlabController.getUserAsync);
15 | router.get("/users", gitlabController.getUsersAsync);
16 | router.get("/projects", gitlabController.getProjectsAsync);
17 | router.get("/todos", gitlabController.getTodosAsync);
18 |
19 | export default router;
20 |
--------------------------------------------------------------------------------
/packages/server/routes/project.router.ts:
--------------------------------------------------------------------------------
1 | import express from "express";
2 | import { ProjectController } from "../controllers/project.controller";
3 |
4 | const router = express.Router();
5 | const projectController = new ProjectController();
6 |
7 | router.get("/", projectController.getProjectsAsync);
8 | router.get("/current", projectController.getCurrentProjectAsync);
9 | router.get("/:id", projectController.getProjectByIdAsync);
10 | router.post("/", projectController.createProjectAsync);
11 | router.put("/:id", projectController.updateProjectAsync);
12 | router.delete("/:id", projectController.deleteProjectAsync);
13 | router.patch("/", projectController.setCurrentProjectAsync);
14 |
15 | export default router;
16 |
--------------------------------------------------------------------------------
/packages/server/routes/rule.router.ts:
--------------------------------------------------------------------------------
1 | import express from "express";
2 | import { RuleController } from "../controllers/rule.controller";
3 |
4 | const router = express.Router();
5 | const ruleController = new RuleController();
6 |
7 | router.get("/emitters", ruleController.getEmittersAsync);
8 | router.get("/listeners", ruleController.getListenersAsync);
9 | router.post("/", ruleController.createRuleAsync);
10 | router.get("/", ruleController.getAllRulesAsync);
11 | router.delete("/:id", ruleController.deleteRuleAsync);
12 | router.put("/toggle/:id", ruleController.toggleRuleAsync);
13 |
14 | export default router;
15 |
--------------------------------------------------------------------------------
/packages/server/routes/setting.router.ts:
--------------------------------------------------------------------------------
1 | import { SettingsController } from "@server/controllers/settings.controller";
2 | import { SettingRepo } from "@server/repositories/setting.repo";
3 | import express from "express";
4 | import { SettingKeys } from "shared";
5 |
6 | const router = express.Router();
7 | const settingsRepo = new SettingRepo();
8 | const controller = new SettingsController();
9 |
10 | router.get("/lastSync", async (req, res) => {
11 | res.send(
12 | (await settingsRepo.getByKeyAsync(SettingKeys.LAST_SYNC))?.value ?? null,
13 | );
14 | });
15 |
16 | router.get("/setup-status", controller.getSetupStatus);
17 | router.get("/gitlab-url", controller.getGitlabUrl);
18 | router.post("/validate-gitlab", controller.validateGitlabConnection);
19 | router.post("/gitlab-config", controller.saveGitlabConfig);
20 | router.post("/complete-setup", controller.completeSetup);
21 |
22 | export default router;
23 |
--------------------------------------------------------------------------------
/packages/server/routes/task.router.ts:
--------------------------------------------------------------------------------
1 | import { TaskController } from "@server/controllers/task.controller";
2 | import express from "express";
3 |
4 | const router = express.Router();
5 | const taskController = new TaskController();
6 |
7 | router.post("/", taskController.createTaskAsync);
8 | router.get("/", taskController.getTasksAsync);
9 | router.put("/order", taskController.updateTaskOrderAsync);
10 | router.get("/discussions", taskController.getDiscussions);
11 | router.put("/:id", taskController.updateTaskAsync);
12 | router.patch("/:id", taskController.restoreTaskAsync);
13 | router.delete("/:ids", taskController.deleteTaskAsync);
14 |
15 | export default router;
16 |
--------------------------------------------------------------------------------
/packages/server/routes/timeTrack.router.ts:
--------------------------------------------------------------------------------
1 | import express from "express";
2 | import { TimeTrackController } from "../controllers/timeTrack.controller";
3 |
4 | const router = express.Router();
5 | const timeTrackController = new TimeTrackController();
6 |
7 | router.get("/", timeTrackController.getTimeTrackEntriesAsync);
8 | router.get("/recording", timeTrackController.getRecordingStateAsync);
9 | router.put("/recording", timeTrackController.toggleRecordingAsync);
10 | router.put("/:id", timeTrackController.updateTimeTrackEntryAsync);
11 |
12 | export default router;
13 |
--------------------------------------------------------------------------------
/packages/server/services/actions/gitlabActionHandler.ts:
--------------------------------------------------------------------------------
1 | import { GitlabClient } from "@server/clients/gitlab.client";
2 | import { ruleAction } from "../../decorators/ruleAction.decorator";
3 | import { ActionTypes, EventNamespaces } from "../../events/eventTypes";
4 |
5 | class GitlabActionHandler {
6 | private gitlabClient = new GitlabClient();
7 |
8 | @ruleAction({
9 | eventNamespace: EventNamespaces.GitLab,
10 | eventName: ActionTypes.UpdateAssignee,
11 | hasParams: true,
12 | })
13 | async updateAssigneeAsync(data: any, eventData: any) {
14 | try {
15 | const { id } = eventData;
16 | const { projectId, gitlabIid } = data.data;
17 |
18 | await this.gitlabClient.updateAssignee(projectId, gitlabIid, id);
19 | } catch (error: any) {
20 | throw error;
21 | }
22 | }
23 | }
24 |
25 | export default GitlabActionHandler;
26 |
--------------------------------------------------------------------------------
/packages/server/services/actions/index.ts:
--------------------------------------------------------------------------------
1 | import "./taskActionHandler";
2 | import "./gitlabActionHandler";
3 |
--------------------------------------------------------------------------------
/packages/server/services/actions/taskActionHandler.ts:
--------------------------------------------------------------------------------
1 | import { ruleAction } from "@server/decorators/ruleAction.decorator";
2 | import { MochiError } from "@server/errors/mochi.error";
3 | import { EventNamespaces, ActionTypes } from "@server/events/eventTypes";
4 | import TaskService from "@server/services/task.service";
5 | import { logError } from "@server/utils/logger";
6 | import { MochiResult } from "@server/utils/mochiResult";
7 | import type { ITask } from "shared/types/task";
8 |
9 | class TaskActionHandler {
10 | private _service: TaskService;
11 |
12 | constructor() {
13 | this._service = new TaskService();
14 | }
15 |
16 | @ruleAction({
17 | eventNamespace: EventNamespaces.Task,
18 | eventName: ActionTypes.Move,
19 | hasParams: true,
20 | })
21 | async moveTaskAsync(data: MochiResult, eventData: any) {
22 | try {
23 | const { _id } = data.data as ITask;
24 | const status = eventData;
25 |
26 | await this._service.moveTaskAsync(_id as string, status);
27 | } catch (error: any) {
28 | logError(new MochiError("Error in moveTaskAsync", 500, error));
29 | throw error;
30 | }
31 | }
32 |
33 | @ruleAction({
34 | eventNamespace: EventNamespaces.Task,
35 | eventName: ActionTypes.Delete,
36 | })
37 | async deleteTaskAsync(data: MochiResult) {
38 | try {
39 | await this._service.setDeletedAsync(data.data._id as string);
40 | } catch (error: any) {
41 | logError(new MochiError("Error in deleteTaskAsync", 500, error));
42 | throw error;
43 | }
44 | }
45 |
46 | @ruleAction({
47 | eventNamespace: EventNamespaces.Task,
48 | eventName: ActionTypes.Restore,
49 | })
50 | async restoreTaskAsync(data: MochiResult) {
51 | try {
52 | await this._service.restoreTaskAsync(data.data._id as string);
53 | } catch (error: any) {
54 | logError(new MochiError("Error in restoreTaskAsync", 500, error));
55 | throw error;
56 | }
57 | }
58 | }
59 |
60 | export default TaskActionHandler;
61 |
--------------------------------------------------------------------------------
/packages/server/services/appState.service.ts:
--------------------------------------------------------------------------------
1 | import type { IAppStateEntry } from "../models/appState.model";
2 | import { AppStateRepo } from "../repositories/appState.repo";
3 |
4 | export class AppStateService {
5 | private appStateRepo: AppStateRepo;
6 |
7 | constructor() {
8 | this.appStateRepo = new AppStateRepo();
9 | }
10 |
11 | async getAppState(key: string): Promise {
12 | return this.appStateRepo.findOneAsync({ key });
13 | }
14 |
15 | async setAppState(key: string, value: string): Promise {
16 | const existing = await this.appStateRepo.findOneAsync({ key });
17 | if (existing) {
18 | return this.appStateRepo.updateAsync(existing._id as string, { value });
19 | } else {
20 | return this.appStateRepo.createAsync({ key, value });
21 | }
22 | }
23 | }
24 |
--------------------------------------------------------------------------------
/packages/server/services/base.service.ts:
--------------------------------------------------------------------------------
1 | import type BaseRepo from "../repositories/base.repo";
2 | import type { Document } from "mongoose";
3 |
4 | export abstract class BaseService {
5 | protected repository: BaseRepo;
6 | protected entityName: string;
7 |
8 | constructor(repository: BaseRepo, entityName: string) {
9 | this.repository = repository;
10 | this.entityName = entityName;
11 | }
12 |
13 | protected async createAsync(data: Partial): Promise {
14 | const entity = await this.repository.createAsync(data);
15 |
16 | return entity;
17 | }
18 |
19 | protected async updateAsync(id: string, data: Partial): Promise {
20 | const entity = await this.repository.updateAsync(id, data);
21 |
22 | return entity;
23 | }
24 |
25 | protected async deleteAsync(id: string): Promise {
26 | await this.repository.deleteAsync(id);
27 | }
28 |
29 | async getByIdAsync(id: string): Promise {
30 | return await this.repository.getByIdAsync(id);
31 | }
32 |
33 | async findOneAsync(filter: Record): Promise {
34 | return await this.repository.findOneAsync(filter);
35 | }
36 |
37 | async getAllAsync(filter?: Record | null): Promise {
38 | return await this.repository.getAllAsync(filter);
39 | }
40 | }
41 |
--------------------------------------------------------------------------------
/packages/server/services/discussion.service.ts:
--------------------------------------------------------------------------------
1 | import { DiscussionRepo } from "@server/repositories/discussion.repo";
2 | import type { IDiscussion } from "shared/types/task";
3 | import { MochiError } from "../errors/mochi.error";
4 | import { BaseService } from "./base.service";
5 |
6 | export class DiscussionService extends BaseService {
7 | constructor() {
8 | super(new DiscussionRepo(), "Discussion");
9 | }
10 |
11 | async getDiscussionsByTaskId(id: string) {
12 | try {
13 | return await super.getAllAsync({ taskId: id });
14 | } catch (error: any) {
15 | throw new MochiError("Error fetching discussions", 500, error);
16 | }
17 | }
18 |
19 | async upsertDiscussion(discussion: IDiscussion, taskId: string) {
20 | if (!discussion || !taskId) {
21 | throw new MochiError("Invalid discussion or task ID", 400);
22 | }
23 |
24 | const existing = await this.repository.findOneAsync({
25 | discussionId: discussion.discussionId,
26 | });
27 |
28 | if (existing) {
29 | if (existing.notes?.length !== discussion.notes?.length) {
30 | existing.set({
31 | notes: discussion.notes,
32 | });
33 | }
34 | } else {
35 | super.createAsync({
36 | ...discussion,
37 | taskId,
38 | });
39 | }
40 | }
41 |
42 | async deleteDiscussionAsync(id: string) {
43 | try {
44 | return super.deleteAsync(id);
45 | } catch (error: any) {
46 | throw new MochiError("Error deleting rule", 500, error);
47 | }
48 | }
49 | }
50 |
--------------------------------------------------------------------------------
/packages/server/services/emitters/index.ts:
--------------------------------------------------------------------------------
1 | import "./taskEventEmitter";
2 |
--------------------------------------------------------------------------------
/packages/server/services/emitters/taskEventEmitter.ts:
--------------------------------------------------------------------------------
1 | import { ruleEvent } from "@server/decorators/ruleEvent.decorator";
2 | import { MochiError } from "@server/errors/mochi.error";
3 | import { EventNamespaces, EventTypes } from "@server/events/eventTypes";
4 | import TaskService from "@server/services/task.service";
5 | import { MochiResult } from "@server/utils/mochiResult";
6 | import type { ITask } from "shared/types/task";
7 |
8 | class TaskEventEmitter extends TaskService {
9 | constructor() {
10 | super();
11 | }
12 |
13 | @ruleEvent(EventNamespaces.Task, EventTypes.Created)
14 | async createTaskAsync(projectId: string, task: Partial) {
15 | try {
16 | if (!projectId) {
17 | throw new MochiError("No project selected", 404);
18 | }
19 |
20 | task.projectId = projectId;
21 |
22 | const createdTask = await super.createAsync(task as ITask);
23 | return new MochiResult(createdTask);
24 | } catch (error: any) {
25 | return new MochiResult(null, error);
26 | }
27 | }
28 |
29 | @ruleEvent(EventNamespaces.Task, EventTypes.Updated)
30 | async updateTaskAsync(id: string, task: Partial) {
31 | try {
32 | const updatedTask = await super.updateAsync(id, task);
33 |
34 | if (!updatedTask) {
35 | throw new MochiError("Task not found", 404);
36 | }
37 |
38 | return new MochiResult(updatedTask);
39 | } catch (error: any) {
40 | return new MochiResult(null, error);
41 | }
42 | }
43 |
44 | @ruleEvent(EventNamespaces.Task, EventTypes.Deleted)
45 | async deleteTaskAsync(id: string) {
46 | try {
47 | const deletedTask = await super.setDeletedAsync(id);
48 | if (!deletedTask) {
49 | throw new MochiError("Task not found", 404);
50 | }
51 |
52 | return new MochiResult(deletedTask);
53 | } catch (error: any) {
54 | return new MochiResult(null, error);
55 | }
56 | }
57 | }
58 |
59 | export default TaskEventEmitter;
60 |
--------------------------------------------------------------------------------
/packages/server/services/rule.service.ts:
--------------------------------------------------------------------------------
1 | import { MochiError } from "../errors/mochi.error";
2 | import type { IRule } from "../models/rule.model";
3 | import { RuleRepo } from "../repositories/rule.repo";
4 | import { BaseService } from "./base.service";
5 |
6 | export class RuleService extends BaseService {
7 | constructor() {
8 | super(new RuleRepo(), "Rule");
9 | }
10 |
11 | async createRuleAsync(rule: IRule) {
12 | try {
13 | return super.createAsync(rule);
14 | } catch (error: any) {
15 | throw new MochiError("Error creating rule", 500, error);
16 | }
17 | }
18 |
19 | async deleteRuleAsync(id: string) {
20 | try {
21 | return super.deleteAsync(id);
22 | } catch (error: any) {
23 | throw new MochiError("Error deleting rule", 500, error);
24 | }
25 | }
26 |
27 | async toggleRuleAsync(id: string) {
28 | try {
29 | const rule = await super.getByIdAsync(id);
30 |
31 | if (!rule) {
32 | throw new Error("Rule not found");
33 | }
34 |
35 | rule.enabled = !rule.enabled;
36 |
37 | return super.updateAsync(id, rule);
38 | } catch (error: any) {
39 | throw new MochiError("Error toggling rule", 500, error);
40 | }
41 | }
42 | }
43 |
--------------------------------------------------------------------------------
/packages/server/services/task.service.ts:
--------------------------------------------------------------------------------
1 | import { MochiError } from "@server/errors/mochi.error";
2 | import { TaskRepo } from "@server/repositories/task.repo";
3 | import { BaseService } from "@server/services/base.service";
4 | import { MochiResult } from "@server/utils/mochiResult";
5 | import type { ITask } from "shared/types/task";
6 |
7 | class TaskService extends BaseService {
8 | constructor() {
9 | super(new TaskRepo(), "Task");
10 | }
11 |
12 | async getTasksAsync(projectId: string, showDeleted: boolean) {
13 | if (!projectId) {
14 | throw new MochiError("No project selected", 404);
15 | }
16 |
17 | const tasks = await super.getAllAsync({
18 | projectId,
19 | });
20 |
21 | return showDeleted ? tasks : tasks.filter((task) => !task.deleted);
22 | }
23 |
24 | async moveTaskAsync(taskId: string, status: string) {
25 | const updatedTask = await super.updateAsync(taskId, { status });
26 | if (!updatedTask) {
27 | throw new MochiError("Task not found", 404);
28 | }
29 | return updatedTask;
30 | }
31 |
32 | async updateTaskOrderAsync(tasks: string[]) {
33 | const updatePromises = tasks.map((taskId, index) => {
34 | return super.updateAsync(taskId, { order: index });
35 | });
36 |
37 | await Promise.all(updatePromises);
38 | }
39 |
40 | async updateTaskAsync(id: string, task: Partial) {
41 | const updatedTask = await super.updateAsync(id, task);
42 |
43 | if (!updatedTask) {
44 | throw new MochiError("Task not found", 404);
45 | }
46 |
47 | return new MochiResult(updatedTask);
48 | }
49 |
50 | async restoreTaskAsync(id: string) {
51 | const restoredTask = await super.updateAsync(id, {
52 | deleted: false,
53 | });
54 | if (!restoredTask) {
55 | throw new MochiError("Task not found", 404);
56 | }
57 | return restoredTask;
58 | }
59 |
60 | async setDeletedAsync(id: string) {
61 | const deletedTask = await super.updateAsync(id, {
62 | deleted: true,
63 | });
64 | if (!deletedTask) {
65 | throw new MochiError("Task not found", 404);
66 | }
67 | return deletedTask;
68 | }
69 | }
70 |
71 | export { TaskService };
72 | export default TaskService;
73 |
--------------------------------------------------------------------------------
/packages/server/services/timeTrack.service.ts:
--------------------------------------------------------------------------------
1 | import { AppStateKey } from "../models/appState.model";
2 | import type { ITimeTrackEntry } from "../models/timeTrack.model";
3 | import { TimeTrackRepo } from "../repositories/timeTrack.repo";
4 | import { MochiResult } from "../utils/mochiResult";
5 | import { AppStateService } from "./appState.service";
6 | import { BaseService } from "./base.service";
7 |
8 | class TimeTrackService extends BaseService {
9 | private appStateService: AppStateService;
10 |
11 | constructor() {
12 | super(new TimeTrackRepo(), "TimeTrack");
13 |
14 | this.appStateService = new AppStateService();
15 | }
16 |
17 | public async updateTimeTrackEntryAsync(id: string, entry: ITimeTrackEntry) {
18 | try {
19 | return await super.updateAsync(id, entry);
20 | } catch (error: any) {
21 | return new MochiResult(null, error);
22 | }
23 | }
24 |
25 | public async toggleRecordingAsync() {
26 | const isRecording = await this.appStateService.getAppState(
27 | AppStateKey.Recording,
28 | );
29 |
30 | if (isRecording?.value === undefined || isRecording.value === "false") {
31 | await this.appStateService.setAppState(AppStateKey.Recording, "true");
32 |
33 | await this.createAsync({
34 | start: new Date(),
35 | });
36 |
37 | return true;
38 | } else {
39 | await this.appStateService.setAppState(AppStateKey.Recording, "false");
40 |
41 | const currentEntry = await this.repository.findOneAsync({
42 | end: undefined,
43 | });
44 |
45 | if (currentEntry) {
46 | await this.updateAsync(currentEntry._id as string, {
47 | end: new Date(),
48 | });
49 | }
50 |
51 | return false;
52 | }
53 | }
54 |
55 | public async getRecordingStateAsync(): Promise {
56 | const isRecording = await this.appStateService.getAppState(
57 | AppStateKey.Recording,
58 | );
59 | return isRecording?.value === "true";
60 | }
61 |
62 | public async getTimetrackEntriesAsync(): Promise {
63 | const startOfWeek = new Date();
64 | startOfWeek.setHours(0, 0, 0, 0);
65 | startOfWeek.setDate(startOfWeek.getDate() - startOfWeek.getDay());
66 |
67 | const endOfWeek = new Date(startOfWeek);
68 | endOfWeek.setDate(endOfWeek.getDate() + 7);
69 |
70 | return this.repository.getAllAsync({
71 | start: { $gte: startOfWeek, $lt: endOfWeek },
72 | });
73 | }
74 | }
75 |
76 | export default TimeTrackService;
77 |
--------------------------------------------------------------------------------
/packages/server/services/user.service.ts:
--------------------------------------------------------------------------------
1 | // services/UserService.ts
2 | import { UserRepo } from "../repositories/user.repo";
3 | import type { IUser } from "../models/user.model";
4 |
5 | export class UserService {
6 | private userRepo: UserRepo;
7 |
8 | constructor() {
9 | this.userRepo = new UserRepo();
10 | }
11 |
12 | async getUser(): Promise {
13 | return this.userRepo.findOneAsync({});
14 | }
15 |
16 | async createUser(userData: Partial): Promise {
17 | return this.userRepo.createAsync(userData);
18 | }
19 | }
20 |
--------------------------------------------------------------------------------
/packages/server/sockets/index.ts:
--------------------------------------------------------------------------------
1 | import { Server, Socket } from "socket.io";
2 | import { logInfo } from "../utils/logger";
3 |
4 | export class SocketHandler {
5 | private static instance: SocketHandler | null = null;
6 | private io: Server | null = null;
7 |
8 | private constructor() {} // Private constructor to prevent direct instantiation
9 |
10 | // Method to get the singleton instance
11 | public static getInstance(): SocketHandler {
12 | if (!SocketHandler.instance) {
13 | SocketHandler.instance = new SocketHandler();
14 | }
15 | return SocketHandler.instance;
16 | }
17 |
18 | // Init function to start the WebSocket connection
19 | public init(server: any) {
20 | if (this.io) {
21 | console.log("Socket.IO is already initialized.");
22 | return;
23 | }
24 |
25 | this.io = new Server(server, {
26 | cors: {
27 | origin: "*", // You can specify allowed origins here
28 | },
29 | });
30 |
31 | this.io.on("connection", (socket: Socket) => {
32 | console.log("New connection: " + socket.id);
33 | logInfo("New connection: " + socket.id);
34 |
35 | // Listen for ping events and respond with pong
36 | socket.on("ping", () => {
37 | socket.emit("pong");
38 | });
39 |
40 | socket.on("disconnect", () => {
41 | logInfo("Disconnected: " + socket.id);
42 | });
43 | });
44 | }
45 |
46 | // Method to get the io instance
47 | public getIO(): Server {
48 | if (!this.io) {
49 | throw new Error("Socket.IO not initialized");
50 | }
51 | return this.io;
52 | }
53 | }
54 |
55 | export default SocketHandler;
56 |
--------------------------------------------------------------------------------
/packages/server/syncs/gitlab/noteCount.processor.ts:
--------------------------------------------------------------------------------
1 | import { GitlabClient } from "@server/clients/gitlab.client";
2 | import type { FieldProcessor } from "shared/types";
3 | import type { ITask } from "shared/types/task";
4 |
5 | export default class NoteCountProcessor implements FieldProcessor {
6 | fieldName = "noteCount";
7 | private client = new GitlabClient();
8 |
9 | async process(entity: any, task: ITask, projectId: string): Promise {
10 | const user = await this.client.getUserByAccessToken();
11 | const notes = await this.client.request({
12 | endpoint: `/projects/${projectId}/${task.type}s/${task.gitlabIid}/notes`,
13 | method: "GET",
14 | });
15 |
16 | task.relevantDiscussionCount = notes.filter(
17 | (item: any) =>
18 | !item.resolved && !item.system && item.body.includes(user.username),
19 | ).length;
20 | }
21 |
22 | shouldProcess(entity: any): boolean {
23 | return entity.type === "merge_request";
24 | }
25 | }
26 |
--------------------------------------------------------------------------------
/packages/server/syncs/gitlab/pipeline.processor.ts:
--------------------------------------------------------------------------------
1 | import { GitlabClient } from "@server/clients/gitlab.client";
2 | import type { FieldProcessor } from "shared/types";
3 | import type { ITask } from "shared/types/task";
4 |
5 | export default class PipelineProcessor implements FieldProcessor {
6 | fieldName = "pipeline";
7 | private client = new GitlabClient();
8 |
9 | async process(
10 | entity: any,
11 | task: Partial,
12 | projectId: string,
13 | ): Promise {
14 | const pipelineState = await this.client.getPipelineState(
15 | projectId,
16 | task.gitlabIid!,
17 | );
18 |
19 | task.pipelineStatus = pipelineState.pipelineStatus;
20 | task.latestPipelineId = pipelineState.latestPipelineId;
21 | task.pipelineReports = pipelineState.pipelineReports;
22 | }
23 |
24 | shouldProcess(entity: any): boolean {
25 | return entity.type === "merge_request";
26 | }
27 | }
28 |
--------------------------------------------------------------------------------
/packages/server/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "../../tsconfig.json",
3 | "compilerOptions": {
4 | "allowSyntheticDefaultImports": true,
5 | "esModuleInterop": true,
6 | "jsx": "preserve",
7 | "types": ["bun-types"],
8 | "noEmit": true,
9 | "isolatedModules": true,
10 |
11 | "baseUrl": "./",
12 | "paths": {
13 | "@server/*": ["*"]
14 | }
15 | }
16 | }
17 |
--------------------------------------------------------------------------------
/packages/server/utils/asyncContext.ts:
--------------------------------------------------------------------------------
1 | import { AsyncLocalStorage } from "async_hooks";
2 |
3 | export enum ContextKeys {
4 | Project = "currentProject",
5 | }
6 |
7 | export const asyncLocalStorage = new AsyncLocalStorage