From e35ff8f70505df3087689275ac974f722518f04d Mon Sep 17 00:00:00 2001 From: ikotome Date: Sat, 21 Feb 2026 21:27:46 +0900 Subject: [PATCH 01/37] feat: add morning briefing and transit direction features with Google Calendar integration --- backend/src/app.ts | 6 + .../google-calendar.service.ts | 169 +++++++++++++++ .../google-calendar/google-calendar.types.ts | 21 ++ .../morning-briefing.service.ts | 202 ++++++++++++++++++ .../morning-briefing.types.ts | 73 +++++++ .../src/features/transit/transit.service.ts | 101 +++++++++ backend/src/features/transit/transit.types.ts | 47 ++++ backend/src/routes/briefing-routes.ts | 48 +++++ backend/src/routes/calendar-routes.ts | 21 ++ backend/src/routes/root-routes.ts | 3 + backend/src/routes/transit-routes.ts | 54 +++++ backend/src/types/env.d.ts | 3 + 12 files changed, 748 insertions(+) create mode 100644 backend/src/features/google-calendar/google-calendar.service.ts create mode 100644 backend/src/features/google-calendar/google-calendar.types.ts create mode 100644 backend/src/features/morning-briefing/morning-briefing.service.ts create mode 100644 backend/src/features/morning-briefing/morning-briefing.types.ts create mode 100644 backend/src/features/transit/transit.service.ts create mode 100644 backend/src/features/transit/transit.types.ts create mode 100644 backend/src/routes/briefing-routes.ts create mode 100644 backend/src/routes/calendar-routes.ts create mode 100644 backend/src/routes/transit-routes.ts 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..b591b47 --- /dev/null +++ b/backend/src/features/google-calendar/google-calendar.service.ts @@ -0,0 +1,169 @@ +import { createAuth } from "../../lib/auth"; +import type { CalendarEvent, TodayEventsResult } from "./google-calendar.types"; + +// --------------------------------------------------------------------------- +// Token helpers +// --------------------------------------------------------------------------- + +type RefreshedToken = { access_token: string; expires_in: number }; + +async function refreshGoogleAccessToken( + clientId: string, + clientSecret: string, + refreshToken: string, +): Promise { + const res = await fetch("https://oauth2.googleapis.com/token", { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body: new URLSearchParams({ + client_id: clientId, + client_secret: clientSecret, + refresh_token: refreshToken, + grant_type: "refresh_token", + }), + }); + + if (!res.ok) { + console.error("Google token refresh failed:", res.status, await res.text()); + return null; + } + + const data = (await res.json()) as Record; + if (typeof data.access_token !== "string" || typeof data.expires_in !== "number") { + return null; + } + return { access_token: data.access_token, expires_in: data.expires_in }; +} + +/** + * Retrieve a valid Google access token for the given user. + * + * Better Auth stores OAuth tokens (optionally encrypted) in the `account` + * table. We use Better Auth's **internal adapter** so that encryption / + * decryption is handled transparently. + */ +async function getGoogleAccessToken( + env: Env, + userId: string, +): Promise { + const auth = createAuth(env); + // biome-ignore lint/suspicious/noExplicitAny: accessing Better Auth internals + const ctx = await (auth as any).$context; + if (!ctx?.internalAdapter) { + console.error("Could not obtain Better Auth internal adapter"); + return null; + } + + const accounts: Array> = + await ctx.internalAdapter.findAccounts(userId); + const google = accounts.find((a) => a.providerId === "google"); + if (!google) return null; + + const accessToken = typeof google.accessToken === "string" ? google.accessToken : null; + const refreshToken = typeof google.refreshToken === "string" ? google.refreshToken : null; + + // Check whether the current access token is still valid. + const expiresAt = + google.accessTokenExpiresAt instanceof Date + ? google.accessTokenExpiresAt + : typeof google.accessTokenExpiresAt === "string" + ? new Date(google.accessTokenExpiresAt) + : null; + + const isExpired = expiresAt ? expiresAt.getTime() < Date.now() : true; + + if (!isExpired && accessToken) { + return accessToken; + } + + // Token is expired (or missing) — try to refresh. + if (!refreshToken) return null; + + const refreshed = await refreshGoogleAccessToken( + env.GOOGLE_CLIENT_ID, + env.GOOGLE_CLIENT_SECRET, + refreshToken, + ); + if (!refreshed) return null; + + // Persist the refreshed token (Better Auth encrypts transparently). + try { + await ctx.internalAdapter.updateAccount( + google.id as string, + { + accessToken: refreshed.access_token, + accessTokenExpiresAt: new Date(Date.now() + refreshed.expires_in * 1000), + }, + ); + } catch (e) { + console.error("Failed to persist refreshed token:", e); + // We still got a valid token — continue. + } + + return refreshed.access_token; +} + +// --------------------------------------------------------------------------- +// 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; + + 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..0a55933 --- /dev/null +++ b/backend/src/features/morning-briefing/morning-briefing.service.ts @@ -0,0 +1,202 @@ +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 type { + EventBriefing, + MorningBriefingRequest, + MorningBriefingResult, +} from "./morning-briefing.types"; + +// --------------------------------------------------------------------------- +// 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 +// --------------------------------------------------------------------------- + +/** + * 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 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; + + return { + date: dateStr, + now: nowHHmm, + totalEvents: calendar.events.length, + briefings, + urgent, + eventsWithoutLocation: withoutLocation, + }; +} 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..3c3c592 --- /dev/null +++ b/backend/src/features/morning-briefing/morning-briefing.types.ts @@ -0,0 +1,73 @@ +import type { CalendarEvent } from "../google-calendar/google-calendar.types"; +import type { TransitRoute, TransitStep } from "../transit/transit.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; +}; + +// --------------------------------------------------------------------------- +// 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[]; +}; diff --git a/backend/src/features/transit/transit.service.ts b/backend/src/features/transit/transit.service.ts new file mode 100644 index 0000000..2c36790 --- /dev/null +++ b/backend/src/features/transit/transit.service.ts @@ -0,0 +1,101 @@ +import type { + TransitQuery, + TransitResult, + TransitRoute, + TransitStep, +} from "./transit.types"; + +/** + * Look up transit directions between two points via the + * Google Directions API (`mode=transit`). + * + * Cost: covered by the $200/month free Google Maps Platform credit. + * + * @param apiKey - Google Maps Platform API key (server-side, restricted to Directions API). + * @param query - Origin / destination / optional arrival time. + */ +export async function getTransitDirections( + apiKey: string, + query: TransitQuery, +): Promise { + const params = new URLSearchParams({ + origin: query.origin, + destination: query.destination, + mode: "transit", + language: "ja", + region: "jp", + alternatives: "true", + key: apiKey, + }); + + if (query.arrivalTime) { + const arrivalTs = Math.floor( + new Date(query.arrivalTime).getTime() / 1000, + ); + params.set("arrival_time", arrivalTs.toString()); + } + + const res = await fetch( + `https://maps.googleapis.com/maps/api/directions/json?${params}`, + ); + + if (!res.ok) { + console.error("Directions API HTTP error:", res.status); + return { routes: [], bestRoute: null }; + } + + // biome-ignore lint/suspicious/noExplicitAny: Google Directions API response + const data = (await res.json()) as any; + + if (data.status !== "OK") { + console.error("Directions API status:", data.status, data.error_message); + return { routes: [], bestRoute: null }; + } + + const routes: TransitRoute[] = (data.routes ?? []) + // biome-ignore lint/suspicious/noExplicitAny: Google Directions route + .map((route: any): TransitRoute | null => { + const leg = route.legs?.[0]; + if (!leg) return null; + + // biome-ignore lint/suspicious/noExplicitAny: Google Directions step + const steps: TransitStep[] = (leg.steps ?? []).map((step: any) => { + const td = step.transit_details; + return { + mode: (step.travel_mode as string) ?? "UNKNOWN", + instruction: + (step.html_instructions as string)?.replace(/<[^>]*>/g, "") ?? "", + durationMinutes: Math.ceil( + ((step.duration?.value as number) ?? 0) / 60, + ), + transitDetails: td + ? { + line: + (td.line?.short_name as string) ?? + (td.line?.name as string) ?? + "", + departureStop: (td.departure_stop?.name as string) ?? "", + arrivalStop: (td.arrival_stop?.name as string) ?? "", + numStops: (td.num_stops as number) ?? 0, + } + : undefined, + } satisfies TransitStep; + }); + + return { + departureTime: (leg.departure_time?.text as string) ?? "", + arrivalTime: (leg.arrival_time?.text as string) ?? "", + durationMinutes: Math.ceil( + ((leg.duration?.value as number) ?? 0) / 60, + ), + summary: (route.summary as string) ?? "", + 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/routes/briefing-routes.ts b/backend/src/routes/briefing-routes.ts new file mode 100644 index 0000000..66b6935 --- /dev/null +++ b/backend/src/routes/briefing-routes.ts @@ -0,0 +1,48 @@ +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, + }); + + 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 a08cfe8..8492151 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/:id", 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 1eda678..e15f0ce 100644 --- a/backend/src/types/env.d.ts +++ b/backend/src/types/env.d.ts @@ -1,7 +1,10 @@ interface Env { AUTH_DB: D1Database; + AI: Ai; GOOGLE_CLIENT_ID: string; GOOGLE_CLIENT_SECRET: string; + /** Google Maps Platform API key (Directions API). */ + GOOGLE_MAPS_API_KEY: string; BETTER_AUTH_SECRET: string; BETTER_AUTH_URL?: string; FRONTEND_ORIGINS?: string; From e1d1fc9edeebd22165cd97f7f8f49471da9350b9 Mon Sep 17 00:00:00 2001 From: ikotome Date: Sun, 22 Feb 2026 04:32:36 +0900 Subject: [PATCH 02/37] feat(weather): integrate weather information for morning briefing and umbrella decision --- .../morning-briefing.service.ts | 14 + .../morning-briefing.types.ts | 3 + .../src/features/weather/weather.service.ts | 250 ++++++++++++++++++ backend/src/features/weather/weather.types.ts | 15 ++ backend/src/types/env.d.ts | 8 + 5 files changed, 290 insertions(+) create mode 100644 backend/src/features/weather/weather.service.ts create mode 100644 backend/src/features/weather/weather.types.ts diff --git a/backend/src/features/morning-briefing/morning-briefing.service.ts b/backend/src/features/morning-briefing/morning-briefing.service.ts index 0a55933..9850ed8 100644 --- a/backend/src/features/morning-briefing/morning-briefing.service.ts +++ b/backend/src/features/morning-briefing/morning-briefing.service.ts @@ -2,6 +2,8 @@ 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, @@ -191,6 +193,17 @@ export async function getMorningBriefing( // The first (earliest) briefing is the most urgent const urgent = briefings.length > 0 ? briefings[0]! : 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, @@ -198,5 +211,6 @@ export async function getMorningBriefing( briefings, urgent, eventsWithoutLocation: withoutLocation, + weather, }; } diff --git a/backend/src/features/morning-briefing/morning-briefing.types.ts b/backend/src/features/morning-briefing/morning-briefing.types.ts index 3c3c592..a3a8a63 100644 --- a/backend/src/features/morning-briefing/morning-briefing.types.ts +++ b/backend/src/features/morning-briefing/morning-briefing.types.ts @@ -1,5 +1,6 @@ import type { CalendarEvent } from "../google-calendar/google-calendar.types"; import type { TransitRoute, TransitStep } from "../transit/transit.types"; +import type { WeatherInfo } from "../weather/weather.types"; // --------------------------------------------------------------------------- // Request @@ -70,4 +71,6 @@ export type MorningBriefingResult = { 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/weather/weather.service.ts b/backend/src/features/weather/weather.service.ts new file mode 100644 index 0000000..77c21e1 --- /dev/null +++ b/backend/src/features/weather/weather.service.ts @@ -0,0 +1,250 @@ +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 { + 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>, +): 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/types/env.d.ts b/backend/src/types/env.d.ts index 768bb8f..5b9865d 100644 --- a/backend/src/types/env.d.ts +++ b/backend/src/types/env.d.ts @@ -10,4 +10,12 @@ interface Env { 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; } From f7f63777a6e291b190071d54e2d5c83e65adb741 Mon Sep 17 00:00:00 2001 From: ikotome Date: Sun, 22 Feb 2026 05:04:35 +0900 Subject: [PATCH 03/37] feat(transit): update to use Google Routes API and enhance transit direction handling --- .../src/features/transit/transit.service.ts | 153 +++++++++++++----- backend/src/types/env.d.ts | 2 +- 2 files changed, 115 insertions(+), 40 deletions(-) diff --git a/backend/src/features/transit/transit.service.ts b/backend/src/features/transit/transit.service.ts index 2c36790..6b5cfaa 100644 --- a/backend/src/features/transit/transit.service.ts +++ b/backend/src/features/transit/transit.service.ts @@ -5,90 +5,165 @@ import type { TransitStep, } from "./transit.types"; +// --------------------------------------------------------------------------- +// Routes API 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.legs.duration", + "routes.legs.steps.transitDetails", + "routes.legs.steps.travelMode", + "routes.legs.steps.navigationInstruction", + "routes.legs.steps.localizedValues", + "routes.legs.departureTime", + "routes.legs.arrivalTime", + "routes.localizedValues", +].join(","); + +/** Format an ISO-8601 datetime to RFC 3339 (what Routes API expects). */ +function toRfc3339(iso: string): string { + const d = new Date(iso); + return Number.isNaN(d.getTime()) ? iso : d.toISOString(); +} + +/** "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; +} + +/** Format a datetime string to "H:mm" display (JST). */ +function toDisplayTime(iso: string | undefined): string { + if (!iso) return ""; + const d = new Date(iso); + if (Number.isNaN(d.getTime())) return ""; + // JST = UTC+9 + const jst = new Date(d.getTime() + 9 * 60 * 60 * 1000); + const h = jst.getUTCHours(); + const m = jst.getUTCMinutes().toString().padStart(2, "0"); + return `${h}:${m}`; +} + +// --------------------------------------------------------------------------- +// Public +// --------------------------------------------------------------------------- + /** - * Look up transit directions between two points via the - * Google Directions API (`mode=transit`). + * Look up transit directions via the **Google Routes API** + * (`routes.googleapis.com`). * + * This is the successor to the legacy Directions API. * Cost: covered by the $200/month free Google Maps Platform credit. * - * @param apiKey - Google Maps Platform API key (server-side, restricted to Directions API). + * @see https://developers.google.com/maps/documentation/routes/compute_route_directions + * + * @param apiKey - Google Maps Platform API key (restricted to Routes API). * @param query - Origin / destination / optional arrival time. */ export async function getTransitDirections( apiKey: string, query: TransitQuery, ): Promise { - const params = new URLSearchParams({ - origin: query.origin, - destination: query.destination, - mode: "transit", - language: "ja", - region: "jp", - alternatives: "true", - key: apiKey, - }); + // biome-ignore lint/suspicious/noExplicitAny: Routes API request body + const body: Record = { + origin: { + address: query.origin, + }, + destination: { + address: query.destination, + }, + travelMode: "TRANSIT", + languageCode: "ja", + regionCode: "JP", + computeAlternativeRoutes: true, + }; if (query.arrivalTime) { - const arrivalTs = Math.floor( - new Date(query.arrivalTime).getTime() / 1000, - ); - params.set("arrival_time", arrivalTs.toString()); + body.arrivalTime = toRfc3339(query.arrivalTime); } - const res = await fetch( - `https://maps.googleapis.com/maps/api/directions/json?${params}`, - ); + 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("Directions API HTTP error:", res.status); + console.error("Routes API HTTP error:", res.status, await res.text()); return { routes: [], bestRoute: null }; } - // biome-ignore lint/suspicious/noExplicitAny: Google Directions API response + // biome-ignore lint/suspicious/noExplicitAny: Routes API response const data = (await res.json()) as any; - if (data.status !== "OK") { - console.error("Directions API status:", data.status, data.error_message); + if (!data.routes || !Array.isArray(data.routes)) { return { routes: [], bestRoute: null }; } - const routes: TransitRoute[] = (data.routes ?? []) - // biome-ignore lint/suspicious/noExplicitAny: Google Directions route + 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; - // biome-ignore lint/suspicious/noExplicitAny: Google Directions step + // biome-ignore lint/suspicious/noExplicitAny: Routes API step const steps: TransitStep[] = (leg.steps ?? []).map((step: any) => { - const td = step.transit_details; + const td = step.transitDetails; + const mode: string = (step.travelMode as string) ?? "UNKNOWN"; + return { - mode: (step.travel_mode as string) ?? "UNKNOWN", + mode, instruction: - (step.html_instructions as string)?.replace(/<[^>]*>/g, "") ?? "", + (step.navigationInstruction?.instructions as string)?.replace( + /<[^>]*>/g, + "", + ) ?? "", durationMinutes: Math.ceil( - ((step.duration?.value as number) ?? 0) / 60, + parseDurationSeconds(step.staticDuration ?? leg.duration) / 60, ), transitDetails: td ? { line: - (td.line?.short_name as string) ?? - (td.line?.name as string) ?? + (td.transitLine?.nameShort as string) ?? + (td.transitLine?.name as string) ?? "", - departureStop: (td.departure_stop?.name as string) ?? "", - arrivalStop: (td.arrival_stop?.name as string) ?? "", - numStops: (td.num_stops as number) ?? 0, + departureStop: (td.stopDetails?.departureStop?.name as string) ?? "", + arrivalStop: (td.stopDetails?.arrivalStop?.name as string) ?? "", + numStops: (td.stopCount as number) ?? 0, } : undefined, } satisfies TransitStep; }); return { - departureTime: (leg.departure_time?.text as string) ?? "", - arrivalTime: (leg.arrival_time?.text as string) ?? "", + departureTime: + toDisplayTime(leg.departureTime) || + ((leg.localizedValues?.departureTime?.text as string) ?? ""), + arrivalTime: + toDisplayTime(leg.arrivalTime) || + ((leg.localizedValues?.arrivalTime?.text as string) ?? ""), durationMinutes: Math.ceil( - ((leg.duration?.value as number) ?? 0) / 60, + parseDurationSeconds(route.duration ?? leg.duration) / 60, ), - summary: (route.summary as string) ?? "", + summary: + (route.description as string) ?? + (route.localizedValues?.duration?.text as string) ?? + "", steps, }; }) diff --git a/backend/src/types/env.d.ts b/backend/src/types/env.d.ts index 5b9865d..e287b92 100644 --- a/backend/src/types/env.d.ts +++ b/backend/src/types/env.d.ts @@ -3,7 +3,7 @@ interface Env { AI: Ai; GOOGLE_CLIENT_ID: string; GOOGLE_CLIENT_SECRET: string; - /** Google Maps Platform API key (Directions API). */ + /** Google Maps Platform API key (Routes API). */ GOOGLE_MAPS_API_KEY: string; BETTER_AUTH_SECRET: string; BETTER_AUTH_URL?: string; From 2a5a9b1ab1e197eb9620184d3bf3582b912b0f7e Mon Sep 17 00:00:00 2001 From: ikotome Date: Sun, 22 Feb 2026 05:22:06 +0900 Subject: [PATCH 04/37] feat(api): add API helpers for calendar, transit, and morning briefing --- frontend/src/app/api-test/page.tsx | 352 +++++++++++++++++++++++++++++ frontend/src/lib/backend-api.ts | 79 +++++++ 2 files changed, 431 insertions(+) create mode 100644 frontend/src/app/api-test/page.tsx create mode 100644 frontend/src/lib/backend-api.ts diff --git a/frontend/src/app/api-test/page.tsx b/frontend/src/app/api-test/page.tsx new file mode 100644 index 0000000..3483126 --- /dev/null +++ b/frontend/src/app/api-test/page.tsx @@ -0,0 +1,352 @@ +"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/lib/backend-api.ts b/frontend/src/lib/backend-api.ts new file mode 100644 index 0000000..7bb8452 --- /dev/null +++ b/frontend/src/lib/backend-api.ts @@ -0,0 +1,79 @@ +"use client"; + +/** + * Lightweight helpers for calling backend API endpoints. + * Reuses the same base-URL resolution logic as auth-api.ts. + */ + +const defaultLocalApiBaseUrl = "http://localhost:8787"; + +function resolveApiBaseUrl(): string { + const value = process.env.NEXT_PUBLIC_API_BASE_URL?.trim(); + if (value && value.length > 0) return value; + + if (typeof window !== "undefined") { + const { hostname } = window.location; + if ( + hostname === "localhost" || + hostname === "127.0.0.1" || + hostname === "::1" + ) { + return defaultLocalApiBaseUrl; + } + } + + return defaultLocalApiBaseUrl; +} + +function endpoint(path: string): string { + return `${resolveApiBaseUrl()}${path}`; +} + +// --------------------------------------------------------------------------- +// Calendar +// --------------------------------------------------------------------------- + +export async function fetchCalendarToday(): Promise { + const res = await fetch(endpoint("/calendar/today"), { + credentials: "include", + }); + if (!res.ok) throw new Error(`Calendar API: ${res.status} ${await res.text()}`); + return res.json(); +} + +// --------------------------------------------------------------------------- +// Transit (Routes API) +// --------------------------------------------------------------------------- + +export async function fetchTransitDirections( + origin: string, + destination: string, + arrivalTime?: string, +): Promise { + const res = await fetch(endpoint("/transit/directions"), { + method: "POST", + credentials: "include", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ origin, destination, arrivalTime }), + }); + if (!res.ok) throw new Error(`Transit API: ${res.status} ${await res.text()}`); + return res.json(); +} + +// --------------------------------------------------------------------------- +// Morning Briefing (Calendar + Transit + Weather) +// --------------------------------------------------------------------------- + +export async function fetchMorningBriefing( + currentLocation: string, + prepMinutes?: number, +): Promise { + const res = await fetch(endpoint("/briefing/morning"), { + method: "POST", + credentials: "include", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ currentLocation, prepMinutes }), + }); + if (!res.ok) throw new Error(`Briefing API: ${res.status} ${await res.text()}`); + return res.json(); +} From 2bd8961b0bf2bd4cfe8e8ceaf810d57f31859320 Mon Sep 17 00:00:00 2001 From: ikotome Date: Sun, 22 Feb 2026 05:33:07 +0900 Subject: [PATCH 05/37] refactor: improve code formatting and readability across multiple services --- .../google-calendar.service.ts | 24 +++---- .../morning-briefing.service.ts | 12 +++- .../morning-briefing.types.ts | 2 +- .../src/features/transit/transit.service.ts | 6 +- .../src/features/weather/weather.service.ts | 35 +++++++---- frontend/src/app/api-test/page.tsx | 63 +++++++++---------- frontend/src/lib/backend-api.ts | 9 ++- 7 files changed, 85 insertions(+), 66 deletions(-) diff --git a/backend/src/features/google-calendar/google-calendar.service.ts b/backend/src/features/google-calendar/google-calendar.service.ts index b591b47..2349234 100644 --- a/backend/src/features/google-calendar/google-calendar.service.ts +++ b/backend/src/features/google-calendar/google-calendar.service.ts @@ -29,7 +29,10 @@ async function refreshGoogleAccessToken( } const data = (await res.json()) as Record; - if (typeof data.access_token !== "string" || typeof data.expires_in !== "number") { + if ( + typeof data.access_token !== "string" || + typeof data.expires_in !== "number" + ) { return null; } return { access_token: data.access_token, expires_in: data.expires_in }; @@ -59,8 +62,10 @@ async function getGoogleAccessToken( const google = accounts.find((a) => a.providerId === "google"); if (!google) return null; - const accessToken = typeof google.accessToken === "string" ? google.accessToken : null; - const refreshToken = typeof google.refreshToken === "string" ? google.refreshToken : null; + const accessToken = + typeof google.accessToken === "string" ? google.accessToken : null; + const refreshToken = + typeof google.refreshToken === "string" ? google.refreshToken : null; // Check whether the current access token is still valid. const expiresAt = @@ -88,13 +93,10 @@ async function getGoogleAccessToken( // Persist the refreshed token (Better Auth encrypts transparently). try { - await ctx.internalAdapter.updateAccount( - google.id as string, - { - accessToken: refreshed.access_token, - accessTokenExpiresAt: new Date(Date.now() + refreshed.expires_in * 1000), - }, - ); + await ctx.internalAdapter.updateAccount(google.id as string, { + accessToken: refreshed.access_token, + accessTokenExpiresAt: new Date(Date.now() + refreshed.expires_in * 1000), + }); } catch (e) { console.error("Failed to persist refreshed token:", e); // We still got a valid token — continue. @@ -163,7 +165,7 @@ export async function getTodayEvents( })); const timedEvents = events.filter((e) => !e.isAllDay); - const earliestEvent = timedEvents.length > 0 ? timedEvents[0]! : null; + const earliestEvent = timedEvents.length > 0 ? (timedEvents[0] ?? null) : null; return { date, events, earliestEvent }; } diff --git a/backend/src/features/morning-briefing/morning-briefing.service.ts b/backend/src/features/morning-briefing/morning-briefing.service.ts index 9850ed8..96952b2 100644 --- a/backend/src/features/morning-briefing/morning-briefing.service.ts +++ b/backend/src/features/morning-briefing/morning-briefing.service.ts @@ -38,7 +38,7 @@ function toJstMinutes(iso: string): number { /** 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 total = (((jstMinOfDay - minutes) % 1440) + 1440) % 1440; const h = Math.floor(total / 60) .toString() .padStart(2, "0"); @@ -169,7 +169,13 @@ export async function getMorningBriefing( const briefings: EventBriefing[] = apiKey ? await Promise.all( withLocation.map((event) => - buildEventBriefing(apiKey, req.currentLocation, event, prepMinutes, nowMinutes), + buildEventBriefing( + apiKey, + req.currentLocation, + event, + prepMinutes, + nowMinutes, + ), ), ) : withLocation.map((event) => ({ @@ -191,7 +197,7 @@ export async function getMorningBriefing( }); // The first (earliest) briefing is the most urgent - const urgent = briefings.length > 0 ? briefings[0]! : null; + 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; diff --git a/backend/src/features/morning-briefing/morning-briefing.types.ts b/backend/src/features/morning-briefing/morning-briefing.types.ts index a3a8a63..e4f8622 100644 --- a/backend/src/features/morning-briefing/morning-briefing.types.ts +++ b/backend/src/features/morning-briefing/morning-briefing.types.ts @@ -1,5 +1,5 @@ import type { CalendarEvent } from "../google-calendar/google-calendar.types"; -import type { TransitRoute, TransitStep } from "../transit/transit.types"; +import type { TransitRoute } from "../transit/transit.types"; import type { WeatherInfo } from "../weather/weather.types"; // --------------------------------------------------------------------------- diff --git a/backend/src/features/transit/transit.service.ts b/backend/src/features/transit/transit.service.ts index 6b5cfaa..7517fd4 100644 --- a/backend/src/features/transit/transit.service.ts +++ b/backend/src/features/transit/transit.service.ts @@ -142,8 +142,10 @@ export async function getTransitDirections( (td.transitLine?.nameShort as string) ?? (td.transitLine?.name as string) ?? "", - departureStop: (td.stopDetails?.departureStop?.name as string) ?? "", - arrivalStop: (td.stopDetails?.arrivalStop?.name as string) ?? "", + departureStop: + (td.stopDetails?.departureStop?.name as string) ?? "", + arrivalStop: + (td.stopDetails?.arrivalStop?.name as string) ?? "", numStops: (td.stopCount as number) ?? 0, } : undefined, diff --git a/backend/src/features/weather/weather.service.ts b/backend/src/features/weather/weather.service.ts index 77c21e1..e68447f 100644 --- a/backend/src/features/weather/weather.service.ts +++ b/backend/src/features/weather/weather.service.ts @@ -85,9 +85,7 @@ async function fetchHourlyForecast( timezone: "Asia/Tokyo", forecast_days: "1", }); - const res = await fetch( - `https://api.open-meteo.com/v1/forecast?${params}`, - ); + 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 @@ -101,8 +99,7 @@ async function fetchHourlyForecast( return times.map((iso, i) => ({ iso, - precipitationProbability: - typeof probs[i] === "number" ? probs[i] : null, + precipitationProbability: typeof probs[i] === "number" ? probs[i] : null, precipitationMm: typeof mms[i] === "number" ? mms[i] : null, })); } catch { @@ -118,10 +115,7 @@ async function fetchHourlyForecast( * 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 { +function pickSlot(slots: HourlySlot[], targetHHmm: string): HourlySlot | null { if (slots.length === 0) return null; const [hStr] = targetHHmm.split(":"); @@ -154,7 +148,10 @@ function decideUmbrella( slot: HourlySlot | null, probThreshold: number, mmThreshold: number, -): Pick { +): Pick< + WeatherInfo, + "umbrellaNeeded" | "reason" | "precipitationProbability" | "precipitationMm" +> { if (!slot) { return { precipitationProbability: null, @@ -218,7 +215,15 @@ function decideUmbrella( export async function getWeather( location: string, targetHHmm: string, - env?: Partial>, + 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) @@ -229,8 +234,12 @@ export async function getWeather( // 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 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 diff --git a/frontend/src/app/api-test/page.tsx b/frontend/src/app/api-test/page.tsx index 3483126..d425dc2 100644 --- a/frontend/src/app/api-test/page.tsx +++ b/frontend/src/app/api-test/page.tsx @@ -24,12 +24,7 @@ import { // Types // --------------------------------------------------------------------------- -type TestId = - | "session" - | "calendar" - | "transit" - | "weather" - | "briefing"; +type TestId = "session" | "calendar" | "transit" | "weather" | "briefing"; type TestResult = { status: "idle" | "running" | "ok" | "error"; @@ -56,31 +51,28 @@ export default function ApiTestPage() { const [destination, setDestination] = useState("京都駅"); const [briefingLocation, setBriefingLocation] = useState("大阪駅"); - const run = useCallback( - async (id: TestId, fn: () => Promise) => { + 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: "running" as const }, + [id]: { status: "ok" as const, data, ms }, })); - 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 }, - })); - } - }, - [], - ); + } 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"; @@ -198,7 +190,8 @@ export default function ApiTestPage() { statusColor={statusColor} > - ※ 天気は朝ブリーフィング内で取得されます。下の「5. 朝ブリーフィング」で確認してください。 + ※ 天気は朝ブリーフィング内で取得されます。下の「5. + 朝ブリーフィング」で確認してください。 @@ -223,9 +216,7 @@ export default function ApiTestPage() { size="sm" colorPalette="orange" onClick={() => - run("briefing", () => - fetchMorningBriefing(briefingLocation), - ) + run("briefing", () => fetchMorningBriefing(briefingLocation)) } disabled={results.briefing.status === "running"} > @@ -328,7 +319,13 @@ function TestCard({ )} {result.status === "ok" && result.data != null && ( - + レスポンス diff --git a/frontend/src/lib/backend-api.ts b/frontend/src/lib/backend-api.ts index 7bb8452..7ad505f 100644 --- a/frontend/src/lib/backend-api.ts +++ b/frontend/src/lib/backend-api.ts @@ -37,7 +37,8 @@ export async function fetchCalendarToday(): Promise { const res = await fetch(endpoint("/calendar/today"), { credentials: "include", }); - if (!res.ok) throw new Error(`Calendar API: ${res.status} ${await res.text()}`); + if (!res.ok) + throw new Error(`Calendar API: ${res.status} ${await res.text()}`); return res.json(); } @@ -56,7 +57,8 @@ export async function fetchTransitDirections( headers: { "Content-Type": "application/json" }, body: JSON.stringify({ origin, destination, arrivalTime }), }); - if (!res.ok) throw new Error(`Transit API: ${res.status} ${await res.text()}`); + if (!res.ok) + throw new Error(`Transit API: ${res.status} ${await res.text()}`); return res.json(); } @@ -74,6 +76,7 @@ export async function fetchMorningBriefing( headers: { "Content-Type": "application/json" }, body: JSON.stringify({ currentLocation, prepMinutes }), }); - if (!res.ok) throw new Error(`Briefing API: ${res.status} ${await res.text()}`); + if (!res.ok) + throw new Error(`Briefing API: ${res.status} ${await res.text()}`); return res.json(); } From cb7b034c702d5d5d09b09d541d3dbedf3841b22d Mon Sep 17 00:00:00 2001 From: ikotome Date: Sun, 22 Feb 2026 05:57:14 +0900 Subject: [PATCH 06/37] feat(env): add GOOGLE_MAPS_API_KEY to example env files and .gitignore for secrets --- .gitignore | 1 + backend/.dev.vars.example | 1 + backend/.secrets/develop.env.example | 1 + backend/.secrets/main.env.example | 1 + backend/.secrets/pr.env.example | 1 + 5 files changed, 5 insertions(+) create mode 100644 .gitignore 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 From b2eece50325a1b8d23e41bc1374ae2afb44411b2 Mon Sep 17 00:00:00 2001 From: ikotome Date: Sun, 22 Feb 2026 06:21:34 +0900 Subject: [PATCH 07/37] refactor(google-calendar): improve formatting of earliestEvent assignment in getTodayEvents function --- .../src/features/google-calendar/google-calendar.service.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/backend/src/features/google-calendar/google-calendar.service.ts b/backend/src/features/google-calendar/google-calendar.service.ts index 2349234..d10cf94 100644 --- a/backend/src/features/google-calendar/google-calendar.service.ts +++ b/backend/src/features/google-calendar/google-calendar.service.ts @@ -165,7 +165,8 @@ export async function getTodayEvents( })); const timedEvents = events.filter((e) => !e.isAllDay); - const earliestEvent = timedEvents.length > 0 ? (timedEvents[0] ?? null) : null; + const earliestEvent = + timedEvents.length > 0 ? (timedEvents[0] ?? null) : null; return { date, events, earliestEvent }; } From fdacdea73845730f8b3e2f830909e845d1e49a90 Mon Sep 17 00:00:00 2001 From: ikotome Date: Sun, 22 Feb 2026 07:17:57 +0900 Subject: [PATCH 08/37] feat(calendar): add logging for access token retrieval and API request errors --- .../google-calendar.service.ts | 6 +- .../src/features/transit/transit.service.ts | 141 ++++++++---------- 2 files changed, 65 insertions(+), 82 deletions(-) diff --git a/backend/src/features/google-calendar/google-calendar.service.ts b/backend/src/features/google-calendar/google-calendar.service.ts index d10cf94..65be488 100644 --- a/backend/src/features/google-calendar/google-calendar.service.ts +++ b/backend/src/features/google-calendar/google-calendar.service.ts @@ -124,8 +124,10 @@ export async function getTodayEvents( const accessToken = await getGoogleAccessToken(env, userId); if (!accessToken) { + console.error("[Calendar] No access token for user:", userId); return { date, events: [], earliestEvent: null }; } + console.log("[Calendar] Got access token for user:", userId); const timeMin = new Date(`${date}T00:00:00+09:00`).toISOString(); const timeMax = new Date(`${date}T23:59:59+09:00`).toISOString(); @@ -143,8 +145,10 @@ export async function getTodayEvents( { headers: { Authorization: `Bearer ${accessToken}` } }, ); + console.log("[Calendar] Request:", `timeMin=${timeMin}`, `timeMax=${timeMax}`); if (!res.ok) { - console.error("Google Calendar API error:", res.status, await res.text()); + const errText = await res.text(); + console.error("Google Calendar API error:", res.status, errText); return { date, events: [], earliestEvent: null }; } diff --git a/backend/src/features/transit/transit.service.ts b/backend/src/features/transit/transit.service.ts index 7517fd4..78d1f01 100644 --- a/backend/src/features/transit/transit.service.ts +++ b/backend/src/features/transit/transit.service.ts @@ -2,11 +2,10 @@ import type { TransitQuery, TransitResult, TransitRoute, - TransitStep, } from "./transit.types"; // --------------------------------------------------------------------------- -// Routes API helpers +// Google Routes API (DRIVE mode) helpers // --------------------------------------------------------------------------- const ROUTES_API_URL = @@ -18,22 +17,16 @@ const ROUTES_API_URL = */ const FIELD_MASK = [ "routes.duration", + "routes.distanceMeters", "routes.legs.duration", - "routes.legs.steps.transitDetails", - "routes.legs.steps.travelMode", + "routes.legs.distanceMeters", + "routes.legs.startLocation", + "routes.legs.endLocation", "routes.legs.steps.navigationInstruction", "routes.legs.steps.localizedValues", - "routes.legs.departureTime", - "routes.legs.arrivalTime", - "routes.localizedValues", + "routes.legs.steps.travelMode", ].join(","); -/** Format an ISO-8601 datetime to RFC 3339 (what Routes API expects). */ -function toRfc3339(iso: string): string { - const d = new Date(iso); - return Number.isNaN(d.getTime()) ? iso : d.toISOString(); -} - /** "123s" → number of seconds. */ function parseDurationSeconds(dur: unknown): number { if (typeof dur === "string") { @@ -43,56 +36,50 @@ function parseDurationSeconds(dur: unknown): number { return 0; } -/** Format a datetime string to "H:mm" display (JST). */ -function toDisplayTime(iso: string | undefined): string { - if (!iso) return ""; - const d = new Date(iso); - if (Number.isNaN(d.getTime())) return ""; - // JST = UTC+9 - const jst = new Date(d.getTime() + 9 * 60 * 60 * 1000); +/** 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 transit directions via the **Google Routes API** - * (`routes.googleapis.com`). + * 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. * - * This is the successor to the legacy Directions API. * 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 (restricted to Routes API). + * @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 { - // biome-ignore lint/suspicious/noExplicitAny: Routes API request body - const body: Record = { - origin: { - address: query.origin, - }, - destination: { - address: query.destination, - }, - travelMode: "TRANSIT", + const body: Record = { + origin: { address: query.origin }, + destination: { address: query.destination }, + travelMode: "DRIVE", languageCode: "ja", regionCode: "JP", - computeAlternativeRoutes: true, + computeAlternativeRoutes: false, }; - if (query.arrivalTime) { - body.arrivalTime = toRfc3339(query.arrivalTime); - } - const res = await fetch(ROUTES_API_URL, { method: "POST", headers: { @@ -111,61 +98,53 @@ export async function getTransitDirections( // biome-ignore lint/suspicious/noExplicitAny: Routes API response const data = (await res.json()) as any; - if (!data.routes || !Array.isArray(data.routes)) { + 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; + + // biome-ignore lint/suspicious/noExplicitAny: Routes API route object 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; - // biome-ignore lint/suspicious/noExplicitAny: Routes API step - const steps: TransitStep[] = (leg.steps ?? []).map((step: any) => { - const td = step.transitDetails; - const mode: string = (step.travelMode as string) ?? "UNKNOWN"; - - return { - mode, - instruction: - (step.navigationInstruction?.instructions as string)?.replace( - /<[^>]*>/g, - "", - ) ?? "", - durationMinutes: Math.ceil( - parseDurationSeconds(step.staticDuration ?? leg.duration) / 60, - ), - transitDetails: td - ? { - line: - (td.transitLine?.nameShort as string) ?? - (td.transitLine?.name as string) ?? - "", - departureStop: - (td.stopDetails?.departureStop?.name as string) ?? "", - arrivalStop: - (td.stopDetails?.arrivalStop?.name as string) ?? "", - numStops: (td.stopCount as number) ?? 0, - } - : undefined, - } satisfies TransitStep; - }); + 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, + ); - return { - departureTime: - toDisplayTime(leg.departureTime) || - ((leg.localizedValues?.departureTime?.text as string) ?? ""), - arrivalTime: - toDisplayTime(leg.arrivalTime) || - ((leg.localizedValues?.arrivalTime?.text as string) ?? ""), + 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(route.duration ?? leg.duration) / 60, + parseDurationSeconds(step.staticDuration ?? step.duration) / 60, ), - summary: - (route.description as string) ?? - (route.localizedValues?.duration?.text as string) ?? - "", + })); + + return { + departureTime, + arrivalTime, + durationMinutes: estimatedMinutes, + summary: `車で約${distanceKm}km(推定${estimatedMinutes}分・乗換含む概算)`, steps, }; }) From 75b0994b9364babf3d916ce293a3ba31fe40b28b Mon Sep 17 00:00:00 2001 From: ikotome Date: Sun, 22 Feb 2026 07:20:10 +0900 Subject: [PATCH 09/37] refactor(google-calendar): improve logging format in getTodayEvents function --- .../src/features/google-calendar/google-calendar.service.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/backend/src/features/google-calendar/google-calendar.service.ts b/backend/src/features/google-calendar/google-calendar.service.ts index 65be488..8f687c2 100644 --- a/backend/src/features/google-calendar/google-calendar.service.ts +++ b/backend/src/features/google-calendar/google-calendar.service.ts @@ -145,7 +145,11 @@ export async function getTodayEvents( { headers: { Authorization: `Bearer ${accessToken}` } }, ); - console.log("[Calendar] Request:", `timeMin=${timeMin}`, `timeMax=${timeMax}`); + console.log( + "[Calendar] Request:", + `timeMin=${timeMin}`, + `timeMax=${timeMax}`, + ); if (!res.ok) { const errText = await res.text(); console.error("Google Calendar API error:", res.status, errText); From 9e9dccd4680f9caf354eac9b0fc6b029a9843205 Mon Sep 17 00:00:00 2001 From: ikotome Date: Sun, 22 Feb 2026 07:42:47 +0900 Subject: [PATCH 10/37] refactor(dashboard): simplify layout and improve widget structure --- .../google-calendar.service.ts | 10 +- frontend/src/app/dashboard/page.tsx | 348 +++++++----------- 2 files changed, 138 insertions(+), 220 deletions(-) diff --git a/backend/src/features/google-calendar/google-calendar.service.ts b/backend/src/features/google-calendar/google-calendar.service.ts index 8f687c2..d10cf94 100644 --- a/backend/src/features/google-calendar/google-calendar.service.ts +++ b/backend/src/features/google-calendar/google-calendar.service.ts @@ -124,10 +124,8 @@ export async function getTodayEvents( const accessToken = await getGoogleAccessToken(env, userId); if (!accessToken) { - console.error("[Calendar] No access token for user:", userId); return { date, events: [], earliestEvent: null }; } - console.log("[Calendar] Got access token for user:", userId); const timeMin = new Date(`${date}T00:00:00+09:00`).toISOString(); const timeMax = new Date(`${date}T23:59:59+09:00`).toISOString(); @@ -145,14 +143,8 @@ export async function getTodayEvents( { headers: { Authorization: `Bearer ${accessToken}` } }, ); - console.log( - "[Calendar] Request:", - `timeMin=${timeMin}`, - `timeMax=${timeMax}`, - ); if (!res.ok) { - const errText = await res.text(); - console.error("Google Calendar API error:", res.status, errText); + console.error("Google Calendar API error:", res.status, await res.text()); return { date, events: [], earliestEvent: null }; } diff --git a/frontend/src/app/dashboard/page.tsx b/frontend/src/app/dashboard/page.tsx index 07fe8d6..e5108fe 100644 --- a/frontend/src/app/dashboard/page.tsx +++ b/frontend/src/app/dashboard/page.tsx @@ -75,224 +75,109 @@ export default function DashboardPage() { }, [state.data]); return ( - - - - - - - - - - - Morning Flow - - - Dashboard - - - - 朝準備ダッシュボード - - - 出発時刻から逆算して、次にやることと残り時間を整える。 - - - - + + + - + + + Morning OS + + + Widgets + + + + 朝ダッシュボード + + + 必要な情報だけをウィジェットで一覧化 + + + - - - - - 今日 - - - {state.data?.date ?? "----/--/--"} - - - - {state.status === "loading" - ? "読み込み中" - : state.status === "error" - ? "読み込み失敗" - : "同期済み"} - - + {state.status === "loading" + ? "読み込み中" + : state.status === "error" + ? "読み込み失敗" + : "同期済み"} + + - - - - 起きる時間 - - - {state.data?.wakeUpTime ?? "--:--"} - - - - - 出発時間 - - - {state.data?.departTime ?? "--:--"} - - - - - 最初の予定 - - - {state.data?.earliestEvent?.title ?? "未登録"} - - - {state.data?.earliestEvent?.startTime ?? "--:--"}{" "} - {state.data?.earliestEvent?.location ?? ""} - - - + + + {state.data?.date ?? "----/--/--"} + + 更新: {state.data?.updatedAt ?? "--"} + + - - - ルーティン概要 - - - {state.data?.routine.length ?? 0} ステップを自動配置 - - - 更新: {state.data?.updatedAt ?? "--"} - - - - + + {state.data?.wakeUpTime ?? "--:--"} + - - - 今日の変更 - {state.data?.overrides?.length ? ( - state.data.overrides.map((override) => ( + + {state.data?.departTime ?? "--:--"} + + + + + {state.data?.routine.length ?? 0} ステップ + + + 逆算タイムラインを自動生成 + + + + + + {state.data?.earliestEvent?.title ?? "未登録"} + + + {state.data?.earliestEvent?.startTime ?? "--:--"} + {state.data?.earliestEvent?.location + ? ` ・ ${state.data.earliestEvent.location}` + : ""} + + + + + {state.data?.overrides?.length ? ( + + {state.data.overrides.map((override) => ( - + {override.date} - + {override.note ?? "特記事項なし"} - - {override.steps.map((step) => ( - - {step.label} - - 追加 - - - ))} - - )) - ) : ( - - 今日は標準ルーティンで運用中。 - - )} - - - - - - - - - タイムライン - - - 出発までの逆算 - - + ))} + + ) : ( + + 今日は標準ルーティンです + + )} + + {routine.length === 0 ? ( state.status === "error" ? ( @@ -302,30 +187,33 @@ export default function DashboardPage() { データを読み込んでいます。 ) ) : ( - + {routine.map((step) => ( - + {step.isOverride ? "変更" : "基本"} - + {step.label} - + {state.data ? toClockString( state.data.departTime, @@ -335,12 +223,50 @@ export default function DashboardPage() { ))} - + )} - - + + ); } + +function WidgetCard({ + title, + children, + colSpan, +}: { + title: string; + children: React.ReactNode; + colSpan: { base: number; md: number }; +}) { + return ( + + + + {title} + + {children} + + + ); +} From f5704828fe51ccfbdffa174650784888efb55ae5 Mon Sep 17 00:00:00 2001 From: ikotome Date: Sun, 22 Feb 2026 07:44:56 +0900 Subject: [PATCH 11/37] feat(dashboard): add links to calendar, maps, and weather in widget cards --- frontend/src/app/dashboard/page.tsx | 111 +++++++++++++++++++++++++++- 1 file changed, 110 insertions(+), 1 deletion(-) diff --git a/frontend/src/app/dashboard/page.tsx b/frontend/src/app/dashboard/page.tsx index e5108fe..c4486dd 100644 --- a/frontend/src/app/dashboard/page.tsx +++ b/frontend/src/app/dashboard/page.tsx @@ -137,7 +137,11 @@ export default function DashboardPage() { - + {state.data?.earliestEvent?.title ?? "未登録"} @@ -147,6 +151,9 @@ export default function DashboardPage() { ? ` ・ ${state.data.earliestEvent.location}` : ""} + + タップしてカレンダーを開く + @@ -177,6 +184,70 @@ export default function DashboardPage() { )} + + + + + カレンダー + + + 予定を確認する + + + + + + マップ + + + 経路を確認する + + + + + + 天気 + + + 雨予報を確認する + + + + + {routine.length === 0 ? ( state.status === "error" ? ( @@ -237,11 +308,49 @@ function WidgetCard({ title, children, colSpan, + href, }: { title: string; children: React.ReactNode; colSpan: { base: number; md: number }; + href?: string; }) { + if (href) { + return ( + + + + {title} + + {children} + + + ); + } + return ( Date: Sun, 22 Feb 2026 07:53:14 +0900 Subject: [PATCH 12/37] refactor(dashboard): update WidgetCard to use anchor tags for links and improve styling --- frontend/src/app/dashboard/page.tsx | 156 ++++++++++++++++------------ 1 file changed, 87 insertions(+), 69 deletions(-) diff --git a/frontend/src/app/dashboard/page.tsx b/frontend/src/app/dashboard/page.tsx index c4486dd..90abe1d 100644 --- a/frontend/src/app/dashboard/page.tsx +++ b/frontend/src/app/dashboard/page.tsx @@ -185,66 +185,78 @@ export default function DashboardPage() { - - + - - カレンダー - - - 予定を確認する - - + + + カレンダー + + + 予定を確認する + + + - - - マップ - - - 経路を確認する - - + + + マップ + + + 経路を確認する + + + - - - 天気 - - - 雨予報を確認する - - + + + 天気 + + + 雨予報を確認する + + + @@ -318,35 +330,41 @@ function WidgetCard({ if (href) { return ( - - + - {title} - - {children} - + + + {title} + + {children} + + + ); } From a47535179e44ab978c2f84b2c54c83f34e82b7ea Mon Sep 17 00:00:00 2001 From: nenrin Date: Sun, 22 Feb 2026 08:17:22 +0900 Subject: [PATCH 13/37] Add dashboard calendar test panel and fix token retrieval --- .../google-calendar.service.ts | 107 ++------ frontend/src/app/dashboard/page.tsx | 251 +++++++++++++++++- frontend/src/lib/backend-api.ts | 19 +- 3 files changed, 289 insertions(+), 88 deletions(-) diff --git a/backend/src/features/google-calendar/google-calendar.service.ts b/backend/src/features/google-calendar/google-calendar.service.ts index d10cf94..a8eae14 100644 --- a/backend/src/features/google-calendar/google-calendar.service.ts +++ b/backend/src/features/google-calendar/google-calendar.service.ts @@ -5,104 +5,41 @@ import type { CalendarEvent, TodayEventsResult } from "./google-calendar.types"; // Token helpers // --------------------------------------------------------------------------- -type RefreshedToken = { access_token: string; expires_in: number }; - -async function refreshGoogleAccessToken( - clientId: string, - clientSecret: string, - refreshToken: string, -): Promise { - const res = await fetch("https://oauth2.googleapis.com/token", { - method: "POST", - headers: { "Content-Type": "application/x-www-form-urlencoded" }, - body: new URLSearchParams({ - client_id: clientId, - client_secret: clientSecret, - refresh_token: refreshToken, - grant_type: "refresh_token", - }), - }); - - if (!res.ok) { - console.error("Google token refresh failed:", res.status, await res.text()); - return null; - } - - const data = (await res.json()) as Record; - if ( - typeof data.access_token !== "string" || - typeof data.expires_in !== "number" - ) { - return null; - } - return { access_token: data.access_token, expires_in: data.expires_in }; -} +const GOOGLE_PROVIDER_ID = "google"; /** * Retrieve a valid Google access token for the given user. * - * Better Auth stores OAuth tokens (optionally encrypted) in the `account` - * table. We use Better Auth's **internal adapter** so that encryption / - * decryption is handled transparently. + * 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); - // biome-ignore lint/suspicious/noExplicitAny: accessing Better Auth internals - const ctx = await (auth as any).$context; - if (!ctx?.internalAdapter) { - console.error("Could not obtain Better Auth internal adapter"); - return null; - } - - const accounts: Array> = - await ctx.internalAdapter.findAccounts(userId); - const google = accounts.find((a) => a.providerId === "google"); - if (!google) return null; - - const accessToken = - typeof google.accessToken === "string" ? google.accessToken : null; - const refreshToken = - typeof google.refreshToken === "string" ? google.refreshToken : null; - - // Check whether the current access token is still valid. - const expiresAt = - google.accessTokenExpiresAt instanceof Date - ? google.accessTokenExpiresAt - : typeof google.accessTokenExpiresAt === "string" - ? new Date(google.accessTokenExpiresAt) - : null; - - const isExpired = expiresAt ? expiresAt.getTime() < Date.now() : true; - - if (!isExpired && accessToken) { - return accessToken; - } - - // Token is expired (or missing) — try to refresh. - if (!refreshToken) return null; - - const refreshed = await refreshGoogleAccessToken( - env.GOOGLE_CLIENT_ID, - env.GOOGLE_CLIENT_SECRET, - refreshToken, - ); - if (!refreshed) return null; - - // Persist the refreshed token (Better Auth encrypts transparently). try { - await ctx.internalAdapter.updateAccount(google.id as string, { - accessToken: refreshed.access_token, - accessTokenExpiresAt: new Date(Date.now() + refreshed.expires_in * 1000), + const tokenPayload = await auth.api.getAccessToken({ + body: { + providerId: GOOGLE_PROVIDER_ID, + userId, + }, }); - } catch (e) { - console.error("Failed to persist refreshed token:", e); - // We still got a valid token — continue. - } - return refreshed.access_token; + 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; + } } // --------------------------------------------------------------------------- diff --git a/frontend/src/app/dashboard/page.tsx b/frontend/src/app/dashboard/page.tsx index 90abe1d..80df536 100644 --- a/frontend/src/app/dashboard/page.tsx +++ b/frontend/src/app/dashboard/page.tsx @@ -3,6 +3,8 @@ import { Badge, Box, + Button, + Code, Container, Flex, Grid, @@ -11,7 +13,13 @@ import { Stack, Text, } from "@chakra-ui/react"; -import { useEffect, useMemo, useState } from "react"; +import { useCallback, useEffect, useMemo, useState } from "react"; +import { signInWithGoogle } from "@/lib/auth-api"; +import { + fetchCalendarToday, + type CalendarTodayEvent, + type CalendarTodayResponse, +} from "@/lib/backend-api"; import type { MorningDashboard } from "@/lib/morning-dashboard-api"; import { getMorningDashboard } from "@/lib/morning-dashboard-api"; @@ -20,6 +28,14 @@ type LoadState = { data: MorningDashboard | null; }; +type CalendarApiState = { + status: "idle" | "loading" | "ready" | "error"; + data: CalendarTodayResponse | null; + error: string | null; + durationMs: number | null; + fetchedAt: string | null; +}; + function toClockString(baseTime: string, offsetMinutes: number): string { const [hourText, minuteText] = baseTime.split(":"); const hours = Number(hourText ?? 0); @@ -37,11 +53,112 @@ function toClockString(baseTime: string, offsetMinutes: number): string { return `${displayHours}:${displayMinutes}`; } +function toCalendarStatusLabel(status: CalendarApiState["status"]): string { + if (status === "loading") { + return "取得中"; + } + if (status === "ready") { + return "取得成功"; + } + if (status === "error") { + return "取得失敗"; + } + return "未取得"; +} + +function toCalendarStatusColor(status: CalendarApiState["status"]): string { + if (status === "loading") { + return "yellow"; + } + if (status === "ready") { + return "green"; + } + if (status === "error") { + return "red"; + } + return "gray"; +} + +function formatApiEventTime(event: CalendarTodayEvent): string { + if (event.isAllDay) { + return "終日"; + } + + const parsed = new Date(event.start); + if (Number.isNaN(parsed.getTime())) { + return event.start || "--:--"; + } + + return parsed.toLocaleTimeString("ja-JP", { + hour: "2-digit", + minute: "2-digit", + hour12: false, + }); +} + +function nowTimeText(): string { + return new Date().toLocaleTimeString("ja-JP", { + hour: "2-digit", + minute: "2-digit", + second: "2-digit", + hour12: false, + }); +} + export default function DashboardPage() { const [state, setState] = useState({ status: "loading", data: null, }); + const [calendarState, setCalendarState] = useState({ + status: "idle", + data: null, + error: null, + durationMs: null, + fetchedAt: null, + }); + + const loadCalendar = useCallback(async () => { + setCalendarState((prev) => ({ + ...prev, + status: "loading", + error: null, + })); + + const startedAt = performance.now(); + try { + const data = await fetchCalendarToday(); + setCalendarState({ + status: "ready", + data, + error: null, + durationMs: Math.round(performance.now() - startedAt), + fetchedAt: nowTimeText(), + }); + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + setCalendarState((prev) => ({ + ...prev, + status: "error", + error: errorMessage, + durationMs: Math.round(performance.now() - startedAt), + fetchedAt: nowTimeText(), + })); + } + }, []); + + const handleGoogleSignIn = useCallback(async () => { + try { + await signInWithGoogle(window.location.href); + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + setCalendarState((prev) => ({ + ...prev, + status: "error", + error: `Googleログイン開始に失敗しました: ${errorMessage}`, + })); + } + }, []); useEffect(() => { let active = true; @@ -65,6 +182,10 @@ export default function DashboardPage() { }; }, []); + useEffect(() => { + void loadCalendar(); + }, [loadCalendar]); + const routine = useMemo(() => { if (!state.data) { return []; @@ -184,6 +305,134 @@ export default function DashboardPage() { )} + + + + + + {toCalendarStatusLabel(calendarState.status)} + + + {calendarState.fetchedAt + ? `最終取得: ${calendarState.fetchedAt}` + : "まだ取得していません"} + {calendarState.durationMs != null + ? ` ・ ${calendarState.durationMs}ms` + : ""} + + + + + + + + + + {calendarState.error ? ( + + + {calendarState.error} + + + ) : null} + + + + 対象日: {calendarState.data?.date ?? "--"} + + + 予定件数: {calendarState.data?.events.length ?? 0} 件 + + + 先頭予定:{" "} + {calendarState.data?.earliestEvent + ? `${formatApiEventTime(calendarState.data.earliestEvent)} ${calendarState.data.earliestEvent.summary}` + : "なし"} + + + + {calendarState.data?.events.length ? ( + + {calendarState.data.events.slice(0, 5).map((event) => ( + + + + {event.summary} + + + {formatApiEventTime(event)} + {event.location ? ` ・ ${event.location}` : ""} + + + + {event.isAllDay ? "終日" : "時刻あり"} + + + ))} + {calendarState.data.events.length > 5 ? ( + + 他 {calendarState.data.events.length - 5} 件 + + ) : null} + + ) : ( + + {calendarState.status === "loading" + ? "予定を取得しています..." + : "今日の予定はありません。"} + + )} + + {calendarState.data ? ( + + + {JSON.stringify(calendarState.data, null, 2)} + + + ) : null} + + + { +export type CalendarTodayEvent = { + id: string; + summary: string; + location: string | null; + start: string; + end: string; + isAllDay: boolean; +}; + +export type CalendarTodayResponse = { + date: string; + events: CalendarTodayEvent[]; + earliestEvent: CalendarTodayEvent | null; +}; + +export async function fetchCalendarToday(): Promise { const res = await fetch(endpoint("/calendar/today"), { credentials: "include", }); if (!res.ok) throw new Error(`Calendar API: ${res.status} ${await res.text()}`); - return res.json(); + return (await res.json()) as CalendarTodayResponse; } // --------------------------------------------------------------------------- From a8c5404657f55545bf31d3dcbb6357448560900d Mon Sep 17 00:00:00 2001 From: nenrin Date: Sun, 22 Feb 2026 08:20:33 +0900 Subject: [PATCH 14/37] Fix frontend formatting and backend lint warning --- backend/src/features/transit/transit.service.ts | 1 - frontend/src/app/dashboard/page.tsx | 13 +++++++++---- 2 files changed, 9 insertions(+), 5 deletions(-) diff --git a/backend/src/features/transit/transit.service.ts b/backend/src/features/transit/transit.service.ts index 78d1f01..c94d017 100644 --- a/backend/src/features/transit/transit.service.ts +++ b/backend/src/features/transit/transit.service.ts @@ -107,7 +107,6 @@ export async function getTransitDirections( // walks + waits add overhead. 1.3× is a conservative estimate. const TRANSIT_MULTIPLIER = 1.3; - // biome-ignore lint/suspicious/noExplicitAny: Routes API route object const routes: TransitRoute[] = data.routes // biome-ignore lint/suspicious/noExplicitAny: Routes API route object .map((route: any): TransitRoute | null => { diff --git a/frontend/src/app/dashboard/page.tsx b/frontend/src/app/dashboard/page.tsx index 80df536..9a204ac 100644 --- a/frontend/src/app/dashboard/page.tsx +++ b/frontend/src/app/dashboard/page.tsx @@ -16,9 +16,9 @@ import { import { useCallback, useEffect, useMemo, useState } from "react"; import { signInWithGoogle } from "@/lib/auth-api"; import { - fetchCalendarToday, type CalendarTodayEvent, type CalendarTodayResponse, + fetchCalendarToday, } from "@/lib/backend-api"; import type { MorningDashboard } from "@/lib/morning-dashboard-api"; import { getMorningDashboard } from "@/lib/morning-dashboard-api"; @@ -136,7 +136,8 @@ export default function DashboardPage() { fetchedAt: nowTimeText(), }); } catch (error) { - const errorMessage = error instanceof Error ? error.message : String(error); + const errorMessage = + error instanceof Error ? error.message : String(error); setCalendarState((prev) => ({ ...prev, status: "error", @@ -151,7 +152,8 @@ export default function DashboardPage() { try { await signInWithGoogle(window.location.href); } catch (error) { - const errorMessage = error instanceof Error ? error.message : String(error); + const errorMessage = + error instanceof Error ? error.message : String(error); setCalendarState((prev) => ({ ...prev, status: "error", @@ -305,7 +307,10 @@ export default function DashboardPage() { )} - + Date: Sun, 22 Feb 2026 08:18:29 +0900 Subject: [PATCH 15/37] refactor(dashboard): replace getMorningDashboard with fetchMorningBriefing and update data structures --- frontend/src/app/dashboard/page.tsx | 924 +++++++++++++++------- frontend/src/lib/morning-dashboard-api.ts | 138 +++- 2 files changed, 781 insertions(+), 281 deletions(-) diff --git a/frontend/src/app/dashboard/page.tsx b/frontend/src/app/dashboard/page.tsx index 9a204ac..2512a04 100644 --- a/frontend/src/app/dashboard/page.tsx +++ b/frontend/src/app/dashboard/page.tsx @@ -1,18 +1,363 @@ -"use client"; +"use client";"use client"; + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +} ); {children} > boxShadow="sm" borderColor="gray.200" borderWidth="1px" p={5} borderRadius="2xl" bg="white" )} API取得に失敗しました。ログイン状態とバックエンド起動を確認してください。 {state.status === "error" && ( {weather?.reason ?? "天気情報なし"} {weather?.locationName ?? "-"} 降水量 {weather ? `${weather.precipitationMm} mm/h` : "--"} {weather?.umbrellaNeeded ? "傘あり" : "晴れ"} {weather ? `${weather.precipitationProbability}%` : "--%"} {weather?.umbrellaNeeded ? "☔" : "☀️"} > alignItems="center" gap={3} templateColumns={{ base: "1fr", md: "1fr 1fr" }} 天気 )} )) {event.location ?? "場所未設定"} / {eventDurationMinutes(event.start, event.end)}分 {event.summary} > ml={2} fontWeight="normal" fontSize="4xl" as="span" /> bg="green.500" borderRadius="full" h="10px" w="10px" mt="6px" todayEvents.slice(0, 3).map((event) => ( ) : ( 予定はありません {todayEvents.length === 0 ? ( 今日の予定 /> bg={slack < 5 ? "red.400" : "green.500"} w={`${Math.min(100, Math.max(0, (Math.max(0, slack) / 60) * 100))}%`} borderRadius="full" h="full" overflow="hidden" borderRadius="full" bg="gray.200" h="8px" mt={3} {departure}に出れば 分 > fontWeight="normal" color="gray.500" fontSize="3xl" as="span" 余裕 定刻通り {transitSummary} > whiteSpace="nowrap" textOverflow="ellipsis" overflow="hidden" mt={1} fontWeight="semibold" fontSize="2xl" color="gray.700" 分 > fontWeight="normal" color="gray.500" fontSize="3xl" as="span" 交通 現在地: {currentLocation} {lateRisk}% > fontWeight="semibold" color={lateRisk >= 60 ? "red.600" : "green.700"} as="span" {departure} > lineHeight={1} color="gray.800" fontWeight="bold" fontSize={{ base: "6xl", md: "8xl" }} 出発まで 更新 > onClick={() => setCurrentLocation(locationInput.trim() || "大阪駅")} colorPalette="gray" size="sm" + - - {state.data?.departTime ?? "--:--"} - - - - - {state.data?.routine.length ?? 0} ステップ - - - 逆算タイムラインを自動生成 - - - - + + 出発まで + + +<<<<<<< HEAD {state.data?.earliestEvent?.title ?? "未登録"} @@ -442,212 +839,195 @@ export default function DashboardPage() { + + 遅刻リスク{" "} + = 60 ? "red.600" : "green.700"} + fontWeight="semibold" +>>>>>>> ba45a79 (refactor(dashboard): replace getMorningDashboard with fetchMorningBriefing and update data structures) > - - - - カレンダー - - - 予定を確認する - - - + {lateRisk}% + + + + 現在地: {currentLocation} + + - + + + 交通 + + + {transitMinutes} + - - - マップ - - - 経路を確認する - - - + 分 + + + + {transitSummary} + + + 定刻通り + + - + + 余裕 + + + {Math.max(0, slack)} + - - - 天気 - - - 雨予報を確認する - - - - - + 分 + + + + {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" && ( + + API取得に失敗しました。ログイン状態とバックエンド起動を確認してください。 + + )} ); } -function WidgetCard({ - title, - children, - colSpan, - href, -}: { - title: string; - children: React.ReactNode; - colSpan: { base: number; md: number }; - href?: string; -}) { - if (href) { - return ( - - - - - - {title} - - {children} - - - - - ); - } - +function Card({ children }: { children: React.ReactNode }) { return ( - - - {title} - - {children} - + {children} ); } diff --git a/frontend/src/lib/morning-dashboard-api.ts b/frontend/src/lib/morning-dashboard-api.ts index 772fab4..8886321 100644 --- a/frontend/src/lib/morning-dashboard-api.ts +++ b/frontend/src/lib/morning-dashboard-api.ts @@ -1,4 +1,4 @@ -import mockDashboard from "../data/morning-dashboard.json"; +import { fetchMorningBriefing } from "./backend-api"; export type CalendarEvent = { title: string; @@ -33,15 +33,135 @@ export type MorningDashboard = { overrides?: MorningRoutineOverride[]; }; -export async function getMorningDashboard( - date?: string, -): Promise { - if (!date || mockDashboard.date === date) { - return mockDashboard as MorningDashboard; +type BriefingEvent = { + id: string; + summary: string; + location: string | null; + start: string; + end: string; + isAllDay: boolean; +}; + +type EventBriefing = { + event: BriefingEvent; + destination: string; + transitMinutes: number; + leaveBy: string; + wakeUpBy: string; + slackMinutes: number; + lateRiskPercent: number; +}; + +type MorningBriefingResult = { + date: string; + now: string; + totalEvents: number; + briefings: EventBriefing[]; + urgent: EventBriefing | null; + eventsWithoutLocation: BriefingEvent[]; + weather: { + reason?: string; + } | null; +}; + +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}`; +} + +function hhmmToMinutes(hhmm: string): number { + const [h, m] = hhmm.split(":"); + const hh = Number(h); + const mm = Number(m); + if (Number.isNaN(hh) || Number.isNaN(mm)) return 0; + return hh * 60 + mm; +} + +function offsetFromDepart(departTime: string, targetTime: string): number { + const depart = hhmmToMinutes(departTime); + const target = hhmmToMinutes(targetTime); + return Math.max(0, depart - target); +} + +export async function getMorningDashboard(options?: { + date?: string; + currentLocation?: string; + prepMinutes?: number; +}): Promise { + const currentLocation = options?.currentLocation?.trim() || "大阪駅"; + const prepMinutes = options?.prepMinutes ?? 30; + + const raw = (await fetchMorningBriefing( + currentLocation, + prepMinutes, + )) as MorningBriefingResult; + + const urgent = raw.urgent; + const departTime = urgent?.leaveBy ?? "--:--"; + const wakeUpTime = urgent?.wakeUpBy ?? "--:--"; + const earliestStart = urgent ? toJstHHmm(urgent.event.start) : "--:--"; + + const routine: MorningRoutineStep[] = urgent + ? [ + { + id: "wake", + label: "起床", + offsetMinutes: offsetFromDepart(departTime, wakeUpTime), + }, + { + id: "prepare", + label: "身支度", + offsetMinutes: Math.max( + 0, + offsetFromDepart(departTime, wakeUpTime) - 20, + ), + durationMinutes: 20, + }, + { + id: "depart", + label: "出発", + offsetMinutes: 0, + }, + ] + : []; + + const overrides: MorningRoutineOverride[] = []; + if (raw.weather?.reason) { + overrides.push({ + date: raw.date, + note: raw.weather.reason, + steps: [], + }); + } + + if (urgent && urgent.lateRiskPercent >= 60) { + overrides.push({ + date: raw.date, + note: `遅刻リスク ${urgent.lateRiskPercent}% — 早めの行動がおすすめ`, + steps: [], + }); } return { - ...mockDashboard, - date, - } as MorningDashboard; + userId: "current-user", + date: raw.date, + earliestEvent: urgent + ? { + title: urgent.event.summary, + location: urgent.event.location, + startTime: earliestStart, + } + : null, + earliestEventJson: urgent ? JSON.stringify(urgent.event) : undefined, + wakeUpTime, + departTime, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + routine, + overrides, + }; } From 5b120c58b69b39d3c6405f1715a99213f01de4e8 Mon Sep 17 00:00:00 2001 From: ikotome Date: Sun, 22 Feb 2026 08:20:26 +0900 Subject: [PATCH 16/37] fix(docs): correct spelling of Hono in README.md --- backend/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 From e547b989292a0788e94a1a6eb64e381b5ad88da1 Mon Sep 17 00:00:00 2001 From: ikotome Date: Sun, 22 Feb 2026 08:37:16 +0900 Subject: [PATCH 17/37] refactor(dashboard): remove unused calendar-related code and clean up imports --- frontend/src/app/dashboard/page.tsx | 704 +--------------------------- 1 file changed, 7 insertions(+), 697 deletions(-) diff --git a/frontend/src/app/dashboard/page.tsx b/frontend/src/app/dashboard/page.tsx index 2512a04..0ae0481 100644 --- a/frontend/src/app/dashboard/page.tsx +++ b/frontend/src/app/dashboard/page.tsx @@ -1,355 +1,8 @@ -"use client";"use client"; +"use client"; - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -} ); {children} > boxShadow="sm" borderColor="gray.200" borderWidth="1px" p={5} borderRadius="2xl" bg="white" )} API取得に失敗しました。ログイン状態とバックエンド起動を確認してください。 {state.status === "error" && ( {weather?.reason ?? "天気情報なし"} {weather?.locationName ?? "-"} 降水量 {weather ? `${weather.precipitationMm} mm/h` : "--"} {weather?.umbrellaNeeded ? "傘あり" : "晴れ"} {weather ? `${weather.precipitationProbability}%` : "--%"} {weather?.umbrellaNeeded ? "☔" : "☀️"} > alignItems="center" gap={3} templateColumns={{ base: "1fr", md: "1fr 1fr" }} 天気 )} )) {event.location ?? "場所未設定"} / {eventDurationMinutes(event.start, event.end)}分 {event.summary} > ml={2} fontWeight="normal" fontSize="4xl" as="span" /> bg="green.500" borderRadius="full" h="10px" w="10px" mt="6px" todayEvents.slice(0, 3).map((event) => ( ) : ( 予定はありません {todayEvents.length === 0 ? ( 今日の予定 /> bg={slack < 5 ? "red.400" : "green.500"} w={`${Math.min(100, Math.max(0, (Math.max(0, slack) / 60) * 100))}%`} borderRadius="full" h="full" overflow="hidden" borderRadius="full" bg="gray.200" h="8px" mt={3} {departure}に出れば 分 > fontWeight="normal" color="gray.500" fontSize="3xl" as="span" 余裕 定刻通り {transitSummary} > whiteSpace="nowrap" textOverflow="ellipsis" overflow="hidden" mt={1} fontWeight="semibold" fontSize="2xl" color="gray.700" 分 > fontWeight="normal" color="gray.500" fontSize="3xl" as="span" 交通 現在地: {currentLocation} {lateRisk}% > fontWeight="semibold" color={lateRisk >= 60 ? "red.600" : "green.700"} as="span" {departure} > lineHeight={1} color="gray.800" fontWeight="bold" fontSize={{ base: "6xl", md: "8xl" }} 出発まで 更新 > onClick={() => setCurrentLocation(locationInput.trim() || "大阪駅")} colorPalette="gray" size="sm" @@ -661,185 +160,6 @@ export default function DashboardPage() { color="gray.800" lineHeight={1} > -<<<<<<< HEAD - - {state.data?.earliestEvent?.title ?? "未登録"} - - - {state.data?.earliestEvent?.startTime ?? "--:--"} - {state.data?.earliestEvent?.location - ? ` ・ ${state.data.earliestEvent.location}` - : ""} - - - タップしてカレンダーを開く - - - - - {state.data?.overrides?.length ? ( - - {state.data.overrides.map((override) => ( - - - {override.date} - - - {override.note ?? "特記事項なし"} - - - ))} - - ) : ( - - 今日は標準ルーティンです - - )} - - - - - - - - {toCalendarStatusLabel(calendarState.status)} - - - {calendarState.fetchedAt - ? `最終取得: ${calendarState.fetchedAt}` - : "まだ取得していません"} - {calendarState.durationMs != null - ? ` ・ ${calendarState.durationMs}ms` - : ""} - - - - - - - - - - {calendarState.error ? ( - - - {calendarState.error} - - - ) : null} - - - - 対象日: {calendarState.data?.date ?? "--"} - - - 予定件数: {calendarState.data?.events.length ?? 0} 件 - - - 先頭予定:{" "} - {calendarState.data?.earliestEvent - ? `${formatApiEventTime(calendarState.data.earliestEvent)} ${calendarState.data.earliestEvent.summary}` - : "なし"} - - - - {calendarState.data?.events.length ? ( - - {calendarState.data.events.slice(0, 5).map((event) => ( - - - - {event.summary} - - - {formatApiEventTime(event)} - {event.location ? ` ・ ${event.location}` : ""} - - - - {event.isAllDay ? "終日" : "時刻あり"} - - - ))} - {calendarState.data.events.length > 5 ? ( - - 他 {calendarState.data.events.length - 5} 件 - - ) : null} - - ) : ( - - {calendarState.status === "loading" - ? "予定を取得しています..." - : "今日の予定はありません。"} - - )} - - {calendarState.data ? ( - - - {JSON.stringify(calendarState.data, null, 2)} - - - ) : null} - - - - - @@ -848,7 +168,6 @@ export default function DashboardPage() { as="span" color={lateRisk >= 60 ? "red.600" : "green.700"} fontWeight="semibold" ->>>>>>> ba45a79 (refactor(dashboard): replace getMorningDashboard with fetchMorningBriefing and update data structures) > {lateRisk}% @@ -945,11 +264,7 @@ export default function DashboardPage() { bg="green.500" /> - + {toJstHHmm(event.start)} - {event.location ?? "場所未設定"} /{" "} - {eventDurationMinutes(event.start, event.end)}分 + {event.location ?? "場所未設定"} / {eventDurationMinutes(event.start, event.end)}分 @@ -981,9 +295,7 @@ export default function DashboardPage() { alignItems="center" > - - {weather?.umbrellaNeeded ? "☔" : "☀️"} - + {weather?.umbrellaNeeded ? "☔" : "☀️"} {weather ? `${weather.precipitationProbability}%` : "--%"} @@ -995,9 +307,7 @@ export default function DashboardPage() { - - 降水量 {weather ? `${weather.precipitationMm} mm/h` : "--"} - + 降水量 {weather ? `${weather.precipitationMm} mm/h` : "--"} {weather?.locationName ?? "-"} {weather?.reason ?? "天気情報なし"} From 76bfd3bcb5f3ed60e37978f2b879d1a5d909f471 Mon Sep 17 00:00:00 2001 From: ikotome Date: Sun, 22 Feb 2026 08:59:53 +0900 Subject: [PATCH 18/37] feat(dashboard): implement caching for morning briefing and add force refresh option --- .../0003_morning_briefing_cache.sql | 16 ++ .../morning-briefing.service.ts | 165 +++++++++++++++++- .../morning-briefing.types.ts | 5 + backend/src/routes/briefing-routes.ts | 1 + frontend/src/app/dashboard/page.tsx | 162 ++++++++++++----- frontend/src/lib/backend-api.ts | 3 +- 6 files changed, 299 insertions(+), 53 deletions(-) create mode 100644 backend/migrations/0003_morning_briefing_cache.sql 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/features/morning-briefing/morning-briefing.service.ts b/backend/src/features/morning-briefing/morning-briefing.service.ts index 96952b2..09fc215 100644 --- a/backend/src/features/morning-briefing/morning-briefing.service.ts +++ b/backend/src/features/morning-briefing/morning-briefing.service.ts @@ -10,6 +10,10 @@ import type { MorningBriefingResult, } from "./morning-briefing.types"; +type CacheRow = { + payload_json: string; +}; + // --------------------------------------------------------------------------- // JST helpers // --------------------------------------------------------------------------- @@ -132,16 +136,115 @@ async function buildEventBriefing( // Main entry point // --------------------------------------------------------------------------- +function formatJstDate(jstDate: Date): string { + return jstDate.toISOString().split("T")[0] as string; +} + /** - * Generate a complete morning briefing for the authenticated user. + * Cache slots: + * - 05:00 update slot + * - 23:00 update slot * - * 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 + * Between 00:00-04:59, we still use the previous day's 23:00 slot. */ -export async function getMorningBriefing( +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, @@ -220,3 +323,51 @@ export async function getMorningBriefing( 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 index e4f8622..188be4c 100644 --- a/backend/src/features/morning-briefing/morning-briefing.types.ts +++ b/backend/src/features/morning-briefing/morning-briefing.types.ts @@ -14,6 +14,11 @@ export type MorningBriefingRequest = { * Default: 30 */ prepMinutes?: number; + /** + * Force bypass cache and recompute briefing immediately. + * Used when user explicitly presses "更新". + */ + forceRefresh?: boolean; }; // --------------------------------------------------------------------------- diff --git a/backend/src/routes/briefing-routes.ts b/backend/src/routes/briefing-routes.ts index 66b6935..eac3432 100644 --- a/backend/src/routes/briefing-routes.ts +++ b/backend/src/routes/briefing-routes.ts @@ -41,6 +41,7 @@ export function registerBriefingRoutes(app: App): void { typeof body.prepMinutes === "number" && body.prepMinutes > 0 ? body.prepMinutes : undefined, + forceRefresh: body.forceRefresh === true, }); return c.json(result); diff --git a/frontend/src/app/dashboard/page.tsx b/frontend/src/app/dashboard/page.tsx index 0ae0481..4f203d0 100644 --- a/frontend/src/app/dashboard/page.tsx +++ b/frontend/src/app/dashboard/page.tsx @@ -84,25 +84,29 @@ export default function DashboardPage() { const [state, setState] = useState({ status: "loading", data: null }); const [locationInput, setLocationInput] = useState("大阪駅"); const [currentLocation, setCurrentLocation] = useState("大阪駅"); + const [refreshToken, setRefreshToken] = useState(0); + const [forceRefresh, setForceRefresh] = useState(false); useEffect(() => { let active = true; setState((prev) => ({ ...prev, status: "loading" })); - fetchMorningBriefing(currentLocation, 30) + 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 }); + setForceRefresh(false); }); return () => { active = false; }; - }, [currentLocation]); + }, [currentLocation, refreshToken, forceRefresh]); const urgent = state.data?.urgent ?? null; const departure = urgent?.leaveBy ?? "--:--"; @@ -124,45 +128,74 @@ export default function DashboardPage() { return [...byId.values()] .filter((e) => !e.isAllDay) - .sort((a, b) => new Date(a.start).getTime() - new Date(b.start).getTime()); + .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); + setRefreshToken((v) => v + 1); + }; + return ( - + - - + + setLocationInput(e.target.value)} - maxW="240px" + onKeyDown={(e) => { + if (e.key === "Enter") { + applyLocation(); + } + }} + flex="1" + minW={{ base: "140px", md: "240px" }} bg="white" borderColor="gray.300" - size="sm" + size="md" placeholder="現在地(例: 大阪駅)" /> - - + + 出発まで {departure} - + 遅刻リスク{" "} - - - + + + 交通 - + {transitMinutes} @@ -195,7 +236,7 @@ export default function DashboardPage() { {transitSummary} - + 定刻通り - - + + 余裕 - + {Math.max(0, slack)} - + {departure}に出れば - - + + 今日の予定 - + {todayEvents.length === 0 ? ( - + 予定はありません ) : ( @@ -264,19 +309,27 @@ export default function DashboardPage() { bg="green.500" /> - + {toJstHHmm(event.start)} {event.summary} - - {event.location ?? "場所未設定"} / {eventDurationMinutes(event.start, event.end)}分 + + {event.location ?? "場所未設定"} /{" "} + {eventDurationMinutes(event.start, event.end)}分 @@ -285,8 +338,8 @@ export default function DashboardPage() { - - + + 天気 - {weather?.umbrellaNeeded ? "☔" : "☀️"} + + {weather?.umbrellaNeeded ? "☔" : "☀️"} + - + {weather ? `${weather.precipitationProbability}%` : "--%"} - + {weather?.umbrellaNeeded ? "傘あり" : "晴れ"} - - 降水量 {weather ? `${weather.precipitationMm} mm/h` : "--"} + + + 降水量 {weather ? `${weather.precipitationMm} mm/h` : "--"} + {weather?.locationName ?? "-"} - + {weather?.reason ?? "天気情報なし"} @@ -317,7 +382,7 @@ export default function DashboardPage() { {state.status === "error" && ( - + API取得に失敗しました。ログイン状態とバックエンド起動を確認してください。 )} @@ -327,12 +392,19 @@ export default function DashboardPage() { ); } -function Card({ children }: { children: React.ReactNode }) { +function Card({ + children, + minH, +}: { + children: React.ReactNode; + minH?: string | { base: string; md: string }; +}) { return ( { const res = await fetch(endpoint("/briefing/morning"), { method: "POST", credentials: "include", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ currentLocation, prepMinutes }), + body: JSON.stringify({ currentLocation, prepMinutes, forceRefresh }), }); if (!res.ok) throw new Error(`Briefing API: ${res.status} ${await res.text()}`); From 0d8cb1b47f3e4f4a84b62ec25a53f499535e372c Mon Sep 17 00:00:00 2001 From: ikotome Date: Sun, 22 Feb 2026 10:19:02 +0900 Subject: [PATCH 19/37] refactor(dashboard): remove refreshToken state and update dependencies in useEffect --- frontend/src/app/dashboard/page.tsx | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/frontend/src/app/dashboard/page.tsx b/frontend/src/app/dashboard/page.tsx index 4f203d0..2589e8c 100644 --- a/frontend/src/app/dashboard/page.tsx +++ b/frontend/src/app/dashboard/page.tsx @@ -84,7 +84,6 @@ export default function DashboardPage() { const [state, setState] = useState({ status: "loading", data: null }); const [locationInput, setLocationInput] = useState("大阪駅"); const [currentLocation, setCurrentLocation] = useState("大阪駅"); - const [refreshToken, setRefreshToken] = useState(0); const [forceRefresh, setForceRefresh] = useState(false); useEffect(() => { @@ -106,7 +105,7 @@ export default function DashboardPage() { return () => { active = false; }; - }, [currentLocation, refreshToken, forceRefresh]); + }, [currentLocation, forceRefresh]); const urgent = state.data?.urgent ?? null; const departure = urgent?.leaveBy ?? "--:--"; @@ -138,7 +137,6 @@ export default function DashboardPage() { setLocationInput(next); setCurrentLocation(next); setForceRefresh(true); - setRefreshToken((v) => v + 1); }; return ( From 3bbbb7d159241dedfc665687812261d942c9f26c Mon Sep 17 00:00:00 2001 From: ikotome Date: Sun, 22 Feb 2026 10:34:18 +0900 Subject: [PATCH 20/37] feat(dashboard): enhance error handling with specific messages and login prompt --- frontend/src/app/dashboard/page.tsx | 40 ++++++++++++++++++++++++----- 1 file changed, 33 insertions(+), 7 deletions(-) diff --git a/frontend/src/app/dashboard/page.tsx b/frontend/src/app/dashboard/page.tsx index 2589e8c..2b6ac90 100644 --- a/frontend/src/app/dashboard/page.tsx +++ b/frontend/src/app/dashboard/page.tsx @@ -62,6 +62,7 @@ type MorningBriefingResult = { type State = { status: "loading" | "ready" | "error"; data: MorningBriefingResult | null; + errorType?: "unauthorized" | "unknown"; }; function toJstHHmm(iso: string): string { @@ -81,14 +82,18 @@ function eventDurationMinutes(start: string, end: string): number { } export default function DashboardPage() { - const [state, setState] = useState({ status: "loading", data: null }); + 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" })); + setState((prev) => ({ ...prev, status: "loading", errorType: undefined })); fetchMorningBriefing(currentLocation, 30, forceRefresh) .then((raw) => { @@ -96,9 +101,15 @@ export default function DashboardPage() { setState({ status: "ready", data: raw as MorningBriefingResult }); setForceRefresh(false); }) - .catch(() => { + .catch((error: unknown) => { if (!active) return; - setState({ status: "error", data: null }); + 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); }); @@ -380,9 +391,24 @@ export default function DashboardPage() { {state.status === "error" && ( - - API取得に失敗しました。ログイン状態とバックエンド起動を確認してください。 - + + + {state.errorType === "unauthorized" + ? "ログインが必要です。" + : "API取得に失敗しました。ログイン状態とバックエンド起動を確認してください。"} + + {state.errorType === "unauthorized" && ( + + )} + )} From b775b16d9658803f32888c73e49b4d7185a7532e Mon Sep 17 00:00:00 2001 From: ikotome Date: Sun, 22 Feb 2026 10:40:44 +0900 Subject: [PATCH 21/37] fix(dashboard): improve error handling for unauthorized access --- frontend/src/app/dashboard/page.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/frontend/src/app/dashboard/page.tsx b/frontend/src/app/dashboard/page.tsx index 2b6ac90..886202b 100644 --- a/frontend/src/app/dashboard/page.tsx +++ b/frontend/src/app/dashboard/page.tsx @@ -104,7 +104,8 @@ export default function DashboardPage() { .catch((error: unknown) => { if (!active) return; const message = error instanceof Error ? error.message : ""; - const isUnauthorized = message.includes(" 401 ") || message.includes("401"); + const isUnauthorized = + message.includes(" 401 ") || message.includes("401"); setState({ status: "error", data: null, From c259394c6d81154b8fa6573ed035bf47754af38b Mon Sep 17 00:00:00 2001 From: nenrin Date: Sun, 22 Feb 2026 04:54:17 +0900 Subject: [PATCH 22/37] move root page to task-decomp route --- frontend/src/app/{ => task-decomp}/page.tsx | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename frontend/src/app/{ => task-decomp}/page.tsx (100%) diff --git a/frontend/src/app/page.tsx b/frontend/src/app/task-decomp/page.tsx similarity index 100% rename from frontend/src/app/page.tsx rename to frontend/src/app/task-decomp/page.tsx From 8649667e54add1e3638a22feb310e4410bc854e2 Mon Sep 17 00:00:00 2001 From: nenrin Date: Sun, 22 Feb 2026 04:59:46 +0900 Subject: [PATCH 23/37] split task-decomp page into helpers and step components --- .../components/task-decomp-steps.tsx | 487 ++++++++++++ frontend/src/app/task-decomp/constants.ts | 13 + frontend/src/app/task-decomp/helpers.ts | 231 ++++++ frontend/src/app/task-decomp/page.tsx | 716 ++---------------- frontend/src/app/task-decomp/types.ts | 5 + 5 files changed, 808 insertions(+), 644 deletions(-) create mode 100644 frontend/src/app/task-decomp/components/task-decomp-steps.tsx create mode 100644 frontend/src/app/task-decomp/constants.ts create mode 100644 frontend/src/app/task-decomp/helpers.ts create mode 100644 frontend/src/app/task-decomp/types.ts diff --git a/frontend/src/app/task-decomp/components/task-decomp-steps.tsx b/frontend/src/app/task-decomp/components/task-decomp-steps.tsx new file mode 100644 index 0000000..0443f79 --- /dev/null +++ b/frontend/src/app/task-decomp/components/task-decomp-steps.tsx @@ -0,0 +1,487 @@ +"use client"; + +import { + Badge, + Button, + Card, + Field, + Heading, + HStack, + Input, + List, + ProgressCircle, + Stack, + Tabs, + Text, + Textarea, +} from "@chakra-ui/react"; +import type { FormEvent } from "react"; +import { AuthPanel } from "@/components/auth/auth-panel"; +import type { SessionResponse } from "@/lib/auth-api"; +import type { WorkflowRecord } from "@/lib/task-workflow-api"; +import { + formatDateTime, + toHistoryTitle, + toStatusLabelFromRecord, +} from "../helpers"; +import type { ResultTab, RunPhase } from "../types"; + +type AuthStepProps = { + onSessionChanged: (nextSession: SessionResponse) => void; +}; + +type ComposeStepProps = { + task: string; + context: string; + deadline: string; + maxSteps: string; + onTaskChange: (value: string) => void; + onContextChange: (value: string) => void; + onDeadlineChange: (value: string) => void; + onMaxStepsChange: (value: string) => void; + onSubmit: (event: FormEvent) => void; + displayErrorMessage: string | null; + requiresCalendarReauth: boolean; + onCalendarReauth: () => void; + isReauthRunning: boolean; + phase: RunPhase; + isSessionLoading: boolean; + history: WorkflowRecord[]; + onSelectHistory: (item: WorkflowRecord) => void; +}; + +type RunningStepProps = { + workflowProgress: number; + phase: RunPhase; + waitingDots: string; + statusLabel: string; + displayErrorMessage: string | null; + requiresCalendarReauth: boolean; + onCalendarReauth: () => void; + isReauthRunning: boolean; +}; + +type ResultStepProps = { + phase: RunPhase; + statusLabel: string; + resultTab: ResultTab; + onResultTabChange: (tab: ResultTab) => void; + record: WorkflowRecord | null; + history: WorkflowRecord[]; + displayErrorMessage: string | null; + onStartNewTask: () => void; + onSelectHistory: (item: WorkflowRecord) => void; +}; + +export function AuthStep({ onSessionChanged }: AuthStepProps) { + return ( + + + まずGoogleでログインし、カレンダー連携権限を許可してください。 + 認証後はタスク入力画面に進みます。 + + + + ); +} + +export function ComposeStep({ + task, + context, + deadline, + maxSteps, + onTaskChange, + onContextChange, + onDeadlineChange, + onMaxStepsChange, + onSubmit, + displayErrorMessage, + requiresCalendarReauth, + onCalendarReauth, + isReauthRunning, + phase, + isSessionLoading, + history, + onSelectHistory, +}: ComposeStepProps) { + return ( + +
+ + + 細分化したいタスク +