diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..7eba98f --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +backend/.secrets/ diff --git a/backend/.dev.vars.example b/backend/.dev.vars.example index d97b8b5..fb05e9b 100644 --- a/backend/.dev.vars.example +++ b/backend/.dev.vars.example @@ -7,3 +7,4 @@ BETTER_AUTH_URL=http://localhost:8787 GOOGLE_CLIENT_ID=your-google-client-id GOOGLE_CLIENT_SECRET=your-google-client-secret BETTER_AUTH_SECRET=replace-with-random-long-secret +GOOGLE_MAPS_API_KEY=your-google-maps-api-key diff --git a/backend/.secrets/develop.env.example b/backend/.secrets/develop.env.example index b994a6f..1fd6b42 100644 --- a/backend/.secrets/develop.env.example +++ b/backend/.secrets/develop.env.example @@ -1,3 +1,4 @@ BETTER_AUTH_SECRET=replace-with-random-long-secret GOOGLE_CLIENT_ID=your-develop-google-client-id GOOGLE_CLIENT_SECRET=your-develop-google-client-secret +GOOGLE_MAPS_API_KEY=your-google-maps-api-key diff --git a/backend/.secrets/main.env.example b/backend/.secrets/main.env.example index 6ae5ece..baa67a5 100644 --- a/backend/.secrets/main.env.example +++ b/backend/.secrets/main.env.example @@ -1,3 +1,4 @@ BETTER_AUTH_SECRET=replace-with-random-long-secret GOOGLE_CLIENT_ID=your-main-google-client-id GOOGLE_CLIENT_SECRET=your-main-google-client-secret +GOOGLE_MAPS_API_KEY=your-google-maps-api-key diff --git a/backend/.secrets/pr.env.example b/backend/.secrets/pr.env.example index 7deac92..5914aed 100644 --- a/backend/.secrets/pr.env.example +++ b/backend/.secrets/pr.env.example @@ -1,3 +1,4 @@ BETTER_AUTH_SECRET=replace-with-random-long-secret GOOGLE_CLIENT_ID=your-pr-google-client-id GOOGLE_CLIENT_SECRET=your-pr-google-client-secret +GOOGLE_MAPS_API_KEY=your-google-maps-api-key diff --git a/backend/README.md b/backend/README.md index 8a4ccca..7a92aa9 100644 --- a/backend/README.md +++ b/backend/README.md @@ -5,7 +5,7 @@ ## 技術スタック -- Hono +- Honogit pull - Cloudflare Workers - Cloudflare Workflows - Workers AI diff --git a/backend/migrations/0003_morning_briefing_cache.sql b/backend/migrations/0003_morning_briefing_cache.sql new file mode 100644 index 0000000..b9fadf0 --- /dev/null +++ b/backend/migrations/0003_morning_briefing_cache.sql @@ -0,0 +1,16 @@ +create table "morning_briefing_cache" ( + "id" text not null primary key, + "user_id" text not null, + "slot_key" text not null, + "location_key" text not null, + "prep_minutes" integer not null, + "payload_json" text not null, + "created_at" date not null, + "updated_at" date not null +); + +create unique index "morning_briefing_cache_unique_idx" + on "morning_briefing_cache" ("user_id", "slot_key", "location_key", "prep_minutes"); + +create index "morning_briefing_cache_user_slot_idx" + on "morning_briefing_cache" ("user_id", "slot_key"); diff --git a/backend/src/app.ts b/backend/src/app.ts index af3eb28..edfc123 100644 --- a/backend/src/app.ts +++ b/backend/src/app.ts @@ -2,8 +2,11 @@ import type { Context } from "hono"; import { Hono } from "hono"; import { getAllowedOrigins, isAllowedOrigin } from "./lib/origins"; import { registerAuthRoutes } from "./routes/auth-routes"; +import { registerBriefingRoutes } from "./routes/briefing-routes"; +import { registerCalendarRoutes } from "./routes/calendar-routes"; import { registerRootRoutes } from "./routes/root-routes"; import { registerTaskRoutes } from "./routes/task-routes"; +import { registerTransitRoutes } from "./routes/transit-routes"; import { registerWorkflowRoutes } from "./routes/workflow-routes"; import type { App } from "./types/app"; @@ -49,7 +52,10 @@ export function createApp(): App { registerRootRoutes(app); registerAuthRoutes(app); + registerBriefingRoutes(app); + registerCalendarRoutes(app); registerTaskRoutes(app); + registerTransitRoutes(app); registerWorkflowRoutes(app); return app; diff --git a/backend/src/features/google-calendar/google-calendar.service.ts b/backend/src/features/google-calendar/google-calendar.service.ts new file mode 100644 index 0000000..a8eae14 --- /dev/null +++ b/backend/src/features/google-calendar/google-calendar.service.ts @@ -0,0 +1,109 @@ +import { createAuth } from "../../lib/auth"; +import type { CalendarEvent, TodayEventsResult } from "./google-calendar.types"; + +// --------------------------------------------------------------------------- +// Token helpers +// --------------------------------------------------------------------------- + +const GOOGLE_PROVIDER_ID = "google"; + +/** + * Retrieve a valid Google access token for the given user. + * + * Better Auth handles token decryption / refresh internally. We should always + * use the public `getAccessToken` API instead of reading `account` rows + * directly. + */ +async function getGoogleAccessToken( + env: Env, + userId: string, +): Promise { + const auth = createAuth(env); + try { + const tokenPayload = await auth.api.getAccessToken({ + body: { + providerId: GOOGLE_PROVIDER_ID, + userId, + }, + }); + + if ( + !tokenPayload || + typeof tokenPayload.accessToken !== "string" || + tokenPayload.accessToken.trim().length === 0 + ) { + return null; + } + + return tokenPayload.accessToken; + } catch (error) { + console.error("Failed to get Google access token:", error); + return null; + } +} + +// --------------------------------------------------------------------------- +// Calendar API +// --------------------------------------------------------------------------- + +/** + * Fetch today's events from the authenticated user's primary Google Calendar. + * + * Time zone is fixed to `Asia/Tokyo` (JST). + */ +export async function getTodayEvents( + env: Env, + userId: string, +): Promise { + // Compute "today" in JST + const jstNow = new Date(Date.now() + 9 * 60 * 60 * 1000); + const date = jstNow.toISOString().split("T")[0] as string; + + const accessToken = await getGoogleAccessToken(env, userId); + if (!accessToken) { + return { date, events: [], earliestEvent: null }; + } + + const timeMin = new Date(`${date}T00:00:00+09:00`).toISOString(); + const timeMax = new Date(`${date}T23:59:59+09:00`).toISOString(); + + const params = new URLSearchParams({ + timeMin, + timeMax, + singleEvents: "true", + orderBy: "startTime", + timeZone: "Asia/Tokyo", + }); + + const res = await fetch( + `https://www.googleapis.com/calendar/v3/calendars/primary/events?${params}`, + { headers: { Authorization: `Bearer ${accessToken}` } }, + ); + + if (!res.ok) { + console.error("Google Calendar API error:", res.status, await res.text()); + return { date, events: [], earliestEvent: null }; + } + + // biome-ignore lint/suspicious/noExplicitAny: Google Calendar API response + const data = (await res.json()) as any; + + const events: CalendarEvent[] = (data.items ?? []) + // biome-ignore lint/suspicious/noExplicitAny: Google Calendar event + .filter((item: any) => item.status !== "cancelled") + // biome-ignore lint/suspicious/noExplicitAny: Google Calendar event + .map((item: any) => ({ + id: item.id as string, + summary: (item.summary as string) ?? "(無題)", + location: (item.location as string) ?? null, + start: item.start?.dateTime ?? item.start?.date ?? "", + end: item.end?.dateTime ?? item.end?.date ?? "", + isAllDay: !item.start?.dateTime, + })); + + const timedEvents = events.filter((e) => !e.isAllDay); + const earliestEvent = + timedEvents.length > 0 ? (timedEvents[0] ?? null) : null; + + return { date, events, earliestEvent }; +} diff --git a/backend/src/features/google-calendar/google-calendar.types.ts b/backend/src/features/google-calendar/google-calendar.types.ts new file mode 100644 index 0000000..788800f --- /dev/null +++ b/backend/src/features/google-calendar/google-calendar.types.ts @@ -0,0 +1,21 @@ +/** A single Google Calendar event (simplified). */ +export type CalendarEvent = { + id: string; + summary: string; + location: string | null; + /** ISO-8601 datetime or date string. */ + start: string; + /** ISO-8601 datetime or date string. */ + end: string; + /** true for all-day events. */ + isAllDay: boolean; +}; + +/** Result of fetching today's events. */ +export type TodayEventsResult = { + /** YYYY-MM-DD */ + date: string; + events: CalendarEvent[]; + /** The earliest *timed* (non-all-day) event, or null. */ + earliestEvent: CalendarEvent | null; +}; diff --git a/backend/src/features/morning-briefing/morning-briefing.service.ts b/backend/src/features/morning-briefing/morning-briefing.service.ts new file mode 100644 index 0000000..09fc215 --- /dev/null +++ b/backend/src/features/morning-briefing/morning-briefing.service.ts @@ -0,0 +1,373 @@ +import { getTodayEvents } from "../google-calendar/google-calendar.service"; +import type { CalendarEvent } from "../google-calendar/google-calendar.types"; +import { getTransitDirections } from "../transit/transit.service"; +import type { TransitRoute } from "../transit/transit.types"; +import { getWeather } from "../weather/weather.service"; +import type { WeatherInfo } from "../weather/weather.types"; +import type { + EventBriefing, + MorningBriefingRequest, + MorningBriefingResult, +} from "./morning-briefing.types"; + +type CacheRow = { + payload_json: string; +}; + +// --------------------------------------------------------------------------- +// JST helpers +// --------------------------------------------------------------------------- + +const JST_OFFSET_MS = 9 * 60 * 60 * 1000; + +function jstNow(): Date { + return new Date(Date.now() + JST_OFFSET_MS); +} + +/** "HH:mm" in JST from a UTC Date. */ +function toJstHHmm(d: Date): string { + const jst = new Date(d.getTime() + JST_OFFSET_MS); + const h = jst.getUTCHours().toString().padStart(2, "0"); + const m = jst.getUTCMinutes().toString().padStart(2, "0"); + return `${h}:${m}`; +} + +/** Parse an ISO-8601 datetime → minutes-since-midnight in JST. */ +function toJstMinutes(iso: string): number { + const d = new Date(iso); + if (Number.isNaN(d.getTime())) return -1; + const jst = new Date(d.getTime() + JST_OFFSET_MS); + return jst.getUTCHours() * 60 + jst.getUTCMinutes(); +} + +/** Subtract `minutes` from a JST minutes-since-midnight value → "HH:mm". */ +function subtractMinutes(jstMinOfDay: number, minutes: number): string { + const total = (((jstMinOfDay - minutes) % 1440) + 1440) % 1440; + const h = Math.floor(total / 60) + .toString() + .padStart(2, "0"); + const m = (total % 60).toString().padStart(2, "0"); + return `${h}:${m}`; +} + +// --------------------------------------------------------------------------- +// Late-risk engine +// --------------------------------------------------------------------------- + +/** + * Compute a late-risk percentage (0–100). + * + * Model: + * slackMinutes = (event start minutes) − (now minutes) − transitMinutes − prepMinutes + * + * slack >= 15 min → 0% (comfortable) + * slack <= −10 min → 100% (basically late) + * In between → linear 0–100 + a flat 10% transit-delay buffer + * + * This is intentionally simple and deterministic — no ML, no history needed, + * yet gives useful "urgency" feedback. + */ +function computeLateRisk(slackMinutes: number): number { + const COMFORTABLE = 15; // minutes of margin considered "safe" + const HOPELESS = -10; // at this point you're late + const TRANSIT_BUFFER = 10; // flat % added to account for delays + + if (slackMinutes >= COMFORTABLE) return 0; + if (slackMinutes <= HOPELESS) return 100; + + // Linear from 0% (at COMFORTABLE) to 90% (at HOPELESS) + const range = COMFORTABLE - HOPELESS; // 25 + const raw = ((COMFORTABLE - slackMinutes) / range) * 90; + const withBuffer = raw + TRANSIT_BUFFER; + return Math.min(100, Math.max(0, Math.round(withBuffer))); +} + +// --------------------------------------------------------------------------- +// Briefing builder (per event) +// --------------------------------------------------------------------------- + +async function buildEventBriefing( + apiKey: string, + currentLocation: string, + event: CalendarEvent, + prepMinutes: number, + nowMinutes: number, +): Promise { + const destination = event.location as string; // caller guarantees non-null + + // Ask Google Directions for transit route arriving by event start time + const transit = await getTransitDirections(apiKey, { + origin: currentLocation, + destination, + arrivalTime: event.start, // arrive by event start + }); + + const route: TransitRoute | null = transit.bestRoute; + const transitMinutes = route?.durationMinutes ?? 0; + + // Event start in JST minutes-since-midnight + const eventStartMin = toJstMinutes(event.start); + + // Recommended departure = event start − transit duration + const leaveByMin = eventStartMin - transitMinutes; + const leaveBy = subtractMinutes(eventStartMin, transitMinutes); + + // Recommended wake-up = departure − prep time + const wakeUpBy = subtractMinutes(eventStartMin, transitMinutes + prepMinutes); + + // Slack = how many minutes you have until you MUST leave + const slackMinutes = leaveByMin - nowMinutes; + + const lateRiskPercent = computeLateRisk(slackMinutes); + + return { + event, + destination, + route, + transitMinutes, + leaveBy, + wakeUpBy, + slackMinutes, + lateRiskPercent, + }; +} + +// --------------------------------------------------------------------------- +// Main entry point +// --------------------------------------------------------------------------- + +function formatJstDate(jstDate: Date): string { + return jstDate.toISOString().split("T")[0] as string; +} + +/** + * Cache slots: + * - 05:00 update slot + * - 23:00 update slot + * + * Between 00:00-04:59, we still use the previous day's 23:00 slot. + */ +function getCacheSlotKey(nowUtc: Date): string { + const jst = new Date(nowUtc.getTime() + JST_OFFSET_MS); + const hour = jst.getUTCHours(); + const date = formatJstDate(jst); + + if (hour >= 23) { + return `${date}@23`; + } + + if (hour >= 5) { + return `${date}@05`; + } + + const prev = new Date(jst.getTime() - 24 * 60 * 60 * 1000); + return `${formatJstDate(prev)}@23`; +} + +function normalizeLocationKey(value: string): string { + return value.trim().toLowerCase(); +} + +function cacheId( + userId: string, + slotKey: string, + locationKey: string, + prepMinutes: number, +): string { + return `${userId}::${slotKey}::${locationKey}::${prepMinutes}`; +} + +async function readCache( + db: D1Database, + userId: string, + slotKey: string, + locationKey: string, + prepMinutes: number, +): Promise { + const row = await db + .prepare( + `SELECT payload_json + FROM morning_briefing_cache + WHERE user_id = ?1 + AND slot_key = ?2 + AND location_key = ?3 + AND prep_minutes = ?4 + LIMIT 1`, + ) + .bind(userId, slotKey, locationKey, prepMinutes) + .first(); + + if (!row?.payload_json) return null; + + try { + return JSON.parse(row.payload_json) as MorningBriefingResult; + } catch { + return null; + } +} + +async function writeCache( + db: D1Database, + userId: string, + slotKey: string, + locationKey: string, + prepMinutes: number, + payload: MorningBriefingResult, +): Promise { + const nowIso = new Date().toISOString(); + await db + .prepare( + `INSERT INTO morning_briefing_cache ( + id, + user_id, + slot_key, + location_key, + prep_minutes, + payload_json, + created_at, + updated_at + ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?7) + ON CONFLICT(user_id, slot_key, location_key, prep_minutes) + DO UPDATE SET + payload_json = excluded.payload_json, + updated_at = excluded.updated_at`, + ) + .bind( + cacheId(userId, slotKey, locationKey, prepMinutes), + userId, + slotKey, + locationKey, + prepMinutes, + JSON.stringify(payload), + nowIso, + ) + .run(); +} + +async function computeMorningBriefing( + env: Env, + userId: string, + req: MorningBriefingRequest, +): Promise { + const now = jstNow(); + const nowHHmm = toJstHHmm(new Date()); // based on real UTC + const nowMinutes = now.getUTCHours() * 60 + now.getUTCMinutes(); + const dateStr = now.toISOString().split("T")[0] as string; + + const prepMinutes = req.prepMinutes ?? 30; + + // 1️⃣ Calendar + const calendar = await getTodayEvents(env, userId); + + // Separate events with / without location + const withLocation = calendar.events.filter( + (e) => !e.isAllDay && e.location && e.location.trim().length > 0, + ); + const withoutLocation = calendar.events.filter( + (e) => e.isAllDay || !e.location || e.location.trim().length === 0, + ); + + // 2️⃣ Transit + risk for each event with a location (in parallel) + const apiKey = env.GOOGLE_MAPS_API_KEY; + const briefings: EventBriefing[] = apiKey + ? await Promise.all( + withLocation.map((event) => + buildEventBriefing( + apiKey, + req.currentLocation, + event, + prepMinutes, + nowMinutes, + ), + ), + ) + : withLocation.map((event) => ({ + event, + destination: event.location as string, + route: null, + transitMinutes: 0, + leaveBy: "--:--", + wakeUpBy: "--:--", + slackMinutes: 0, + lateRiskPercent: 0, + })); + + // Sort by event start time (earliest first) + briefings.sort((a, b) => { + const aMin = toJstMinutes(a.event.start); + const bMin = toJstMinutes(b.event.start); + return aMin - bMin; + }); + + // The first (earliest) briefing is the most urgent + const urgent = briefings.length > 0 ? (briefings[0] ?? null) : null; + + // 3️⃣ Weather — check at the departure location around the leave-by time + let weather: WeatherInfo | null = null; + try { + const weatherLocation = urgent?.destination ?? req.currentLocation; + const weatherTime = urgent?.leaveBy ?? nowHHmm; + weather = await getWeather(weatherLocation, weatherTime, env); + } catch { + // Never let weather break the briefing + weather = null; + } + + return { + date: dateStr, + now: nowHHmm, + totalEvents: calendar.events.length, + briefings, + urgent, + eventsWithoutLocation: withoutLocation, + weather, + }; +} + +/** + * Generate a complete morning briefing for the authenticated user. + * + * Flow: + * 1. Fetch today's Google Calendar events + * 2. For each event **with a location**, query Google Directions (transit) + * 3. Compute departure time, wake-up time, slack, late-risk + * 4. Return a sorted list + the most urgent item + */ +export async function getMorningBriefing( + env: Env, + userId: string, + req: MorningBriefingRequest, +): Promise { + const prepMinutes = req.prepMinutes ?? 30; + const slotKey = getCacheSlotKey(new Date()); + const locationKey = normalizeLocationKey(req.currentLocation); + + if (!req.forceRefresh) { + const cached = await readCache( + env.AUTH_DB, + userId, + slotKey, + locationKey, + prepMinutes, + ); + if (cached) { + return cached; + } + } + + const computed = await computeMorningBriefing(env, userId, { + ...req, + prepMinutes, + }); + + await writeCache( + env.AUTH_DB, + userId, + slotKey, + locationKey, + prepMinutes, + computed, + ); + + return computed; +} diff --git a/backend/src/features/morning-briefing/morning-briefing.types.ts b/backend/src/features/morning-briefing/morning-briefing.types.ts new file mode 100644 index 0000000..188be4c --- /dev/null +++ b/backend/src/features/morning-briefing/morning-briefing.types.ts @@ -0,0 +1,81 @@ +import type { CalendarEvent } from "../google-calendar/google-calendar.types"; +import type { TransitRoute } from "../transit/transit.types"; +import type { WeatherInfo } from "../weather/weather.types"; + +// --------------------------------------------------------------------------- +// Request +// --------------------------------------------------------------------------- + +export type MorningBriefingRequest = { + /** User's current location (address or place name, e.g. "大阪市北区中崎西2-4-12"). */ + currentLocation: string; + /** + * Minutes the user needs to get ready before leaving. + * Default: 30 + */ + prepMinutes?: number; + /** + * Force bypass cache and recompute briefing immediately. + * Used when user explicitly presses "更新". + */ + forceRefresh?: boolean; +}; + +// --------------------------------------------------------------------------- +// Per-event briefing +// --------------------------------------------------------------------------- + +/** Transit + timing info computed for one calendar event. */ +export type EventBriefing = { + /** The calendar event this briefing is for. */ + event: CalendarEvent; + /** Destination extracted from the event (location field). */ + destination: string; + /** Best transit route from currentLocation → destination. */ + route: TransitRoute | null; + /** Transit duration in minutes. */ + transitMinutes: number; + /** Recommended departure time (HH:mm, JST). */ + leaveBy: string; + /** Recommended wake-up time (HH:mm, JST) — leaveBy minus prepMinutes. */ + wakeUpBy: string; + /** + * Minutes of slack (positive = you have spare time, negative = already late). + * Based on current time vs leaveBy. + */ + slackMinutes: number; + /** + * Estimated late risk as a percentage (0–100). + * + * Calculation: + * - 0% when slackMinutes >= 15 (comfortable margin) + * - 100% when slackMinutes <= -10 (almost certainly late) + * - Linear interpolation in between, with a 10% buffer penalty + * for transit (delays happen) + */ + lateRiskPercent: number; +}; + +// --------------------------------------------------------------------------- +// Full response +// --------------------------------------------------------------------------- + +export type MorningBriefingResult = { + /** YYYY-MM-DD (JST) */ + date: string; + /** Current time at the moment the briefing was computed (HH:mm, JST). */ + now: string; + /** Total calendar events found today. */ + totalEvents: number; + /** Events that have a location → briefing computed. */ + briefings: EventBriefing[]; + /** + * The most urgent briefing (earliest event with location). + * This is the one the user should act on first. + */ + urgent: EventBriefing | null; + /** Events that have NO location (listed for awareness). */ + eventsWithoutLocation: CalendarEvent[]; + /** Weather / umbrella info at the departure location around leave-by time. */ + weather: WeatherInfo | null; +}; 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/backend/src/features/transit/transit.service.ts b/backend/src/features/transit/transit.service.ts new file mode 100644 index 0000000..c94d017 --- /dev/null +++ b/backend/src/features/transit/transit.service.ts @@ -0,0 +1,156 @@ +import type { + TransitQuery, + TransitResult, + TransitRoute, +} from "./transit.types"; + +// --------------------------------------------------------------------------- +// Google Routes API (DRIVE mode) helpers +// --------------------------------------------------------------------------- + +const ROUTES_API_URL = + "https://routes.googleapis.com/directions/v2:computeRoutes"; + +/** + * Field mask — tells Routes API which fields to return. + * Keep it minimal to reduce response size and billing. + */ +const FIELD_MASK = [ + "routes.duration", + "routes.distanceMeters", + "routes.legs.duration", + "routes.legs.distanceMeters", + "routes.legs.startLocation", + "routes.legs.endLocation", + "routes.legs.steps.navigationInstruction", + "routes.legs.steps.localizedValues", + "routes.legs.steps.travelMode", +].join(","); + +/** "123s" → number of seconds. */ +function parseDurationSeconds(dur: unknown): number { + if (typeof dur === "string") { + return Number.parseInt(dur.replace("s", ""), 10) || 0; + } + if (typeof dur === "number") return dur; + return 0; +} + +/** JST helper: add minutes to "now" and format as "H:mm". */ +function jstTimeAfterMinutes(minutes: number): string { + const jst = new Date(Date.now() + 9 * 60 * 60 * 1000 + minutes * 60 * 1000); + const h = jst.getUTCHours(); + const m = jst.getUTCMinutes().toString().padStart(2, "0"); + return `${h}:${m}`; +} + +/** JST "now" as "H:mm". */ +function jstNowHHmm(): string { + return jstTimeAfterMinutes(0); +} + +// --------------------------------------------------------------------------- +// Public +// --------------------------------------------------------------------------- + +/** + * Look up driving directions via the **Google Routes API** (DRIVE mode). + * + * Google does not provide transit (train/bus) routing for Japan. + * We use DRIVE mode to estimate travel duration, then apply a multiplier + * (×1.3 by default) to approximate public-transport time. + * + * Cost: covered by the $200/month free Google Maps Platform credit. + * + * @see https://developers.google.com/maps/documentation/routes/compute_route_directions + * + * @param apiKey - Google Maps Platform API key (Routes API enabled). + * @param query - Origin / destination / optional arrival time. + */ +export async function getTransitDirections( + apiKey: string, + query: TransitQuery, +): Promise { + const body: Record = { + origin: { address: query.origin }, + destination: { address: query.destination }, + travelMode: "DRIVE", + languageCode: "ja", + regionCode: "JP", + computeAlternativeRoutes: false, + }; + + const res = await fetch(ROUTES_API_URL, { + method: "POST", + headers: { + "Content-Type": "application/json", + "X-Goog-Api-Key": apiKey, + "X-Goog-FieldMask": FIELD_MASK, + }, + body: JSON.stringify(body), + }); + + if (!res.ok) { + console.error("Routes API HTTP error:", res.status, await res.text()); + return { routes: [], bestRoute: null }; + } + + // biome-ignore lint/suspicious/noExplicitAny: Routes API response + const data = (await res.json()) as any; + + if (!data.routes || !Array.isArray(data.routes) || data.routes.length === 0) { + return { routes: [], bestRoute: null }; + } + + // Multiplier to approximate public-transport time from driving time. + // Trains in Japan are often comparable or faster than driving, but + // walks + waits add overhead. 1.3× is a conservative estimate. + const TRANSIT_MULTIPLIER = 1.3; + + const routes: TransitRoute[] = data.routes + // biome-ignore lint/suspicious/noExplicitAny: Routes API route object + .map((route: any): TransitRoute | null => { + const leg = route.legs?.[0]; + if (!leg) return null; + + const rawDurationSec = parseDurationSeconds( + route.duration ?? leg.duration, + ); + const estimatedMinutes = Math.ceil( + (rawDurationSec / 60) * TRANSIT_MULTIPLIER, + ); + const distanceKm = Math.round( + ((route.distanceMeters ?? leg.distanceMeters ?? 0) as number) / 1000, + ); + + const departureTime = jstNowHHmm(); + const arrivalTime = jstTimeAfterMinutes(estimatedMinutes); + + // biome-ignore lint/suspicious/noExplicitAny: Routes API step + const steps = (leg.steps ?? []).map((step: any) => ({ + mode: "DRIVE" as const, + instruction: + (step.navigationInstruction?.instructions as string)?.replace( + /<[^>]*>/g, + "", + ) ?? "", + durationMinutes: Math.ceil( + parseDurationSeconds(step.staticDuration ?? step.duration) / 60, + ), + })); + + return { + departureTime, + arrivalTime, + durationMinutes: estimatedMinutes, + summary: `車で約${distanceKm}km(推定${estimatedMinutes}分・乗換含む概算)`, + steps, + }; + }) + .filter(Boolean) as TransitRoute[]; + + return { + routes, + bestRoute: routes[0] ?? null, + }; +} diff --git a/backend/src/features/transit/transit.types.ts b/backend/src/features/transit/transit.types.ts new file mode 100644 index 0000000..c4d0055 --- /dev/null +++ b/backend/src/features/transit/transit.types.ts @@ -0,0 +1,47 @@ +/** Request to look up transit directions. */ +export type TransitQuery = { + /** Address or place name of the starting point (e.g. "大阪駅"). */ + origin: string; + /** Address or place name of the destination (e.g. "京都大学"). */ + destination: string; + /** + * ISO-8601 datetime by which you must *arrive*. + * When provided the Directions API calculates routes backwards from this time. + */ + arrivalTime?: string; +}; + +/** A single step inside a transit route. */ +export type TransitStep = { + /** WALKING · TRANSIT */ + mode: string; + /** Human-readable instruction (HTML tags stripped). */ + instruction: string; + durationMinutes: number; + /** Present only when `mode === "TRANSIT"`. */ + transitDetails?: { + /** Line / route name (e.g. "JR京都線"). */ + line: string; + departureStop: string; + arrivalStop: string; + numStops: number; + }; +}; + +/** A complete route option. */ +export type TransitRoute = { + /** e.g. "8:12" */ + departureTime: string; + /** e.g. "9:05" */ + arrivalTime: string; + durationMinutes: number; + summary: string; + steps: TransitStep[]; +}; + +/** Result of a transit directions lookup. */ +export type TransitResult = { + routes: TransitRoute[]; + /** The first (best) route, or null if nothing found. */ + bestRoute: TransitRoute | null; +}; diff --git a/backend/src/features/weather/weather.service.ts b/backend/src/features/weather/weather.service.ts new file mode 100644 index 0000000..e68447f --- /dev/null +++ b/backend/src/features/weather/weather.service.ts @@ -0,0 +1,259 @@ +import type { WeatherInfo } from "./weather.types"; + +// --------------------------------------------------------------------------- +// Defaults & thresholds +// --------------------------------------------------------------------------- + +/** Kyoto Station (fallback when geocoding fails and no ENV override). */ +const FALLBACK_LAT = 34.9858; +const FALLBACK_LON = 135.7588; +const FALLBACK_NAME = "京都駅(デフォルト)"; + +const DEFAULT_PROB_THRESHOLD = 50; // % +const DEFAULT_MM_THRESHOLD = 0.2; // mm/h + +// --------------------------------------------------------------------------- +// Geocoding (Open-Meteo — no API key required) +// --------------------------------------------------------------------------- + +type GeoResult = { lat: number; lon: number; name: string }; + +/** + * Resolve a place name to lat/lon via Open-Meteo Geocoding API. + * Returns `null` on any failure so the caller can fall back. + * + * @example + * ``` + * curl "https://geocoding-api.open-meteo.com/v1/search?name=京都大学&count=1&language=ja&format=json" + * ``` + */ +async function geocode(location: string): Promise { + try { + const params = new URLSearchParams({ + name: location, + count: "1", + language: "ja", + format: "json", + }); + const res = await fetch( + `https://geocoding-api.open-meteo.com/v1/search?${params}`, + ); + if (!res.ok) return null; + + // biome-ignore lint/suspicious/noExplicitAny: Open-Meteo geocoding response + const data = (await res.json()) as any; + const first = data?.results?.[0]; + if (!first || typeof first.latitude !== "number") return null; + + return { + lat: first.latitude as number, + lon: first.longitude as number, + name: (first.name as string) ?? location, + }; + } catch { + return null; + } +} + +// --------------------------------------------------------------------------- +// Forecast (Open-Meteo — no API key required) +// --------------------------------------------------------------------------- + +type HourlySlot = { + iso: string; + precipitationProbability: number | null; + precipitationMm: number | null; +}; + +/** + * Fetch today's hourly forecast for the given coordinates. + * + * @example + * ``` + * curl "https://api.open-meteo.com/v1/forecast?latitude=34.98&longitude=135.75&hourly=precipitation_probability,precipitation&timezone=Asia/Tokyo&forecast_days=1" + * ``` + */ +async function fetchHourlyForecast( + lat: number, + lon: number, +): Promise { + try { + const params = new URLSearchParams({ + latitude: lat.toString(), + longitude: lon.toString(), + hourly: "precipitation_probability,precipitation", + timezone: "Asia/Tokyo", + forecast_days: "1", + }); + const res = await fetch(`https://api.open-meteo.com/v1/forecast?${params}`); + if (!res.ok) return []; + + // biome-ignore lint/suspicious/noExplicitAny: Open-Meteo forecast response + const data = (await res.json()) as any; + const hourly = data?.hourly; + if (!hourly?.time || !Array.isArray(hourly.time)) return []; + + const times: string[] = hourly.time; + const probs: (number | null)[] = hourly.precipitation_probability ?? []; + const mms: (number | null)[] = hourly.precipitation ?? []; + + return times.map((iso, i) => ({ + iso, + precipitationProbability: typeof probs[i] === "number" ? probs[i] : null, + precipitationMm: typeof mms[i] === "number" ? mms[i] : null, + })); + } catch { + return []; + } +} + +// --------------------------------------------------------------------------- +// Pick the right hourly slot +// --------------------------------------------------------------------------- + +/** + * Find the hourly slot closest to `targetHHmm` (e.g. "08:12"). + * Open-Meteo returns times like "2026-02-22T08:00" so we match on the hour. + */ +function pickSlot(slots: HourlySlot[], targetHHmm: string): HourlySlot | null { + if (slots.length === 0) return null; + + const [hStr] = targetHHmm.split(":"); + const targetHour = Number(hStr ?? 0); + + // Find the slot whose hour matches (or is closest) + let best: HourlySlot | null = null; + let bestDiff = Number.MAX_SAFE_INTEGER; + + for (const slot of slots) { + // Open-Meteo returns "2026-02-22T08:00" — extract hour + const match = slot.iso.match(/T(\d{2})/); + if (!match) continue; + const slotHour = Number(match[1]); + const diff = Math.abs(slotHour - targetHour); + if (diff < bestDiff) { + bestDiff = diff; + best = slot; + } + } + + return best; +} + +// --------------------------------------------------------------------------- +// Umbrella decision +// --------------------------------------------------------------------------- + +function decideUmbrella( + slot: HourlySlot | null, + probThreshold: number, + mmThreshold: number, +): Pick< + WeatherInfo, + "umbrellaNeeded" | "reason" | "precipitationProbability" | "precipitationMm" +> { + if (!slot) { + return { + precipitationProbability: null, + precipitationMm: null, + umbrellaNeeded: false, + reason: "天気情報を取得できませんでした", + }; + } + + const prob = slot.precipitationProbability; + const mm = slot.precipitationMm; + + // Rule 1: precipitation probability + if (prob !== null && prob >= probThreshold) { + return { + precipitationProbability: prob, + precipitationMm: mm, + umbrellaNeeded: true, + reason: `降水確率 ${prob}% のため`, + }; + } + + // Rule 2: precipitation amount + if (mm !== null && mm >= mmThreshold) { + return { + precipitationProbability: prob, + precipitationMm: mm, + umbrellaNeeded: true, + reason: `雨量 ${mm}mm/h のため`, + }; + } + + // No rain expected + const parts: string[] = []; + if (prob !== null) parts.push(`降水確率 ${prob}%`); + if (mm !== null) parts.push(`雨量 ${mm}mm/h`); + const detail = parts.length > 0 ? parts.join("・") : "データなし"; + + return { + precipitationProbability: prob, + precipitationMm: mm, + umbrellaNeeded: false, + reason: `${detail} — 傘は不要`, + }; +} + +// --------------------------------------------------------------------------- +// Public API +// --------------------------------------------------------------------------- + +/** + * Get weather / umbrella info for a location at a given time. + * + * Never throws — returns a safe fallback on any error so the morning + * briefing is never disrupted by weather failures. + * + * @param location - Place name or address (used for geocoding). + * @param targetHHmm - "HH:mm" in JST to look up (e.g. departure time). Falls back to current hour. + * @param env - Worker env (optional overrides for lat/lon/thresholds). + */ +export async function getWeather( + location: string, + targetHHmm: string, + env?: Partial< + Pick< + Env, + | "WEATHER_DEFAULT_LAT" + | "WEATHER_DEFAULT_LON" + | "WEATHER_UMBRELLA_PROB_THRESHOLD" + | "WEATHER_UMBRELLA_MM_THRESHOLD" + > + >, +): Promise { + const probThreshold = env?.WEATHER_UMBRELLA_PROB_THRESHOLD + ? Number(env.WEATHER_UMBRELLA_PROB_THRESHOLD) + : DEFAULT_PROB_THRESHOLD; + const mmThreshold = env?.WEATHER_UMBRELLA_MM_THRESHOLD + ? Number(env.WEATHER_UMBRELLA_MM_THRESHOLD) + : DEFAULT_MM_THRESHOLD; + + // 1) Geocode the location + const geo = await geocode(location); + const lat = + geo?.lat ?? + (env?.WEATHER_DEFAULT_LAT ? Number(env.WEATHER_DEFAULT_LAT) : FALLBACK_LAT); + const lon = + geo?.lon ?? + (env?.WEATHER_DEFAULT_LON ? Number(env.WEATHER_DEFAULT_LON) : FALLBACK_LON); + const locationName = geo?.name ?? FALLBACK_NAME; + + // 2) Fetch hourly forecast + const slots = await fetchHourlyForecast(lat, lon); + + // 3) Pick the slot matching the target time + const slot = pickSlot(slots, targetHHmm); + + // 4) Decide + const decision = decideUmbrella(slot, probThreshold, mmThreshold); + + return { + locationName, + startIso: slot?.iso ?? "", + ...decision, + }; +} diff --git a/backend/src/features/weather/weather.types.ts b/backend/src/features/weather/weather.types.ts new file mode 100644 index 0000000..e683d20 --- /dev/null +++ b/backend/src/features/weather/weather.types.ts @@ -0,0 +1,15 @@ +/** Weather information for the umbrella decision. */ +export type WeatherInfo = { + /** Resolved location name (from geocoding, or fallback). */ + locationName: string; + /** ISO-8601 datetime of the hourly slot referenced. */ + startIso: string; + /** Precipitation probability 0–100, or null if unavailable. */ + precipitationProbability: number | null; + /** Precipitation in mm/h, or null if unavailable. */ + precipitationMm: number | null; + /** Whether an umbrella is recommended. */ + umbrellaNeeded: boolean; + /** Human-readable reason for the decision. */ + reason: string; +}; diff --git a/backend/src/routes/briefing-routes.ts b/backend/src/routes/briefing-routes.ts new file mode 100644 index 0000000..eac3432 --- /dev/null +++ b/backend/src/routes/briefing-routes.ts @@ -0,0 +1,49 @@ +import type { Context } from "hono"; +import { getMorningBriefing } from "../features/morning-briefing/morning-briefing.service"; +import { getAuthSession } from "../lib/session"; +import type { App } from "../types/app"; + +export function registerBriefingRoutes(app: App): void { + /** + * POST /briefing/morning + * + * Generate a full morning briefing by chaining: + * Google Calendar → Transit Directions → Late-risk engine + * + * Body: { currentLocation: string, prepMinutes?: number } + * + * Returns: MorningBriefingResult + */ + app.post("/briefing/morning", async (c: Context<{ Bindings: Env }>) => { + const session = await getAuthSession(c); + if (!session) { + return c.json({ error: "Authentication required." }, 401); + } + + const body = await c.req.json().catch(() => null); + if ( + !body || + typeof body.currentLocation !== "string" || + body.currentLocation.trim().length === 0 + ) { + return c.json( + { + error: + "Request body must include a non-empty `currentLocation` string.", + }, + 400, + ); + } + + const result = await getMorningBriefing(c.env, session.user.id, { + currentLocation: body.currentLocation.trim(), + prepMinutes: + typeof body.prepMinutes === "number" && body.prepMinutes > 0 + ? body.prepMinutes + : undefined, + forceRefresh: body.forceRefresh === true, + }); + + return c.json(result); + }); +} diff --git a/backend/src/routes/calendar-routes.ts b/backend/src/routes/calendar-routes.ts new file mode 100644 index 0000000..3fab702 --- /dev/null +++ b/backend/src/routes/calendar-routes.ts @@ -0,0 +1,21 @@ +import { getTodayEvents } from "../features/google-calendar/google-calendar.service"; +import { getAuthSession } from "../lib/session"; +import type { App } from "../types/app"; + +export function registerCalendarRoutes(app: App): void { + /** + * GET /calendar/today + * + * Returns today's Google Calendar events for the authenticated user. + * Requires a valid session cookie (Better Auth). + */ + app.get("/calendar/today", async (c) => { + const session = await getAuthSession(c); + if (!session) { + return c.json({ error: "Authentication required." }, 401); + } + + const result = await getTodayEvents(c.env, session.user.id); + return c.json(result); + }); +} diff --git a/backend/src/routes/root-routes.ts b/backend/src/routes/root-routes.ts index 059b221..86c5238 100644 --- a/backend/src/routes/root-routes.ts +++ b/backend/src/routes/root-routes.ts @@ -6,6 +6,9 @@ export function registerRootRoutes(app: App): void { service: "task-decomposer-backend", endpoints: [ "ALL /api/auth/*", + "POST /briefing/morning", + "GET /calendar/today", + "POST /transit/directions", "POST /tasks/decompose", "POST /workflows/decompose", "GET /workflows/history", diff --git a/backend/src/routes/transit-routes.ts b/backend/src/routes/transit-routes.ts new file mode 100644 index 0000000..b828f39 --- /dev/null +++ b/backend/src/routes/transit-routes.ts @@ -0,0 +1,54 @@ +import type { Context } from "hono"; +import { getTransitDirections } from "../features/transit/transit.service"; +import { getAuthSession } from "../lib/session"; +import type { App } from "../types/app"; + +export function registerTransitRoutes(app: App): void { + /** + * POST /transit/directions + * + * Look up transit directions between two points. + * Body: { origin: string, destination: string, arrivalTime?: string } + * + * `arrivalTime` is an ISO-8601 datetime. When provided the API will + * calculate routes that arrive by that time (useful for "what time should + * I leave to arrive at 09:00?"). + */ + app.post("/transit/directions", async (c: Context<{ Bindings: Env }>) => { + const session = await getAuthSession(c); + if (!session) { + return c.json({ error: "Authentication required." }, 401); + } + + const body = await c.req.json().catch(() => null); + if ( + !body || + typeof body.origin !== "string" || + typeof body.destination !== "string" || + body.origin.trim().length === 0 || + body.destination.trim().length === 0 + ) { + return c.json( + { + error: + "Request body must include non-empty `origin` and `destination` strings.", + }, + 400, + ); + } + + const apiKey = c.env.GOOGLE_MAPS_API_KEY; + if (!apiKey) { + return c.json({ error: "Transit service is not configured." }, 503); + } + + const result = await getTransitDirections(apiKey, { + origin: body.origin.trim(), + destination: body.destination.trim(), + arrivalTime: + typeof body.arrivalTime === "string" ? body.arrivalTime : undefined, + }); + + return c.json(result); + }); +} diff --git a/backend/src/types/env.d.ts b/backend/src/types/env.d.ts index 9b982b4..e287b92 100644 --- a/backend/src/types/env.d.ts +++ b/backend/src/types/env.d.ts @@ -1,10 +1,21 @@ interface Env { AUTH_DB: D1Database; + AI: Ai; GOOGLE_CLIENT_ID: string; GOOGLE_CLIENT_SECRET: string; + /** Google Maps Platform API key (Routes API). */ + GOOGLE_MAPS_API_KEY: string; BETTER_AUTH_SECRET: string; BETTER_AUTH_URL?: string; AUTH_COOKIE_PREFIX?: string; FRONTEND_ORIGINS?: string; AUTH_COOKIE_DOMAIN?: string; + /** Fallback latitude when geocoding fails (default: Kyoto Station). */ + WEATHER_DEFAULT_LAT?: string; + /** Fallback longitude when geocoding fails (default: Kyoto Station). */ + WEATHER_DEFAULT_LON?: string; + /** Precipitation probability threshold for umbrella (default: 50). */ + WEATHER_UMBRELLA_PROB_THRESHOLD?: string; + /** Precipitation mm/h threshold for umbrella (default: 0.2). */ + WEATHER_UMBRELLA_MM_THRESHOLD?: string; } diff --git a/frontend/src/app/api-test/page.tsx b/frontend/src/app/api-test/page.tsx new file mode 100644 index 0000000..d425dc2 --- /dev/null +++ b/frontend/src/app/api-test/page.tsx @@ -0,0 +1,349 @@ +"use client"; + +import { + Badge, + Box, + Button, + Code, + Container, + Heading, + HStack, + Input, + Stack, + Text, +} from "@chakra-ui/react"; +import { useCallback, useState } from "react"; +import { getSession, signInWithGoogle } from "@/lib/auth-api"; +import { + fetchCalendarToday, + fetchMorningBriefing, + fetchTransitDirections, +} from "@/lib/backend-api"; + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +type TestId = "session" | "calendar" | "transit" | "weather" | "briefing"; + +type TestResult = { + status: "idle" | "running" | "ok" | "error"; + data?: unknown; + error?: string; + ms?: number; +}; + +const initialResults: Record = { + session: { status: "idle" }, + calendar: { status: "idle" }, + transit: { status: "idle" }, + weather: { status: "idle" }, + briefing: { status: "idle" }, +}; + +// --------------------------------------------------------------------------- +// Component +// --------------------------------------------------------------------------- + +export default function ApiTestPage() { + const [results, setResults] = useState(initialResults); + const [origin, setOrigin] = useState("大阪駅"); + const [destination, setDestination] = useState("京都駅"); + const [briefingLocation, setBriefingLocation] = useState("大阪駅"); + + const run = useCallback(async (id: TestId, fn: () => Promise) => { + setResults((prev) => ({ + ...prev, + [id]: { status: "running" as const }, + })); + const t0 = performance.now(); + try { + const data = await fn(); + const ms = Math.round(performance.now() - t0); + setResults((prev) => ({ + ...prev, + [id]: { status: "ok" as const, data, ms }, + })); + } catch (e: unknown) { + const ms = Math.round(performance.now() - t0); + const error = e instanceof Error ? e.message : String(e); + setResults((prev) => ({ + ...prev, + [id]: { status: "error" as const, error, ms }, + })); + } + }, []); + + const statusColor = (s: TestResult["status"]) => { + if (s === "ok") return "green"; + if (s === "error") return "red"; + if (s === "running") return "yellow"; + return "gray"; + }; + + return ( + + + + + API 接続テスト + + 各APIを個別にテストして接続状態を確認できます。 + ログインしてからテストしてください。 + + + + {/* ---------------------------------------------------------- */} + {/* 1. Session */} + {/* ---------------------------------------------------------- */} + + + + + + + + {/* ---------------------------------------------------------- */} + {/* 2. Calendar */} + {/* ---------------------------------------------------------- */} + + + + + {/* ---------------------------------------------------------- */} + {/* 3. Transit (Routes API) */} + {/* ---------------------------------------------------------- */} + + + setOrigin(e.target.value)} + /> + + setDestination(e.target.value)} + /> + + + + + {/* ---------------------------------------------------------- */} + {/* 4. Weather (Open-Meteo) */} + {/* ---------------------------------------------------------- */} + + + ※ 天気は朝ブリーフィング内で取得されます。下の「5. + 朝ブリーフィング」で確認してください。 + + + + {/* ---------------------------------------------------------- */} + {/* 5. Morning Briefing (All combined) */} + {/* ---------------------------------------------------------- */} + + + setBriefingLocation(e.target.value)} + /> + + + + + {/* ---------------------------------------------------------- */} + {/* Summary */} + {/* ---------------------------------------------------------- */} + + + 接続状態まとめ + + {(Object.keys(results) as TestId[]).map((id) => ( + + {id}: {results[id].status} + {results[id].ms != null ? ` (${results[id].ms}ms)` : ""} + + ))} + + + + + + + ); +} + +// --------------------------------------------------------------------------- +// Sub-component: TestCard +// --------------------------------------------------------------------------- + +function TestCard({ + title, + description, + result, + statusColor, + children, +}: { + title: string; + description: string; + result: TestResult; + statusColor: (s: TestResult["status"]) => string; + children: React.ReactNode; +}) { + return ( + + + + + {title} + + {description} + + + + {result.status} + {result.ms != null ? ` ${result.ms}ms` : ""} + + + + {children} + + {result.status === "error" && ( + + + エラー + + + {result.error} + + + )} + + {result.status === "ok" && result.data != null && ( + + + レスポンス + + + {JSON.stringify(result.data, null, 2)} + + + )} + + + ); +} diff --git a/frontend/src/app/dashboard/page.tsx b/frontend/src/app/dashboard/page.tsx index 07fe8d6..886202b 100644 --- a/frontend/src/app/dashboard/page.tsx +++ b/frontend/src/app/dashboard/page.tsx @@ -1,346 +1,440 @@ "use client"; import { - Badge, Box, + Button, Container, - Flex, Grid, - Heading, HStack, + Input, Stack, Text, } from "@chakra-ui/react"; import { useEffect, useMemo, useState } from "react"; -import type { MorningDashboard } from "@/lib/morning-dashboard-api"; -import { getMorningDashboard } from "@/lib/morning-dashboard-api"; +import { fetchMorningBriefing } from "@/lib/backend-api"; -type LoadState = { +type BriefingEvent = { + id: string; + summary: string; + location: string | null; + start: string; + end: string; + isAllDay: boolean; +}; + +type TransitRoute = { + departureTime: string; + arrivalTime: string; + durationMinutes: number; + summary: string; +}; + +type EventBriefing = { + event: BriefingEvent; + destination: string; + route: TransitRoute | null; + transitMinutes: number; + leaveBy: string; + wakeUpBy: string; + slackMinutes: number; + lateRiskPercent: number; +}; + +type WeatherInfo = { + locationName: string; + startIso: string; + precipitationProbability: number; + precipitationMm: number; + umbrellaNeeded: boolean; + reason: string; +}; + +type MorningBriefingResult = { + date: string; + now: string; + totalEvents: number; + briefings: EventBriefing[]; + urgent: EventBriefing | null; + eventsWithoutLocation: BriefingEvent[]; + weather: WeatherInfo | null; +}; + +type State = { status: "loading" | "ready" | "error"; - data: MorningDashboard | null; + data: MorningBriefingResult | null; + errorType?: "unauthorized" | "unknown"; }; -function toClockString(baseTime: string, offsetMinutes: number): string { - const [hourText, minuteText] = baseTime.split(":"); - const hours = Number(hourText ?? 0); - const minutes = Number(minuteText ?? 0); - if (Number.isNaN(hours) || Number.isNaN(minutes)) { - return baseTime; - } +function toJstHHmm(iso: string): string { + const d = new Date(iso); + if (Number.isNaN(d.getTime())) return "--:--"; + const jst = new Date(d.getTime() + 9 * 60 * 60 * 1000); + const h = jst.getUTCHours().toString().padStart(2, "0"); + const m = jst.getUTCMinutes().toString().padStart(2, "0"); + return `${h}:${m}`; +} - const total = hours * 60 + minutes - offsetMinutes; - const normalized = ((total % 1440) + 1440) % 1440; - const displayHours = Math.floor(normalized / 60) - .toString() - .padStart(2, "0"); - const displayMinutes = (normalized % 60).toString().padStart(2, "0"); - return `${displayHours}:${displayMinutes}`; +function eventDurationMinutes(start: string, end: string): number { + const s = new Date(start); + const e = new Date(end); + if (Number.isNaN(s.getTime()) || Number.isNaN(e.getTime())) return 0; + return Math.max(0, Math.round((e.getTime() - s.getTime()) / 60000)); } export default function DashboardPage() { - const [state, setState] = useState({ + const [state, setState] = useState({ status: "loading", data: null, + errorType: undefined, }); + const [locationInput, setLocationInput] = useState("大阪駅"); + const [currentLocation, setCurrentLocation] = useState("大阪駅"); + const [forceRefresh, setForceRefresh] = useState(false); useEffect(() => { let active = true; + setState((prev) => ({ ...prev, status: "loading", errorType: undefined })); - getMorningDashboard() - .then((data) => { - if (!active) { - return; - } - setState({ status: "ready", data }); + fetchMorningBriefing(currentLocation, 30, forceRefresh) + .then((raw) => { + if (!active) return; + setState({ status: "ready", data: raw as MorningBriefingResult }); + setForceRefresh(false); }) - .catch(() => { - if (!active) { - return; - } - setState({ status: "error", data: null }); + .catch((error: unknown) => { + if (!active) return; + const message = error instanceof Error ? error.message : ""; + const isUnauthorized = + message.includes(" 401 ") || message.includes("401"); + setState({ + status: "error", + data: null, + errorType: isUnauthorized ? "unauthorized" : "unknown", + }); + setForceRefresh(false); }); return () => { active = false; }; - }, []); + }, [currentLocation, forceRefresh]); + + const urgent = state.data?.urgent ?? null; + const departure = urgent?.leaveBy ?? "--:--"; + const lateRisk = urgent?.lateRiskPercent ?? 0; + const slack = urgent?.slackMinutes ?? 0; + const transitSummary = urgent?.route?.summary ?? "経路情報なし"; + const transitMinutes = urgent?.transitMinutes ?? 0; + const weather = state.data?.weather ?? null; + + const todayEvents = useMemo(() => { + const byId = new Map(); - const routine = useMemo(() => { - if (!state.data) { - return []; + for (const b of state.data?.briefings ?? []) { + byId.set(b.event.id, b.event); } - return [...state.data.routine].sort( - (a, b) => b.offsetMinutes - a.offsetMinutes, - ); + for (const e of state.data?.eventsWithoutLocation ?? []) { + byId.set(e.id, e); + } + + return [...byId.values()] + .filter((e) => !e.isAllDay) + .sort( + (a, b) => new Date(a.start).getTime() - new Date(b.start).getTime(), + ); }, [state.data]); + const applyLocation = () => { + const next = locationInput.trim() || "大阪駅"; + setLocationInput(next); + setCurrentLocation(next); + setForceRefresh(true); + }; + return ( - - - - + + + + + setLocationInput(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") { + applyLocation(); + } + }} + flex="1" + minW={{ base: "140px", md: "240px" }} + bg="white" + borderColor="gray.300" + size="md" + placeholder="現在地(例: 大阪駅)" + /> + + - - - - - - Morning Flow - - - Dashboard - - - - 朝準備ダッシュボード - - - 出発時刻から逆算して、次にやることと残り時間を整える。 + + + 出発まで + + + {departure} + + + 遅刻リスク{" "} + = 60 ? "red.600" : "green.700"} + fontWeight="semibold" + > + {lateRisk}% + + + + 現在地: {currentLocation} - - - - - - 今日 - - - {state.data?.date ?? "----/--/--"} - - - - {state.status === "loading" - ? "読み込み中" - : state.status === "error" - ? "読み込み失敗" - : "同期済み"} - - - - + + 交通 + + + {transitMinutes} + - - - 起きる時間 - - - {state.data?.wakeUpTime ?? "--:--"} - - - - - 出発時間 - - - {state.data?.departTime ?? "--:--"} - - - - - 最初の予定 - - - {state.data?.earliestEvent?.title ?? "未登録"} - - - {state.data?.earliestEvent?.startTime ?? "--:--"}{" "} - {state.data?.earliestEvent?.location ?? ""} - - - + 分 + + + + {transitSummary} + + + 定刻通り + + - + + 余裕 + + + {Math.max(0, slack)} + - - ルーティン概要 - - - {state.data?.routine.length ?? 0} ステップを自動配置 - - - 更新: {state.data?.updatedAt ?? "--"} - - - - - - - - 今日の変更 - {state.data?.overrides?.length ? ( - state.data.overrides.map((override) => ( - - - {override.date} - - - {override.note ?? "特記事項なし"} - - - {override.steps.map((step) => ( - - {step.label} - - 追加 - - - ))} - - - )) - ) : ( - - 今日は標準ルーティンで運用中。 - - )} - - + 分 + + + + {departure}に出れば + + + + + - - - - - タイムライン - - - 出発までの逆算 - - - - {routine.length === 0 ? ( - state.status === "error" ? ( - - タイムラインの取得に失敗しました。 - - ) : ( - データを読み込んでいます。 - ) + + + 今日の予定 + + + {todayEvents.length === 0 ? ( + + 予定はありません + ) : ( - - {routine.map((step) => ( - - - ( + + + + + {toJstHHmm(event.start)} + - {step.isOverride ? "変更" : "基本"} - - - {step.label} + {event.summary} - - - {state.data - ? toClockString( - state.data.departTime, - step.offsetMinutes, - ) - : "--:--"} - - ))} + + {event.location ?? "場所未設定"} /{" "} + {eventDurationMinutes(event.start, event.end)}分 + + + + )) + )} + + + + + + 天気 + + + + + {weather?.umbrellaNeeded ? "☔" : "☀️"} + + + + {weather ? `${weather.precipitationProbability}%` : "--%"} + + + {weather?.umbrellaNeeded ? "傘あり" : "晴れ"} + + + + + + 降水量 {weather ? `${weather.precipitationMm} mm/h` : "--"} + + {weather?.locationName ?? "-"} + + {weather?.reason ?? "天気情報なし"} + + + + + + {state.status === "error" && ( + + + {state.errorType === "unauthorized" + ? "ログインが必要です。" + : "API取得に失敗しました。ログイン状態とバックエンド起動を確認してください。"} + + {state.errorType === "unauthorized" && ( + )} - + )} ); } + +function Card({ + children, + minH, +}: { + children: React.ReactNode; + minH?: string | { base: string; md: string }; +}) { + return ( + + {children} + + ); +} 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 ( - -
- - - 細分化したいタスク -