diff --git a/backend/src/features/task-decompose/task-calendar.service.ts b/backend/src/features/task-decompose/task-calendar.service.ts index e4f5d81..736b1b5 100644 --- a/backend/src/features/task-decompose/task-calendar.service.ts +++ b/backend/src/features/task-decompose/task-calendar.service.ts @@ -3,6 +3,7 @@ import type { TaskDecomposeRequest, TaskDecomposeResult, } from "./task-decompose.types"; +import { normalizeTaskTimezone } from "./task-timezone"; import type { CalendarCreatedEvent, CalendarSyncResult, @@ -51,6 +52,23 @@ function buildEventSummary( return `[${index + 1}/${totalCount}] ${normalizedSubtask} | ${normalizedOverall}`; } +function toOverallTaskName( + requestTask: string, + breakdown: TaskDecomposeResult, +): string { + const summary = toSingleLine(breakdown.summary ?? ""); + if (summary.length > 0) { + return summary; + } + + const goal = toSingleLine(breakdown.goal ?? ""); + if (goal.length > 0) { + return goal; + } + + return toSingleLine(requestTask); +} + function safeDate(value: string, fallback: Date): Date { const parsed = new Date(value); if (Number.isNaN(parsed.getTime())) { @@ -279,8 +297,12 @@ export async function createCalendarEvents( input: CreateCalendarEventsInput, ): Promise { const accessToken = await getGoogleAccessToken(env, input.userId); - const timezone = input.request.timezone ?? "UTC"; + const timezone = normalizeTaskTimezone(input.request.timezone); const calendarId = PRIMARY_CALENDAR_ID; + const overallTaskName = toOverallTaskName( + input.request.task, + input.breakdown, + ); const createdEvents: CalendarCreatedEvent[] = []; const totalCount = Math.max(input.breakdown.subtasks.length, 1); @@ -296,7 +318,7 @@ export async function createCalendarEvents( const eventId = await createStableEventId(input.workflowId, index); const summary = buildEventSummary( subtask.title, - input.request.task, + overallTaskName, index, totalCount, ); diff --git a/backend/src/features/task-decompose/task-decompose.service.ts b/backend/src/features/task-decompose/task-decompose.service.ts index 98f7ade..1d97d2c 100644 --- a/backend/src/features/task-decompose/task-decompose.service.ts +++ b/backend/src/features/task-decompose/task-decompose.service.ts @@ -3,13 +3,13 @@ import type { TaskDecomposeResult, TaskSubtask, } from "./task-decompose.types"; +import { normalizeTaskTimezone } from "./task-timezone"; const AI_MODEL = "@cf/meta/llama-3.1-8b-instruct-fp8"; const DEFAULT_MAX_STEPS = 6; const MIN_DURATION_MINUTES = 15; const MAX_DURATION_MINUTES = 240; const DEFAULT_INFERRED_DEADLINE_DAYS = 7; -const DEFAULT_PLANNING_TIMEZONE = "Asia/Tokyo"; function asStringArray(value: unknown): string[] { if (!Array.isArray(value)) { @@ -76,14 +76,6 @@ function fallbackSummary(task: string): string { return `"${task}" を実行可能な単位へ分解した計画です。`; } -function normalizeTimezone(timezone: string | undefined): string { - if (!timezone || timezone.trim().length === 0) { - return DEFAULT_PLANNING_TIMEZONE; - } - - return timezone.trim(); -} - function formatPromptDateInTimezone(date: Date, timezone: string): string { try { return new Intl.DateTimeFormat("en-CA", { @@ -260,7 +252,7 @@ function normalizeResult( function createPrompt(payload: TaskDecomposeRequest): string { const maxSteps = payload.maxSteps ?? DEFAULT_MAX_STEPS; - const planningTimezone = normalizeTimezone(payload.timezone); + const planningTimezone = normalizeTaskTimezone(payload.timezone); const now = new Date(); const deadlineGuidance = payload.deadline diff --git a/backend/src/features/task-decompose/task-decompose.types.ts b/backend/src/features/task-decompose/task-decompose.types.ts index a50231f..6a44d82 100644 --- a/backend/src/features/task-decompose/task-decompose.types.ts +++ b/backend/src/features/task-decompose/task-decompose.types.ts @@ -7,6 +7,13 @@ export type TaskDecomposeRequest = { maxSteps?: number; }; +export type ValidatedTaskDecomposeRequest = Omit< + TaskDecomposeRequest, + "timezone" +> & { + timezone: string; +}; + export type TaskSubtask = { title: string; description: string; diff --git a/backend/src/features/task-decompose/task-decompose.validation.ts b/backend/src/features/task-decompose/task-decompose.validation.ts index 39a1ef7..d565ed0 100644 --- a/backend/src/features/task-decompose/task-decompose.validation.ts +++ b/backend/src/features/task-decompose/task-decompose.validation.ts @@ -1,4 +1,5 @@ -import type { TaskDecomposeRequest } from "./task-decompose.types"; +import type { ValidatedTaskDecomposeRequest } from "./task-decompose.types"; +import { normalizeTaskTimezone } from "./task-timezone"; const MAX_STEPS_LIMIT = 12; @@ -20,17 +21,12 @@ function parseDeadline(value: unknown): string | undefined { return parsed.toISOString(); } -function parseTimezone(value: unknown): string | undefined { +function parseTimezone(value: unknown): string { if (typeof value !== "string") { - return undefined; - } - - const trimmed = value.trim(); - if (trimmed.length === 0) { - return undefined; + return normalizeTaskTimezone(undefined); } - return trimmed; + return normalizeTaskTimezone(value); } function parseMaxSteps(value: unknown): number | undefined { @@ -46,7 +42,9 @@ function parseMaxSteps(value: unknown): number | undefined { return Math.min(rounded, MAX_STEPS_LIMIT); } -export function toRequestPayload(input: unknown): TaskDecomposeRequest | null { +export function toRequestPayload( + input: unknown, +): ValidatedTaskDecomposeRequest | null { if (!input || typeof input !== "object") { return null; } diff --git a/backend/src/features/task-decompose/task-timezone.ts b/backend/src/features/task-decompose/task-timezone.ts new file mode 100644 index 0000000..349ad2b --- /dev/null +++ b/backend/src/features/task-decompose/task-timezone.ts @@ -0,0 +1,18 @@ +const DEFAULT_TASK_TIMEZONE = "Asia/Tokyo"; + +export function normalizeTaskTimezone( + value: string | null | undefined, +): string { + if (!value || value.trim().length === 0) { + return DEFAULT_TASK_TIMEZONE; + } + + const candidate = value.trim(); + try { + return new Intl.DateTimeFormat("en-US", { + timeZone: candidate, + }).resolvedOptions().timeZone; + } catch { + return DEFAULT_TASK_TIMEZONE; + } +} diff --git a/frontend/src/app/page.tsx b/frontend/src/app/page.tsx index a3d333a..f94eff5 100644 --- a/frontend/src/app/page.tsx +++ b/frontend/src/app/page.tsx @@ -1,1021 +1,44 @@ "use client"; import { - Avatar, Badge, Box, Button, Card, Container, - Field, + Grid, Heading, HStack, - Input, List, - ProgressCircle, Stack, - Tabs, Text, - Textarea, } from "@chakra-ui/react"; -import type { FormEvent } from "react"; -import { useCallback, useEffect, useMemo, useRef, useState } from "react"; -import { AuthPanel } from "@/components/auth/auth-panel"; -import { - AuthApiError, - getSession, - type SessionResponse, - signInWithGoogle, - signOut, -} from "@/lib/auth-api"; -import { - getTaskWorkflowHistory, - getTaskWorkflowStatus, - startTaskWorkflow, - TaskWorkflowApiError, - type WorkflowRecord, - type WorkflowRuntimeStatus, -} from "@/lib/task-workflow-api"; - -type RunPhase = "idle" | "starting" | "waiting" | "completed" | "failed"; -type ErrorAction = "start" | "status" | "history" | "session" | "signOut"; -type ViewMode = "auth" | "compose" | "running" | "result"; -type TransitionDirection = "forward" | "backward"; - -type StepItem = { - label: string; -}; - -const CALENDAR_REAUTH_MARKER = "REAUTH_REQUIRED_CALENDAR_SCOPE:"; -const DEFAULT_USER_TIMEZONE = "Asia/Tokyo"; -const STEP_ITEMS: StepItem[] = [ - { label: "認証" }, - { label: "入力" }, - { label: "実行" }, - { label: "結果" }, -]; - -function viewIndex(view: ViewMode): number { - if (view === "auth") { - return 0; - } - if (view === "compose") { - return 1; - } - if (view === "running") { - return 2; - } - return 3; -} - -function fallbackErrorMessage(action: ErrorAction): string { - if (action === "start") { - return "Workflow の開始に失敗しました。"; - } - if (action === "status") { - return "Workflow の状態取得に失敗しました。"; - } - if (action === "history") { - return "履歴の取得に失敗しました。"; - } - if (action === "signOut") { - return "ログアウトに失敗しました。時間をおいて再試行してください。"; - } - return "セッション状態の取得に失敗しました。"; -} - -function toErrorMessage(error: unknown, action: ErrorAction): string { - if (error instanceof TypeError) { - return "ネットワーク接続に失敗しました。通信環境を確認してください。"; - } - - if (error instanceof TaskWorkflowApiError || error instanceof AuthApiError) { - if (error.status === 401) { - return "ログインが必要です。Google でログインしてください。"; - } - if (error.status >= 500) { - return "サーバー側でエラーが発生しました。時間をおいて再試行してください。"; - } - } - - return fallbackErrorMessage(action); -} - -function needsCalendarReauth(rawMessage: string | null): boolean { - return Boolean(rawMessage?.includes(CALENDAR_REAUTH_MARKER)); -} - -function toDisplayErrorMessage(rawMessage: string | null): string | null { - if (!rawMessage) { - return null; - } - - if (!needsCalendarReauth(rawMessage)) { - return rawMessage; - } - - const message = rawMessage.replace(CALENDAR_REAUTH_MARKER, "").trim(); - if (message.length > 0) { - return message; - } - - return "Google Calendar の権限再許可が必要です。"; -} - -function formatDateTime( - value: string | null | undefined, - timezone?: string | null, -): string { - if (!value) { - return "-"; - } - - const parsed = new Date(value); - if (Number.isNaN(parsed.getTime())) { - return value; - } - - return parsed.toLocaleString("ja-JP", { - timeZone: timezone ?? DEFAULT_USER_TIMEZONE, - year: "numeric", - month: "2-digit", - day: "2-digit", - hour: "2-digit", - minute: "2-digit", - }); -} - -function toStatusLabel( - phase: RunPhase, - workflowStatus: WorkflowRuntimeStatus | null, - record: WorkflowRecord | null, -): string { - if (phase === "starting") { - return "開始中"; - } - - if (record?.status) { - return toStatusLabelFromRecord(record.status); - } - - if (workflowStatus) { - if (workflowStatus.status === "running") { - return "実行中"; - } - if (workflowStatus.status === "complete") { - return "完了"; - } - if ( - workflowStatus.status === "errored" || - workflowStatus.status === "terminated" - ) { - return "失敗"; - } - } - - if (phase === "waiting") { - return "待機中"; - } - - return "未実行"; -} - -function toStatusLabelFromRecord(status: WorkflowRecord["status"]): string { - if (status === "queued") { - return "キュー待ち"; - } - if (status === "running") { - return "細分化中"; - } - if (status === "calendar_syncing") { - return "カレンダー反映中"; - } - if (status === "completed") { - return "完了"; - } - return "失敗"; -} - -function toHistoryTitle(record: WorkflowRecord): string { - const goal = record.llmOutput?.goal?.trim(); - if (goal && goal.length > 0) { - return goal; - } - - const summary = record.llmOutput?.summary?.trim(); - if (summary && summary.length > 0) { - return summary; - } - - return record.taskInput; -} - -function toWorkflowProgress( - phase: RunPhase, - record: WorkflowRecord | null, - workflowStatus: WorkflowRuntimeStatus | null, -): number { - if (phase === "starting") { - return 15; - } - - if (record?.status) { - if (record.status === "queued") { - return 25; - } - if (record.status === "running") { - return 58; - } - if (record.status === "calendar_syncing") { - return 84; - } - return 100; - } - - if (workflowStatus?.status === "complete") { - return 100; - } - if ( - workflowStatus?.status === "errored" || - workflowStatus?.status === "terminated" - ) { - return 100; - } - if (phase === "waiting") { - return 45; - } - - return 0; -} - -function toDeadlineIso(value: string): string | undefined { - const trimmed = value.trim(); - if (trimmed.length === 0) { - return undefined; - } - - const parsed = new Date(trimmed); - if (Number.isNaN(parsed.getTime())) { - return undefined; - } - - return parsed.toISOString(); -} - -function toInitials(name: string | null | undefined): string { - if (!name || name.trim().length === 0) { - return "GU"; - } - - const letters = name - .trim() - .split(/\s+/) - .slice(0, 2) - .map((part) => part.charAt(0).toUpperCase()) - .filter((part) => part.length > 0) - .join(""); - - return letters.length > 0 ? letters : "GU"; -} - -export default function Home() { - const [session, setSession] = useState(null); - const [isSessionLoading, setIsSessionLoading] = useState(true); - - const [task, setTask] = useState(""); - const [context, setContext] = useState(""); - const [deadline, setDeadline] = useState(""); - const [maxSteps, setMaxSteps] = useState("6"); - - const [phase, setPhase] = useState("idle"); - const [workflowId, setWorkflowId] = useState(null); - const [workflowStatus, setWorkflowStatus] = - useState(null); - const [record, setRecord] = useState(null); - const [history, setHistory] = useState([]); - const [errorMessage, setErrorMessage] = useState(null); - const [isReauthRunning, setIsReauthRunning] = useState(false); - const [isSignOutRunning, setIsSignOutRunning] = useState(false); - const [dotTick, setDotTick] = useState(0); - - const [resultTab, setResultTab] = useState<"result" | "history">("result"); - const [viewMode, setViewMode] = useState("auth"); - const [transitionDirection, setTransitionDirection] = - useState("forward"); - - const viewModeRef = useRef("auth"); - - const signedInUser = useMemo(() => session?.user ?? null, [session]); - const statusLabel = useMemo( - () => toStatusLabel(phase, workflowStatus, record), - [phase, workflowStatus, record], - ); - const workflowProgress = useMemo( - () => toWorkflowProgress(phase, record, workflowStatus), - [phase, record, workflowStatus], - ); - const requiresCalendarReauth = useMemo( - () => needsCalendarReauth(errorMessage), - [errorMessage], - ); - const displayErrorMessage = useMemo( - () => toDisplayErrorMessage(errorMessage), - [errorMessage], - ); - - const setView = useCallback((nextView: ViewMode) => { - const previous = viewModeRef.current; - if (previous === nextView) { - return; - } - - setTransitionDirection( - viewIndex(nextView) > viewIndex(previous) ? "forward" : "backward", - ); - viewModeRef.current = nextView; - setViewMode(nextView); - }, []); - - const refreshSession = useCallback(async () => { - setIsSessionLoading(true); - try { - const nextSession = await getSession(); - setSession(nextSession); - } catch (error) { - setErrorMessage(toErrorMessage(error, "session")); - setSession(null); - } finally { - setIsSessionLoading(false); - } - }, []); - - const refreshHistory = useCallback(async () => { - if (!signedInUser) { - setHistory([]); - return; - } - - try { - const response = await getTaskWorkflowHistory(8); - setHistory(response.items); - } catch (error) { - setErrorMessage(toErrorMessage(error, "history")); - } - }, [signedInUser]); - - useEffect(() => { - void refreshSession(); - }, [refreshSession]); - - useEffect(() => { - void refreshHistory(); - }, [refreshHistory]); - - useEffect(() => { - if (phase !== "waiting") { - setDotTick(0); - return; - } - - const timer = window.setInterval(() => { - setDotTick((prev) => (prev + 1) % 3); - }, 420); - - return () => { - window.clearInterval(timer); - }; - }, [phase]); - - useEffect(() => { - if (!workflowId || phase !== "waiting") { - return; - } - - let active = true; - let inFlight = false; - - const poll = async () => { - if (inFlight) { - return; - } - - inFlight = true; - try { - const response = await getTaskWorkflowStatus(workflowId); - if (!active) { - return; - } - - setWorkflowStatus(response.workflowStatus); - setRecord(response.record); - - if (response.record?.status === "completed") { - setPhase("completed"); - setErrorMessage(null); - void refreshHistory(); - return; - } - - if (response.record?.status === "failed") { - setPhase("failed"); - setErrorMessage( - response.record.errorMessage ?? "Workflow が失敗しました。", - ); - void refreshHistory(); - return; - } - - if ( - response.workflowStatus.status === "errored" || - response.workflowStatus.status === "terminated" - ) { - setPhase("failed"); - setErrorMessage( - response.workflowStatus.error?.message ?? - "Workflow が異常終了しました。", - ); - } - } catch (error) { - if (!active) { - return; - } - - setErrorMessage(toErrorMessage(error, "status")); - if (error instanceof TaskWorkflowApiError && error.status === 401) { - setPhase("failed"); - } - } finally { - inFlight = false; - } - }; - - void poll(); - const pollIntervalMs = - record?.status === "running" || record?.status === "calendar_syncing" - ? 5000 - : 3000; - const timer = window.setInterval(() => { - void poll(); - }, pollIntervalMs); - - return () => { - active = false; - window.clearInterval(timer); - }; - }, [workflowId, phase, record?.status, refreshHistory]); - - useEffect(() => { - let nextView: ViewMode; - - if (!signedInUser) { - nextView = "auth"; - } else if (phase === "starting" || phase === "waiting") { - nextView = "running"; - } else if ( - phase === "completed" || - phase === "failed" || - record?.status === "completed" || - record?.status === "failed" - ) { - nextView = "result"; - } else { - nextView = "compose"; - } - - setView(nextView); - }, [phase, record?.status, setView, signedInUser]); - - const handleSubmit = async (event: FormEvent) => { - event.preventDefault(); - - if (!signedInUser) { - setErrorMessage( - "実行にはログインが必要です。Google でログインしてください。", - ); - return; - } - - const trimmedTask = task.trim(); - if (trimmedTask.length === 0) { - setErrorMessage("タスク入力は必須です。"); - return; - } - - const deadlineIso = toDeadlineIso(deadline); - if (deadline.trim().length > 0 && !deadlineIso) { - setErrorMessage("最終期限の形式が正しくありません。"); - return; - } - - const parsedMaxSteps = Number(maxSteps); - const safeMaxSteps = Number.isFinite(parsedMaxSteps) - ? Math.min(Math.max(Math.trunc(parsedMaxSteps), 1), 12) - : 6; - const timezone = - Intl.DateTimeFormat().resolvedOptions().timeZone ?? DEFAULT_USER_TIMEZONE; - - setPhase("starting"); - setErrorMessage(null); - - try { - const response = await startTaskWorkflow({ - task: trimmedTask, - context: context.trim().length > 0 ? context.trim() : undefined, - deadline: deadlineIso, - timezone, - maxSteps: safeMaxSteps, - }); - - setWorkflowId(response.id); - setWorkflowStatus(response.workflowStatus); - setRecord(response.record); - - if (response.record?.status === "completed") { - setPhase("completed"); - } else if (response.record?.status === "failed") { - setPhase("failed"); - setErrorMessage( - response.record.errorMessage ?? "Workflow が失敗しました。", - ); - } else { - setPhase("waiting"); - } - - await refreshHistory(); - } catch (error) { - setPhase("failed"); - setErrorMessage(toErrorMessage(error, "start")); - } - }; - - const handleSelectHistory = (item: WorkflowRecord) => { - setWorkflowId(item.workflowId); - setRecord(item); - setWorkflowStatus(null); - setErrorMessage(null); - setResultTab("result"); - - if (item.status === "completed") { - setPhase("completed"); - return; - } - - if (item.status === "failed") { - setPhase("failed"); - setErrorMessage(item.errorMessage ?? "Workflow が失敗しました。"); - return; - } - - setPhase("waiting"); - }; - - const handleStartNewTask = () => { - setPhase("idle"); - setWorkflowId(null); - setWorkflowStatus(null); - setRecord(null); - setErrorMessage(null); - setResultTab("result"); - }; - - const handleSignOut = async () => { - setIsSignOutRunning(true); - try { - await signOut(); - setSession(null); - setHistory([]); - setTask(""); - setContext(""); - setDeadline(""); - setMaxSteps("6"); - handleStartNewTask(); - } catch (error) { - setErrorMessage(toErrorMessage(error, "signOut")); - } finally { - setIsSignOutRunning(false); - } - }; - - const handleCalendarReauth = async () => { - setIsReauthRunning(true); - try { - await signInWithGoogle(window.location.href); - } catch (error) { - setErrorMessage(toErrorMessage(error, "session")); - setIsReauthRunning(false); - return; - } - - setIsReauthRunning(false); - }; - - const handleAuthPanelSessionChanged = useCallback( - (nextSession: SessionResponse) => { - setSession(nextSession); - setIsSessionLoading(false); - }, - [], - ); - - const currentStepIndex = viewIndex(viewMode); - const waitingDots = ".".repeat(dotTick + 1); - const breakdown = record?.llmOutput; - const calendarResult = record?.calendarOutput; - - const screenBody = (() => { - if (viewMode === "auth") { - return ( - - - まずGoogleでログインし、カレンダー連携権限を許可してください。 - 認証後はタスク入力画面に進みます。 - - - - ); - } - - if (viewMode === "compose") { - return ( - -
- - - 細分化したいタスク -