Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion ui/.gitignore
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
node_modules/
node_modules/
dist/
12 changes: 12 additions & 0 deletions ui/.prettierignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
# Build artifacts
dist/
build/

# Dependencies
node_modules/

# Coverage
coverage/

# Generated files
*.log
18 changes: 17 additions & 1 deletion ui/src/components/CurrentTaskFooter.tsx
Original file line number Diff line number Diff line change
@@ -1,19 +1,21 @@
import { useEffect, useState } from "react";
import { Task, Category } from "@/types/task";
import { Square } from "lucide-react";
import { Square, Pause } from "lucide-react";
import { Button } from "@/components/ui/button";
import { formatTimeAsHHmm } from "@/lib/utils";

interface CurrentTaskFooterProps {
currentTask: Task | null;
categories: Category[];
onTaskTimer: (taskId: string, action: "start" | "stop" | "complete") => void;
onPauseTask: (task: Task) => void;
}

export const CurrentTaskFooter = ({
currentTask,
categories,
onTaskTimer,
onPauseTask,
}: CurrentTaskFooterProps) => {
const [currentTime, setCurrentTime] = useState(new Date());
const [elapsedTime, setElapsedTime] = useState(0);
Expand Down Expand Up @@ -93,6 +95,20 @@ export const CurrentTaskFooter = ({
<p className="text-xs text-gray-500">経過時間</p>
</div>

<Button
size="sm"
variant="outline"
onClick={() => onPauseTask(currentTask)}
className="hover:bg-orange-50"
style={{
color: "#ea580c",
borderColor: "#ea580c",
}}
>
<Pause className="h-4 w-4 mr-1" />
中断
</Button>

<Button
size="sm"
variant="outline"
Expand Down
17 changes: 16 additions & 1 deletion ui/src/components/task/SortableTask.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { useSortable } from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import { Button } from "@/components/ui/button";
import { GripVertical, Trash2, CalendarCheck } from "lucide-react";
import { GripVertical, Trash2, CalendarCheck, Pause } from "lucide-react";
import React from "react";
import { TaskTimerButton } from "./TaskTimerButton";
import { TaskTitleField } from "./TaskTitleField";
Expand Down Expand Up @@ -106,6 +106,9 @@ const SortableTask = ({ task }: SortableTaskProps) => {
// タスクが完了しているかどうか
const isCompleted = !!task.end_time;

// タスクが実行中かどうか
const isRunning = !!task.start_time && !task.end_time;

return (
<div
ref={setNodeRef}
Expand Down Expand Up @@ -154,6 +157,18 @@ const SortableTask = ({ task }: SortableTaskProps) => {
</div>

<div className="flex gap-2 items-center">
{/* 中断ボタン:実行中のタスクのみ表示 */}
{isRunning && (
<Button
size="icon"
variant="outline"
onClick={() => taskActions.handlePauseTask(task)}
className="h-8 w-8 text-orange-500 hover:text-orange-700 hover:bg-orange-50"
title="中断"
>
<Pause className="h-4 w-4" />
</Button>
)}
{/* 今日に移動ボタン:完了していない&今日以外のタスクに表示 */}
{!isCompleted && !isToday && (
<Button
Expand Down
1 change: 1 addition & 0 deletions ui/src/contexts/TaskContext.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ export interface TaskActions {
) => void;
handleRepeatTask: (task: Task) => void;
handleMoveToToday: (taskId: string) => void;
handlePauseTask: (task: Task) => void;
}

// タスクコンテキストの型定義
Expand Down
145 changes: 145 additions & 0 deletions ui/src/hooks/useTaskActions.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
import { describe, it, expect } from "vitest";
import { taskReducer } from "@/reducers/taskReducer";
import { Task } from "@/types/task";

describe("Task Pause Functionality", () => {
const mockRunningTask: Task = {
id: "1",
user_id: "user1",
title: "タスク1",
description: "説明",
estimated_minute: 30,
task_order: 1,
start_time: "2024-01-01T10:00:00Z",
end_time: null,
category_id: "cat1",
created_at: "2024-01-01T00:00:00Z",
task_date: "2024-01-01",
};

const mockTask2: Task = {
id: "2",
user_id: "user1",
title: "タスク2",
description: "",
estimated_minute: 20,
task_order: 2,
start_time: null,
end_time: null,
category_id: null,
created_at: "2024-01-01T00:00:00Z",
task_date: "2024-01-01",
};

describe("pause task behavior (completing task and creating new one)", () => {
it("should complete a running task by setting end_time", () => {
const initialState = [mockRunningTask, mockTask2];

// タスクを完了させる(end_timeを設定)
const action = {
type: "UPDATE_TASK" as const,
payload: {
id: "1",
end_time: "2024-01-01T11:00:00Z",
},
};

const newState = taskReducer(initialState, action);

// タスクが完了していることを確認
const completedTask = newState.find((t) => t.id === "1");
expect(completedTask?.end_time).toBe("2024-01-01T11:00:00Z");
});

it("should add a new task with same attributes after pausing", () => {
const initialState = [mockTask2];

// 同じ属性で新しいタスクを追加
const newTask: Task = {
id: "3",
user_id: mockRunningTask.user_id,
title: mockRunningTask.title,
description: mockRunningTask.description,
estimated_minute: mockRunningTask.estimated_minute,
task_order: null,
start_time: null,
end_time: null,
category_id: mockRunningTask.category_id,
created_at: "2024-01-01T11:00:00Z",
task_date: mockRunningTask.task_date,
};

const action = {
type: "ADD_TASK" as const,
payload: newTask,
};

const newState = taskReducer(initialState, action);

// 新しいタスクが追加されたことを確認
expect(newState).toHaveLength(2);
const addedTask = newState.find((t) => t.id === "3");
expect(addedTask).toBeDefined();
expect(addedTask?.title).toBe(mockRunningTask.title);
expect(addedTask?.estimated_minute).toBe(
mockRunningTask.estimated_minute,
);
expect(addedTask?.category_id).toBe(mockRunningTask.category_id);
expect(addedTask?.start_time).toBeNull();
expect(addedTask?.end_time).toBeNull();
});

it("should complete original task and add new task in sequence (simulating pause)", () => {
const initialState = [mockRunningTask, mockTask2];

// ステップ1: 実行中のタスクを完了
const completeAction = {
type: "UPDATE_TASK" as const,
payload: {
id: "1",
end_time: "2024-01-01T11:00:00Z",
},
};

const stateAfterComplete = taskReducer(initialState, completeAction);
const completedTask = stateAfterComplete.find((t) => t.id === "1");
expect(completedTask?.end_time).toBe("2024-01-01T11:00:00Z");

// ステップ2: 同じ属性で新しいタスクを追加
const newTask: Task = {
id: "3",
user_id: mockRunningTask.user_id,
title: mockRunningTask.title,
description: mockRunningTask.description,
estimated_minute: mockRunningTask.estimated_minute,
task_order: null,
start_time: null,
end_time: null,
category_id: mockRunningTask.category_id,
created_at: "2024-01-01T11:00:00Z",
task_date: mockRunningTask.task_date,
};

const addAction = {
type: "ADD_TASK" as const,
payload: newTask,
};

const finalState = taskReducer(stateAfterComplete, addAction);

// 最終的な状態を確認
expect(finalState).toHaveLength(3);

// 元のタスクが完了している
const original = finalState.find((t) => t.id === "1");
expect(original?.end_time).toBe("2024-01-01T11:00:00Z");

// 新しいタスクが未開始状態で追加されている
const newTaskInState = finalState.find((t) => t.id === "3");
expect(newTaskInState).toBeDefined();
expect(newTaskInState?.title).toBe(mockRunningTask.title);
expect(newTaskInState?.start_time).toBeNull();
expect(newTaskInState?.end_time).toBeNull();
});
});
});
77 changes: 76 additions & 1 deletion ui/src/hooks/useTaskActions.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import React from "react";
import { createClient } from "@/lib/supabase/client";
import { useToast } from "@/components/ui/use-toast";
import { TaskAction } from "@/types/task";
import { TaskAction, Task } from "@/types/task";
import { getTodayDateString } from "@/lib/utils";

const supabase = createClient();
Expand Down Expand Up @@ -85,9 +85,84 @@ export const useTaskActions = (dispatch: React.Dispatch<TaskAction>) => {
}
};

// タスクを中断
const handlePauseTask = async (task: Task) => {
// 1. 現在のタスクに終了時刻を設定して完了させる
const endTime = new Date().toISOString();
const { error: updateError } = await supabase
.from("tasks")
.update({ end_time: endTime })
.eq("id", task.id);

if (updateError) {
toast({
title: "Error",
description: "タスクの中断に失敗しました",
variant: "destructive",
});
console.error("Error pausing task:", updateError);
return;
}

// ローカル状態を更新
dispatch({
type: "UPDATE_TASK",
payload: { id: task.id, end_time: endTime },
});

// 2. 同じ属性で新しいタスクを作成
const newTask = {
title: task.title,
description: task.description,
user_id: task.user_id,
estimated_minute: task.estimated_minute,
category_id: task.category_id,
task_date: task.task_date,
task_order: null,
};

const { data, error: insertError } = await supabase
.from("tasks")
.insert(newTask)
.select();

if (insertError) {
toast({
title: "Error",
description: "新しいタスクの作成に失敗しました",
variant: "destructive",
});
console.error("Error creating new task:", insertError);
return;
}

if (!data || data.length === 0) {
toast({
title: "Error",
description: "新しいタスクの作成に失敗しました",
variant: "destructive",
});
console.error("Error creating new task: No data returned");
return;
}

// 新しく作成したタスクをリストに追加
const createdTask = data[0] as unknown as Task;
dispatch({
type: "ADD_TASK",
payload: createdTask,
});

toast({
title: "Success",
description: `タスク "${task.title}" を中断しました`,
});
};

return {
handleDelete,
handleTaskTimer,
handleMoveToToday,
handlePauseTask,
};
};
1 change: 1 addition & 0 deletions ui/src/pages/TaskList/TaskList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -683,6 +683,7 @@ const TaskList = () => {
currentTask={currentRunningTask || null}
categories={categories}
onTaskTimer={taskActions.handleTaskTimer}
onPauseTask={taskActions.handlePauseTask}
/>
</TaskProvider>
);
Expand Down
Loading