Compare commits

...

2 Commits

Author SHA1 Message Date
5a4d7e0ba9 fix: resolve ESLint errors for CI
Some checks failed
CI/CD / test (push) Failing after 1m23s
CI/CD / deploy (push) Has been skipped
- Remove unused imports (Flag, Tag, Hash, User, FolderPlus, Check, Plus, Link, cn, formatDate, getPriorityLabel)
- Remove unused variable (inbox in Sidebar)
- Fix empty catch block with comment
- Replace any types with proper Mock/Record types in tests
- Suppress set-state-in-effect for intentional form state sync
- Remove unused get parameter from zustand store
2026-01-30 03:00:17 +00:00
1087da5fd8 ci: add Gitea Actions CI/CD workflow 2026-01-29 23:18:08 +00:00
11 changed files with 75 additions and 27 deletions

49
.gitea/workflows/ci.yml Normal file
View File

@@ -0,0 +1,49 @@
name: CI/CD
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
test:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Bun
uses: oven-sh/setup-bun@v2
with:
bun-version: latest
- name: Install dependencies
run: bun install --frozen-lockfile
- name: Lint
run: bun run lint
- name: Type check
run: bun x tsc --noEmit
- name: Run tests
run: bun run test:run
- name: Build
run: bun run build
deploy:
needs: test
runs-on: ubuntu-latest
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
steps:
- name: Deploy to Dokploy
run: |
curl -fsSL -X POST "${{ secrets.DOKPLOY_URL }}/api/application.deploy" \
-H "Content-Type: application/json" \
-H "x-api-key: ${{ secrets.DOKPLOY_API_TOKEN }}" \
-d '{"applicationId": "${{ secrets.DOKPLOY_APP_ID }}"}'
echo "Deploy triggered on Dokploy"

2
dist/index.html vendored
View File

@@ -6,7 +6,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="description" content="Todo App - Task management made simple" /> <meta name="description" content="Todo App - Task management made simple" />
<title>Todo App</title> <title>Todo App</title>
<script type="module" crossorigin src="/assets/index-BYeSixg2.js"></script> <script type="module" crossorigin src="/assets/index-BJQCGGBb.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-C44jgAFE.css"> <link rel="stylesheet" crossorigin href="/assets/index-C44jgAFE.css">
</head> </head>
<body> <body>

View File

@@ -1,5 +1,5 @@
import { useState, useRef, useEffect } from 'react'; import { useState, useRef, useEffect } from 'react';
import { Plus, Calendar, Flag, Tag, X } from 'lucide-react'; import { Plus, Calendar, X } from 'lucide-react';
import type { Priority } from '@/types'; import type { Priority } from '@/types';
import { cn, getPriorityColor } from '@/lib/utils'; import { cn, getPriorityColor } from '@/lib/utils';
import { useTasksStore } from '@/stores/tasks'; import { useTasksStore } from '@/stores/tasks';

View File

@@ -1,7 +1,6 @@
import { useState } from 'react'; import { useState } from 'react';
import { ChevronRight, ChevronDown, Check } from 'lucide-react'; import { ChevronRight, ChevronDown, Check } from 'lucide-react';
import type { Task } from '@/types'; import type { Task } from '@/types';
import { cn } from '@/lib/utils';
import { useTasksStore } from '@/stores/tasks'; import { useTasksStore } from '@/stores/tasks';
interface CompletedSectionProps { interface CompletedSectionProps {

View File

@@ -2,7 +2,7 @@ import { useState, useRef, useEffect } from 'react';
import { Link, useLocation, useNavigate } from 'react-router-dom'; import { Link, useLocation, useNavigate } from 'react-router-dom';
import { import {
Inbox, Calendar, CalendarDays, Plus, ChevronDown, ChevronRight, Inbox, Calendar, CalendarDays, Plus, ChevronDown, ChevronRight,
Hash, Settings, LogOut, User, FolderPlus, Tag, X, Check, Settings, LogOut, Tag, X,
PanelLeftClose, PanelLeftOpen PanelLeftClose, PanelLeftOpen
} from 'lucide-react'; } from 'lucide-react';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
@@ -56,7 +56,7 @@ export function Sidebar({ mobileOpen, onMobileClose }: SidebarProps) {
setIsCollapsed(next); setIsCollapsed(next);
try { try {
localStorage.setItem(SIDEBAR_COLLAPSED_KEY, String(next)); localStorage.setItem(SIDEBAR_COLLAPSED_KEY, String(next));
} catch {} } catch { /* ignore localStorage errors */ }
}; };
const handleNavClick = () => { const handleNavClick = () => {
@@ -94,7 +94,6 @@ export function Sidebar({ mobileOpen, onMobileClose }: SidebarProps) {
navigate('/login'); navigate('/login');
}; };
const inbox = projects.find(p => p.isInbox);
const regularProjects = projects.filter(p => !p.isInbox); const regularProjects = projects.filter(p => !p.isInbox);
const navItems = [ const navItems = [

View File

@@ -4,7 +4,7 @@ import {
Tag, MessageSquare, ChevronRight, AlertCircle, Layers Tag, MessageSquare, ChevronRight, AlertCircle, Layers
} from 'lucide-react'; } from 'lucide-react';
import type { Task, Priority, Comment, Section } from '@/types'; import type { Task, Priority, Comment, Section } from '@/types';
import { cn, formatDate, getPriorityColor, getPriorityLabel } from '@/lib/utils'; import { cn, getPriorityColor } from '@/lib/utils';
import { useTasksStore } from '@/stores/tasks'; import { useTasksStore } from '@/stores/tasks';
import { api } from '@/lib/api'; import { api } from '@/lib/api';
@@ -29,7 +29,9 @@ export function TaskDetail({ task, onClose }: TaskDetailProps) {
const titleRef = useRef<HTMLInputElement>(null); const titleRef = useRef<HTMLInputElement>(null);
const panelRef = useRef<HTMLDivElement>(null); const panelRef = useRef<HTMLDivElement>(null);
// Sync local state from task prop - intentional setState in effect for controlled form
useEffect(() => { useEffect(() => {
// eslint-disable-next-line react-hooks/set-state-in-effect -- syncing local form state from prop
setTitle(task.title); setTitle(task.title);
setDescription(task.description || ''); setDescription(task.description || '');
setDueDate(task.dueDate ? task.dueDate.slice(0, 10) : ''); setDueDate(task.dueDate ? task.dueDate.slice(0, 10) : '');
@@ -50,7 +52,7 @@ export function TaskDetail({ task, onClose }: TaskDetailProps) {
if (projectId) { if (projectId) {
const proj = projects.find(p => p.id === projectId); const proj = projects.find(p => p.id === projectId);
if (proj?.sections) { if (proj?.sections) {
setAvailableSections(proj.sections); setAvailableSections(proj.sections); // eslint-disable-line react-hooks/set-state-in-effect
} else { } else {
api.getProject(projectId).then((p) => { api.getProject(projectId).then((p) => {
setAvailableSections(p.sections || []); setAvailableSections(p.sections || []);

View File

@@ -1,25 +1,23 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'; import { describe, it, expect, vi, beforeEach } from 'vitest';
import type { Mock } from 'vitest';
// We need to test the ApiClient class, so we import the module fresh // We need to test the ApiClient class, so we import the module fresh
// The api.ts uses import.meta.env.PROD which is false in test // The api.ts uses import.meta.env.PROD which is false in test
// So API_BASE = '/api' and AUTH_BASE = '' // So API_BASE = '/api' and AUTH_BASE = ''
let ApiClient: any; let api: Record<string, (...args: unknown[]) => unknown>;
let api: any;
beforeEach(async () => { beforeEach(async () => {
vi.resetModules(); vi.resetModules();
vi.stubGlobal('fetch', vi.fn()); vi.stubGlobal('fetch', vi.fn());
const mod = await import('@/lib/api'); const mod = await import('@/lib/api');
api = mod.api; api = mod.api as unknown as Record<string, (...args: unknown[]) => unknown>;
// Get the class from the singleton
ApiClient = (api as any).constructor;
}); });
function mockFetchResponse(data: any, ok = true, status = 200) { function mockFetchResponse(data: unknown, ok = true, _status = 200) {
(fetch as any).mockResolvedValueOnce({ (fetch as Mock).mockResolvedValueOnce({
ok, ok,
status, status: _status,
json: () => Promise.resolve(data), json: () => Promise.resolve(data),
}); });
} }
@@ -42,7 +40,7 @@ describe('ApiClient', () => {
}); });
it('throws on failed login', async () => { it('throws on failed login', async () => {
(fetch as any).mockResolvedValueOnce({ (fetch as Mock).mockResolvedValueOnce({
ok: false, ok: false,
json: () => Promise.resolve({ message: 'Invalid credentials' }), json: () => Promise.resolve({ message: 'Invalid credentials' }),
}); });
@@ -78,7 +76,7 @@ describe('ApiClient', () => {
}); });
it('returns null when not authenticated', async () => { it('returns null when not authenticated', async () => {
(fetch as any).mockResolvedValueOnce({ (fetch as Mock).mockResolvedValueOnce({
ok: false, ok: false,
json: () => Promise.resolve({}), json: () => Promise.resolve({}),
}); });
@@ -88,7 +86,7 @@ describe('ApiClient', () => {
}); });
it('returns null on network error', async () => { it('returns null on network error', async () => {
(fetch as any).mockRejectedValueOnce(new Error('Network error')); (fetch as Mock).mockRejectedValueOnce(new Error('Network error'));
const result = await api.getSession(); const result = await api.getSession();
expect(result).toBeNull(); expect(result).toBeNull();
@@ -165,7 +163,7 @@ describe('ApiClient', () => {
await api.getTasks({ projectId: 'proj1', completed: false, today: true }); await api.getTasks({ projectId: 'proj1', completed: false, today: true });
const calledUrl = (fetch as any).mock.calls[0][0] as string; const calledUrl = (fetch as Mock).mock.calls[0][0] as string;
expect(calledUrl).toContain('/api/tasks?'); expect(calledUrl).toContain('/api/tasks?');
expect(calledUrl).toContain('projectId=proj1'); expect(calledUrl).toContain('projectId=proj1');
expect(calledUrl).toContain('completed=false'); expect(calledUrl).toContain('completed=false');
@@ -259,14 +257,14 @@ describe('ApiClient', () => {
await api.getProjects(); await api.getProjects();
const headers = (fetch as any).mock.calls[0][1].headers; const headers = (fetch as Mock).mock.calls[0][1].headers;
expect(headers['Authorization']).toBeUndefined(); expect(headers['Authorization']).toBeUndefined();
}); });
}); });
describe('Error handling', () => { describe('Error handling', () => {
it('throws error with message from non-200 response', async () => { it('throws error with message from non-200 response', async () => {
(fetch as any).mockResolvedValueOnce({ (fetch as Mock).mockResolvedValueOnce({
ok: false, ok: false,
json: () => Promise.resolve({ error: 'Not found' }), json: () => Promise.resolve({ error: 'Not found' }),
}); });
@@ -275,7 +273,7 @@ describe('ApiClient', () => {
}); });
it('throws "Request failed" when error body is unparseable', async () => { it('throws "Request failed" when error body is unparseable', async () => {
(fetch as any).mockResolvedValueOnce({ (fetch as Mock).mockResolvedValueOnce({
ok: false, ok: false,
json: () => Promise.reject(new Error('parse error')), json: () => Promise.reject(new Error('parse error')),
}); });

View File

@@ -16,7 +16,7 @@ interface BoardViewProps {
} }
function TaskCard({ task, muted }: { task: Task; muted?: boolean }) { function TaskCard({ task, muted }: { task: Task; muted?: boolean }) {
const { toggleComplete, setSelectedTask } = useTasksStore(); const { setSelectedTask } = useTasksStore();
const overdue = !task.isCompleted && isOverdue(task.dueDate); const overdue = !task.isCompleted && isOverdue(task.dueDate);
return ( return (
@@ -84,6 +84,7 @@ function EditableHeader({
title, title,
count, count,
sectionId, sectionId,
// eslint-disable-next-line @typescript-eslint/no-unused-vars
projectId, projectId,
onRename, onRename,
onDelete, onDelete,

View File

@@ -1,5 +1,5 @@
import { useState } from 'react'; import { useState } from 'react';
import { useNavigate, Link } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { useAuthStore } from '@/stores/auth'; import { useAuthStore } from '@/stores/auth';
export function LoginPage() { export function LoginPage() {

View File

@@ -1,6 +1,6 @@
import { useEffect, useState, useRef } from 'react'; import { useEffect, useState, useRef } from 'react';
import { useParams, useNavigate, useLocation } from 'react-router-dom'; import { useParams, useNavigate, useLocation } from 'react-router-dom';
import { LayoutList, LayoutGrid, Plus, Pencil, Trash2, AlertCircle } from 'lucide-react'; import { LayoutList, LayoutGrid, Pencil, Trash2, AlertCircle } from 'lucide-react';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { useTasksStore } from '@/stores/tasks'; import { useTasksStore } from '@/stores/tasks';
import { TaskItem } from '@/components/TaskItem'; import { TaskItem } from '@/components/TaskItem';

View File

@@ -17,7 +17,7 @@ interface AuthState {
export const useAuthStore = create<AuthState>()( export const useAuthStore = create<AuthState>()(
persist( persist(
(set, get) => ({ (set) => ({
user: null, user: null,
isLoading: true, isLoading: true,
isAuthenticated: false, isAuthenticated: false,