diff --git a/.DS_Store b/.DS_Store new file mode 100644 index 0000000..a105a55 Binary files /dev/null and b/.DS_Store differ diff --git a/.github/.DS_Store b/.github/.DS_Store new file mode 100644 index 0000000..4bfc286 Binary files /dev/null and b/.github/.DS_Store differ diff --git a/README.md b/README.md index 169d834..bf1b11d 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,6 @@ -# プロダクト名 - +# ネボガード(NeboGuard) -![プロダクト名](https://kc3.me/cms/wp-content/uploads/2026/02/444e7120d5cdd74aa75f7a94bf8821a5-scaled.png) +![ネボガード(NeboGuard)](https://kc3.me/cms/wp-content/uploads/2026/02/444e7120d5cdd74aa75f7a94bf8821a5-scaled.png) diff --git a/backend/migrations/0004_morning_routine_settings.sql b/backend/migrations/0004_morning_routine_settings.sql new file mode 100644 index 0000000..0c2d041 --- /dev/null +++ b/backend/migrations/0004_morning_routine_settings.sql @@ -0,0 +1,6 @@ +create table if not exists "morning_routine_settings" ( + "user_id" text primary key, + "routine_json" text not null, + "created_at" text not null, + "updated_at" text not null +); diff --git a/backend/src/app.ts b/backend/src/app.ts index edfc123..a8ed8f1 100644 --- a/backend/src/app.ts +++ b/backend/src/app.ts @@ -4,6 +4,7 @@ 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 { registerMorningRoutineRoutes } from "./routes/morning-routine-routes"; import { registerRootRoutes } from "./routes/root-routes"; import { registerTaskRoutes } from "./routes/task-routes"; import { registerTransitRoutes } from "./routes/transit-routes"; @@ -53,6 +54,7 @@ export function createApp(): App { registerRootRoutes(app); registerAuthRoutes(app); registerBriefingRoutes(app); + registerMorningRoutineRoutes(app); registerCalendarRoutes(app); registerTaskRoutes(app); registerTransitRoutes(app); diff --git a/backend/src/features/morning-routine/morning-routine.service.ts b/backend/src/features/morning-routine/morning-routine.service.ts new file mode 100644 index 0000000..5928310 --- /dev/null +++ b/backend/src/features/morning-routine/morning-routine.service.ts @@ -0,0 +1,162 @@ +export type MorningRoutineItem = { + id: string; + label: string; + minutes: number; +}; + +type RoutineRow = { + routine_json: string; +}; + +const DEFAULT_MORNING_ROUTINE: MorningRoutineItem[] = [ + { id: "prepare", label: "身支度", minutes: 20 }, + { id: "breakfast", label: "朝食", minutes: 15 }, +]; + +function createRoutineItemId(): string { + if ( + typeof crypto !== "undefined" && + typeof crypto.randomUUID === "function" + ) { + return crypto.randomUUID(); + } + return `${Date.now()}-${Math.random().toString(36).slice(2, 10)}`; +} + +function clampMinutes(value: number): number { + if (!Number.isFinite(value)) { + return 0; + } + return Math.min(180, Math.max(0, Math.trunc(value))); +} + +function cloneItems(items: MorningRoutineItem[]): MorningRoutineItem[] { + return items.map((item) => ({ ...item })); +} + +function normalizeRoutineItems(value: unknown): MorningRoutineItem[] | null { + if (!Array.isArray(value)) { + return null; + } + + if (value.length === 0 || value.length > 20) { + return null; + } + + const normalized = value + .map((item) => { + if (!item || typeof item !== "object") { + return null; + } + + const candidate = item as { + id?: unknown; + label?: unknown; + minutes?: unknown; + }; + + const label = + typeof candidate.label === "string" ? candidate.label.trim() : ""; + if (label.length === 0 || label.length > 40) { + return null; + } + + const minutesRaw = + typeof candidate.minutes === "number" + ? candidate.minutes + : typeof candidate.minutes === "string" + ? Number(candidate.minutes) + : Number.NaN; + const minutes = Number.isFinite(minutesRaw) + ? clampMinutes(minutesRaw) + : 0; + + const id = + typeof candidate.id === "string" && candidate.id.trim().length > 0 + ? candidate.id.trim() + : createRoutineItemId(); + + return { id, label, minutes } satisfies MorningRoutineItem; + }) + .filter((item): item is MorningRoutineItem => item !== null); + + if (normalized.length === 0) { + return null; + } + + return normalized; +} + +function parseRoutineJson(json: string): MorningRoutineItem[] | null { + try { + return normalizeRoutineItems(JSON.parse(json)); + } catch { + return null; + } +} + +export function validateMorningRoutineItems( + value: unknown, +): MorningRoutineItem[] | null { + return normalizeRoutineItems(value); +} + +export async function getMorningRoutine( + db: D1Database, + userId: string, +): Promise { + const row = await db + .prepare( + ` + select routine_json + from morning_routine_settings + where user_id = ? + limit 1 + `, + ) + .bind(userId) + .first(); + + if (!row?.routine_json) { + return cloneItems(DEFAULT_MORNING_ROUTINE); + } + + const parsed = parseRoutineJson(row.routine_json); + if (!parsed) { + return cloneItems(DEFAULT_MORNING_ROUTINE); + } + + return parsed; +} + +export async function saveMorningRoutine( + db: D1Database, + userId: string, + items: MorningRoutineItem[], +): Promise { + const normalized = normalizeRoutineItems(items); + if (!normalized) { + throw new Error("Invalid morning routine items."); + } + + const now = new Date().toISOString(); + await db + .prepare( + ` + insert into morning_routine_settings ( + user_id, + routine_json, + created_at, + updated_at + ) + values (?, ?, ?, ?) + on conflict(user_id) do update set + routine_json = excluded.routine_json, + updated_at = excluded.updated_at + `, + ) + .bind(userId, JSON.stringify(normalized), now, now) + .run(); + + return normalized; +} diff --git a/backend/src/routes/morning-routine-routes.ts b/backend/src/routes/morning-routine-routes.ts new file mode 100644 index 0000000..58bb5f7 --- /dev/null +++ b/backend/src/routes/morning-routine-routes.ts @@ -0,0 +1,45 @@ +import { + getMorningRoutine, + saveMorningRoutine, + validateMorningRoutineItems, +} from "../features/morning-routine/morning-routine.service"; +import { getAuthSession } from "../lib/session"; +import type { App } from "../types/app"; + +export function registerMorningRoutineRoutes(app: App): void { + app.get("/briefing/routine", async (c) => { + const authSession = await getAuthSession(c); + if (!authSession) { + return c.json({ error: "Authentication required." }, 401); + } + + const items = await getMorningRoutine(c.env.AUTH_DB, authSession.user.id); + return c.json({ items }); + }); + + app.put("/briefing/routine", async (c) => { + const authSession = await getAuthSession(c); + if (!authSession) { + return c.json({ error: "Authentication required." }, 401); + } + + const body = await c.req.json().catch(() => null); + const items = validateMorningRoutineItems(body?.items); + if (!items) { + return c.json( + { + error: + "Request body must include non-empty `items` with { id?, label, minutes }.", + }, + 400, + ); + } + + const saved = await saveMorningRoutine( + c.env.AUTH_DB, + authSession.user.id, + items, + ); + return c.json({ items: saved }); + }); +} diff --git a/backend/src/routes/root-routes.ts b/backend/src/routes/root-routes.ts index 86c5238..462792e 100644 --- a/backend/src/routes/root-routes.ts +++ b/backend/src/routes/root-routes.ts @@ -7,6 +7,8 @@ export function registerRootRoutes(app: App): void { endpoints: [ "ALL /api/auth/*", "POST /briefing/morning", + "GET /briefing/routine", + "PUT /briefing/routine", "GET /calendar/today", "POST /transit/directions", "POST /tasks/decompose", diff --git a/d76869c4364b9cc4.PNG b/d76869c4364b9cc4.PNG new file mode 100644 index 0000000..dfa1d64 Binary files /dev/null and b/d76869c4364b9cc4.PNG differ diff --git a/frontend/src/app/dashboard/page.tsx b/frontend/src/app/dashboard/page.tsx index 886202b..3fc1736 100644 --- a/frontend/src/app/dashboard/page.tsx +++ b/frontend/src/app/dashboard/page.tsx @@ -4,14 +4,27 @@ import { Box, Button, Container, + Drawer, Grid, + GridItem, HStack, Input, + Portal, Stack, Text, } from "@chakra-ui/react"; -import { useEffect, useMemo, useState } from "react"; -import { fetchMorningBriefing } from "@/lib/backend-api"; +import NextLink from "next/link"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { + fetchMorningBriefing, + fetchMorningRoutine, + type MorningRoutineItem, + updateMorningRoutine, +} from "@/lib/backend-api"; +import { + getTaskWorkflowHistory, + type WorkflowRecord, +} from "@/lib/task-workflow-api"; type BriefingEvent = { id: string; @@ -65,10 +78,42 @@ type State = { errorType?: "unauthorized" | "unknown"; }; +type DecomposedTaskEvent = { + key: string; + taskInput: string; + summary: string; + subtaskTitle: string; + startAt: string; + endAt: string; +}; + +type AlarmStatus = "idle" | "scheduled" | "ringing"; + +const TODAY_EVENTS_LIMIT = 3; +const DECOMPOSED_EVENTS_LIMIT = 3; +const JST_OFFSET_MS = 9 * 60 * 60 * 1000; +const WAKEUP_ALARM_ENABLED_KEY = "dashboard:wakeup-alarm-enabled"; +const DEFAULT_MORNING_ROUTINE: MorningRoutineItem[] = [ + { id: "prepare", label: "身支度", minutes: 20 }, + { id: "breakfast", label: "朝食", minutes: 15 }, +]; + +function truncateText(value: string, maxLength: number): string { + if (maxLength <= 0) { + return ""; + } + if (value.length <= maxLength) { + return value; + } + + const safe = Math.max(1, Math.trunc(maxLength) - 1); + return `${value.slice(0, safe).trimEnd()}…`; +} + 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 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}`; @@ -81,6 +126,185 @@ function eventDurationMinutes(start: string, end: string): number { return Math.max(0, Math.round((e.getTime() - s.getTime()) / 60000)); } +function collectUpcomingDecomposedEvents( + records: WorkflowRecord[], + limit: number, +): DecomposedTaskEvent[] { + const now = Date.now(); + const flattened = records.flatMap((record) => + (record.calendarOutput?.createdEvents ?? []).map((eventItem) => ({ + key: `${record.workflowId}:${eventItem.id}`, + taskInput: record.taskInput, + summary: eventItem.summary, + subtaskTitle: eventItem.subtaskTitle, + startAt: eventItem.startAt, + endAt: eventItem.endAt, + })), + ); + + return flattened + .filter((eventItem) => { + const startAtMs = new Date(eventItem.startAt).getTime(); + return Number.isFinite(startAtMs) && startAtMs >= now; + }) + .sort( + (a, b) => new Date(a.startAt).getTime() - new Date(b.startAt).getTime(), + ) + .slice(0, Math.max(1, Math.trunc(limit))); +} + +function createRoutineItemId(): string { + if ( + typeof crypto !== "undefined" && + typeof crypto.randomUUID === "function" + ) { + return crypto.randomUUID(); + } + return `${Date.now()}-${Math.random().toString(36).slice(2, 10)}`; +} + +function clampMinutes(value: number): number { + if (!Number.isFinite(value)) { + return 0; + } + return Math.min(180, Math.max(0, Math.trunc(value))); +} + +function normalizeRoutineItems(value: unknown): MorningRoutineItem[] | null { + if (!Array.isArray(value)) { + return null; + } + + const normalized = value + .map((item) => { + if (!item || typeof item !== "object") { + return null; + } + + const candidate = item as { + id?: unknown; + label?: unknown; + minutes?: unknown; + }; + const label = + typeof candidate.label === "string" && candidate.label.trim().length > 0 + ? candidate.label.trim() + : null; + if (!label) { + return null; + } + + const minutes = + typeof candidate.minutes === "number" + ? clampMinutes(candidate.minutes) + : 0; + const id = + typeof candidate.id === "string" && candidate.id.trim().length > 0 + ? candidate.id + : createRoutineItemId(); + + return { id, label, minutes } satisfies MorningRoutineItem; + }) + .filter((item): item is MorningRoutineItem => item !== null); + + if (normalized.length === 0) { + return null; + } + + return normalized; +} + +function inferTransferCount(summary: string | null | undefined): number | null { + if (!summary || summary.trim().length === 0) { + return null; + } + + const patterns = [ + /乗り換え\s*(\d+)\s*回/u, + /乗換(?:え)?\s*(\d+)\s*回/u, + /(\d+)\s*回乗換/u, + ]; + + for (const pattern of patterns) { + const matched = pattern.exec(summary); + if (!matched?.[1]) { + continue; + } + + const count = Number(matched[1]); + if (Number.isFinite(count)) { + return count; + } + } + + return null; +} + +function shortenSpeechDestination(value: string): string { + const normalized = value + .replace(/〒\s*\d{3}-?\d{4}\s*/gu, "") + .replace(/\s+/gu, " ") + .trim(); + if (normalized.length === 0) { + return "目的地未設定"; + } + + const stationMatch = normalized.match(/([^\s、,]{1,12}駅)/u); + if (stationMatch?.[1]) { + return stationMatch[1]; + } + + const landmarkMatch = normalized.match( + /([^\s、,]{1,14}(?:空港|ビル|大学|病院|公園|会館))/u, + ); + if (landmarkMatch?.[1]) { + return landmarkMatch[1]; + } + + const firstChunk = normalized.split(/[、,/]/u)[0]?.trim(); + if (!firstChunk) { + return "目的地未設定"; + } + + if (firstChunk.length <= 14) { + return firstChunk; + } + + return `${firstChunk.slice(0, 14)}付近`; +} + +function buildWakeupSpeech( + urgent: EventBriefing | null, + weather: WeatherInfo | null, +): string { + if (!urgent) { + return "本日の予定はまだ取得できていません。"; + } + + const startAt = toJstHHmm(urgent.event.start); + const destination = + urgent.destination?.trim() || + urgent.event.location?.trim() || + "目的地未設定"; + const speechDestination = shortenSpeechDestination(destination); + const transferCount = inferTransferCount(urgent.route?.summary ?? null); + const transferText = + transferCount !== null + ? `乗り換え${transferCount}回` + : urgent.route?.summary?.trim() + ? urgent.route.summary + : "乗り換え情報なし"; + const transitText = + urgent.transitMinutes > 0 + ? `${urgent.transitMinutes}分` + : "移動時間は未取得"; + const umbrellaText = weather?.umbrellaNeeded + ? "雨のため傘を持ってください。" + : "傘は不要です。"; + + return `今日は${startAt}から${speechDestination}。${transferText}、${transitText}。${urgent.leaveBy}出発推奨。${umbrellaText}`; +} + export default function DashboardPage() { const [state, setState] = useState({ status: "loading", @@ -90,34 +314,281 @@ export default function DashboardPage() { const [locationInput, setLocationInput] = useState("大阪駅"); const [currentLocation, setCurrentLocation] = useState("大阪駅"); const [forceRefresh, setForceRefresh] = useState(false); + const [upcomingTaskEvents, setUpcomingTaskEvents] = useState< + DecomposedTaskEvent[] + >([]); + const [taskEventsStatus, setTaskEventsStatus] = useState< + "loading" | "ready" | "error" + >("loading"); + const [alarmEnabled, setAlarmEnabled] = useState(false); + const [alarmStatus, setAlarmStatus] = useState("idle"); + const [alarmTargetMs, setAlarmTargetMs] = useState(null); + const [alarmMessage, setAlarmMessage] = useState(null); + const [lastGuidanceText, setLastGuidanceText] = useState(null); + const [morningRoutine, setMorningRoutine] = useState( + DEFAULT_MORNING_ROUTINE, + ); + const [routineStatus, setRoutineStatus] = useState< + "loading" | "ready" | "error" + >("loading"); + const [routineError, setRoutineError] = useState(null); + const [isRoutineDrawerOpen, setIsRoutineDrawerOpen] = useState(false); + const [routineDraft, setRoutineDraft] = useState( + DEFAULT_MORNING_ROUTINE, + ); + const [isRoutineSaving, setIsRoutineSaving] = useState(false); + const [routineEditorError, setRoutineEditorError] = useState( + null, + ); + const alarmTimeoutRef = useRef(null); + const alarmIntervalRef = useRef(null); + const audioContextRef = useRef(null); + const routineTotalMinutes = useMemo(() => { + const total = morningRoutine.reduce( + (sum, item) => sum + clampMinutes(item.minutes), + 0, + ); + return Math.max(1, Math.min(300, total)); + }, [morningRoutine]); + const routineDraftTotalMinutes = useMemo(() => { + const total = routineDraft.reduce( + (sum, item) => sum + clampMinutes(item.minutes), + 0, + ); + return Math.max(1, Math.min(300, total)); + }, [routineDraft]); useEffect(() => { + let active = true; + setRoutineStatus("loading"); + setRoutineError(null); + + fetchMorningRoutine() + .then((response) => { + if (!active) { + return; + } + const normalized = normalizeRoutineItems(response.items); + if (normalized) { + setMorningRoutine(normalized); + } else { + setMorningRoutine(DEFAULT_MORNING_ROUTINE); + } + setRoutineStatus("ready"); + }) + .catch(() => { + if (!active) { + return; + } + setMorningRoutine(DEFAULT_MORNING_ROUTINE); + setRoutineStatus("error"); + setRoutineError( + "朝ルーティンを取得できませんでした。既定値を使用します。", + ); + }); + + return () => { + active = false; + }; + }, []); + + useEffect(() => { + if (routineStatus === "loading") { + return; + } + let active = true; setState((prev) => ({ ...prev, status: "loading", errorType: undefined })); + setTaskEventsStatus("loading"); + + Promise.allSettled([ + fetchMorningBriefing(currentLocation, routineTotalMinutes, forceRefresh), + getTaskWorkflowHistory(50), + ]) + .then(([briefingResult, workflowResult]) => { + if (!active) { + return; + } - fetchMorningBriefing(currentLocation, 30, forceRefresh) - .then((raw) => { - if (!active) return; - setState({ status: "ready", data: raw as MorningBriefingResult }); - setForceRefresh(false); + if (briefingResult.status === "fulfilled") { + setState({ + status: "ready", + data: briefingResult.value as MorningBriefingResult, + }); + } else { + const message = + briefingResult.reason instanceof Error + ? briefingResult.reason.message + : ""; + const isUnauthorized = + message.includes(" 401 ") || message.includes("401"); + setState({ + status: "error", + data: null, + errorType: isUnauthorized ? "unauthorized" : "unknown", + }); + } + + if (workflowResult.status === "fulfilled") { + setUpcomingTaskEvents( + collectUpcomingDecomposedEvents( + workflowResult.value.items, + DECOMPOSED_EVENTS_LIMIT, + ), + ); + setTaskEventsStatus("ready"); + } else { + setUpcomingTaskEvents([]); + setTaskEventsStatus("error"); + } }) - .catch((error: unknown) => { - if (!active) return; - const message = error instanceof Error ? error.message : ""; - const isUnauthorized = - message.includes(" 401 ") || message.includes("401"); - setState({ - status: "error", - data: null, - errorType: isUnauthorized ? "unauthorized" : "unknown", - }); - setForceRefresh(false); + .finally(() => { + if (active) { + setForceRefresh(false); + } }); return () => { active = false; }; - }, [currentLocation, forceRefresh]); + }, [currentLocation, forceRefresh, routineStatus, routineTotalMinutes]); + + const clearAlarmTimeout = useCallback(() => { + if (alarmTimeoutRef.current !== null) { + window.clearTimeout(alarmTimeoutRef.current); + alarmTimeoutRef.current = null; + } + }, []); + + const stopAlarmSound = useCallback(() => { + if (alarmIntervalRef.current !== null) { + window.clearInterval(alarmIntervalRef.current); + alarmIntervalRef.current = null; + } + + if (typeof navigator !== "undefined" && "vibrate" in navigator) { + navigator.vibrate(0); + } + }, []); + + const ensureAudioContext = + useCallback(async (): Promise => { + if (typeof window === "undefined") { + return null; + } + + const w = window as Window & { webkitAudioContext?: typeof AudioContext }; + const AudioContextCtor = globalThis.AudioContext ?? w.webkitAudioContext; + if (!AudioContextCtor) { + return null; + } + + let context = audioContextRef.current; + if (!context) { + context = new AudioContextCtor(); + audioContextRef.current = context; + } + + if (context.state === "suspended") { + await context.resume().catch(() => undefined); + } + + if (context.state !== "running") { + return null; + } + + return context; + }, []); + + const playAlarmTone = useCallback(async (): Promise => { + const context = await ensureAudioContext(); + if (!context) { + return false; + } + + const now = context.currentTime; + const oscillator = context.createOscillator(); + const gain = context.createGain(); + + oscillator.type = "square"; + oscillator.frequency.setValueAtTime(880, now); + gain.gain.setValueAtTime(0.0001, now); + gain.gain.exponentialRampToValueAtTime(0.42, now + 0.02); + gain.gain.exponentialRampToValueAtTime(0.0001, now + 0.24); + + oscillator.connect(gain); + gain.connect(context.destination); + oscillator.start(now); + oscillator.stop(now + 0.26); + + return true; + }, [ensureAudioContext]); + + const speakBriefing = useCallback((text: string): boolean => { + if ( + typeof window === "undefined" || + !("speechSynthesis" in window) || + typeof SpeechSynthesisUtterance === "undefined" + ) { + return false; + } + + const synth = window.speechSynthesis; + synth.cancel(); + + const utterance = new SpeechSynthesisUtterance(text); + utterance.lang = "ja-JP"; + utterance.rate = 1; + utterance.pitch = 1; + + const jaVoice = synth + .getVoices() + .find((voice) => voice.lang.toLowerCase().startsWith("ja")); + if (jaVoice) { + utterance.voice = jaVoice; + } + + synth.speak(utterance); + return true; + }, []); + + useEffect(() => { + if (typeof window === "undefined") { + return; + } + + const saved = window.localStorage.getItem(WAKEUP_ALARM_ENABLED_KEY); + if (saved === "1") { + setAlarmEnabled(true); + } + }, []); + + useEffect(() => { + if (typeof window === "undefined") { + return; + } + window.localStorage.setItem( + WAKEUP_ALARM_ENABLED_KEY, + alarmEnabled ? "1" : "0", + ); + }, [alarmEnabled]); + + useEffect(() => { + return () => { + clearAlarmTimeout(); + stopAlarmSound(); + + if (typeof window !== "undefined" && "speechSynthesis" in window) { + window.speechSynthesis.cancel(); + } + + const ctx = audioContextRef.current; + audioContextRef.current = null; + if (ctx) { + void ctx.close().catch(() => undefined); + } + }; + }, [clearAlarmTimeout, stopAlarmSound]); const urgent = state.data?.urgent ?? null; const departure = urgent?.leaveBy ?? "--:--"; @@ -126,6 +597,275 @@ export default function DashboardPage() { const transitSummary = urgent?.route?.summary ?? "経路情報なし"; const transitMinutes = urgent?.transitMinutes ?? 0; const weather = state.data?.weather ?? null; + const wakeupTiming = useMemo(() => { + if (!urgent) { + return null; + } + + const eventStartMs = new Date(urgent.event.start).getTime(); + if (!Number.isFinite(eventStartMs)) { + return null; + } + + const safeTransitMinutes = Math.max(0, Math.trunc(urgent.transitMinutes)); + const leaveMs = eventStartMs - safeTransitMinutes * 60 * 1000; + const wakeMs = leaveMs - routineTotalMinutes * 60 * 1000; + if (!Number.isFinite(wakeMs)) { + return null; + } + + return { + leaveMs, + wakeMs, + wakeLabel: toJstHHmm(new Date(wakeMs).toISOString()), + }; + }, [routineTotalMinutes, urgent]); + const wakeUpTime = wakeupTiming?.wakeLabel ?? urgent?.wakeUpBy ?? "--:--"; + const wakeupAlarmPlan = useMemo(() => { + if (!wakeupTiming) { + return null; + } + return { targetMs: wakeupTiming.wakeMs }; + }, [wakeupTiming]); + const routineTimeline = useMemo(() => { + const nowMs = Date.now(); + if (!wakeupTiming) { + return morningRoutine.map((item) => ({ + ...item, + startMs: null as number | null, + endMs: null as number | null, + status: "upcoming" as const, + })); + } + + let cursorMs = wakeupTiming.wakeMs; + const withTime = morningRoutine.map((item) => { + const durationMs = clampMinutes(item.minutes) * 60 * 1000; + const startMs = cursorMs; + const endMs = cursorMs + durationMs; + cursorMs = endMs; + + return { + ...item, + startMs, + endMs, + status: "upcoming" as const, + }; + }); + + const nextIndex = withTime.findIndex((item) => (item.endMs ?? 0) > nowMs); + if (nextIndex === -1) { + return withTime.map((item) => ({ ...item, status: "finished" as const })); + } + + return withTime.map((item, index) => ({ + ...item, + status: + index < nextIndex + ? ("finished" as const) + : index === nextIndex + ? ("next" as const) + : ("upcoming" as const), + })); + }, [morningRoutine, wakeupTiming]); + const nextRoutine = useMemo( + () => routineTimeline.find((item) => item.status === "next") ?? null, + [routineTimeline], + ); + + const startAlarmSound = useCallback(async () => { + clearAlarmTimeout(); + stopAlarmSound(); + setAlarmStatus("ringing"); + setAlarmMessage(null); + + const firstTonePlayed = await playAlarmTone(); + if (!firstTonePlayed) { + setAlarmMessage( + "アラーム音を再生できません。ブラウザで音声再生を許可してください。", + ); + } else { + alarmIntervalRef.current = window.setInterval(() => { + void playAlarmTone(); + }, 900); + } + + if (typeof navigator !== "undefined" && "vibrate" in navigator) { + navigator.vibrate([260, 120, 260, 120, 480]); + } + + if ("Notification" in window && Notification.permission === "granted") { + try { + new Notification("起床アラーム", { + body: `${wakeUpTime} です。起きる時間です。`, + }); + } catch { + // Ignore notification failures. + } + } + }, [clearAlarmTimeout, playAlarmTone, stopAlarmSound, wakeUpTime]); + + const handleEnableAlarm = useCallback(async () => { + setAlarmMessage(null); + const context = await ensureAudioContext(); + if (!context) { + setAlarmMessage( + "このブラウザではアラーム音を有効化できません。別ブラウザをお試しください。", + ); + return; + } + + if ("Notification" in window && Notification.permission === "default") { + void Notification.requestPermission().catch(() => undefined); + } + + setAlarmEnabled(true); + }, [ensureAudioContext]); + + const handleDisableAlarm = useCallback(() => { + setAlarmEnabled(false); + setAlarmStatus("idle"); + setAlarmTargetMs(null); + clearAlarmTimeout(); + stopAlarmSound(); + }, [clearAlarmTimeout, stopAlarmSound]); + + const handleTestAlarm = useCallback(() => { + void startAlarmSound(); + }, [startAlarmSound]); + + const handleStopAlarmAndSpeak = useCallback(() => { + stopAlarmSound(); + setAlarmStatus("idle"); + + const guidance = buildWakeupSpeech(urgent, weather); + setLastGuidanceText(guidance); + const spoken = speakBriefing(guidance); + if (!spoken) { + setAlarmMessage( + "読み上げに対応していないブラウザです。案内文のみ表示します。", + ); + } + }, [speakBriefing, stopAlarmSound, urgent, weather]); + + const handleOpenRoutineEditor = useCallback(() => { + setRoutineDraft(morningRoutine.map((item) => ({ ...item }))); + setRoutineEditorError(null); + setIsRoutineDrawerOpen(true); + }, [morningRoutine]); + + const handleRoutineLabelChange = useCallback((id: string, value: string) => { + setRoutineDraft((prev) => + prev.map((item) => (item.id === id ? { ...item, label: value } : item)), + ); + }, []); + + const handleRoutineMinutesChange = useCallback( + (id: string, value: string) => { + const parsed = Number(value); + const minutes = Number.isFinite(parsed) ? clampMinutes(parsed) : 0; + setRoutineDraft((prev) => + prev.map((item) => (item.id === id ? { ...item, minutes } : item)), + ); + }, + [], + ); + + const handleAddRoutineItem = useCallback(() => { + setRoutineDraft((prev) => [ + ...prev, + { + id: createRoutineItemId(), + label: "追加ルーティン", + minutes: 10, + }, + ]); + }, []); + + const handleRemoveRoutineItem = useCallback((id: string) => { + setRoutineDraft((prev) => { + if (prev.length <= 1) { + return prev; + } + return prev.filter((item) => item.id !== id); + }); + }, []); + + const handleSaveRoutine = useCallback(async () => { + const normalizedDraft = normalizeRoutineItems(routineDraft); + if (!normalizedDraft) { + setRoutineEditorError( + "ルーティン項目を1件以上設定してください(ラベル必須)。", + ); + return; + } + + setRoutineEditorError(null); + setIsRoutineSaving(true); + try { + const response = await updateMorningRoutine(normalizedDraft); + const saved = normalizeRoutineItems(response.items) ?? normalizedDraft; + setMorningRoutine(saved); + setRoutineDraft(saved.map((item) => ({ ...item }))); + setIsRoutineDrawerOpen(false); + setRoutineStatus("ready"); + setRoutineError(null); + setForceRefresh(true); + } catch { + setRoutineEditorError( + "保存に失敗しました。時間をおいて再試行してください。", + ); + } finally { + setIsRoutineSaving(false); + } + }, [routineDraft]); + + useEffect(() => { + clearAlarmTimeout(); + + if (!alarmEnabled || !wakeupAlarmPlan) { + setAlarmTargetMs(null); + setAlarmStatus((prev) => (prev === "ringing" ? prev : "idle")); + return; + } + + setAlarmTargetMs(wakeupAlarmPlan.targetMs); + const delayMs = wakeupAlarmPlan.targetMs - Date.now(); + + if (delayMs <= 0) { + setAlarmStatus((prev) => (prev === "ringing" ? prev : "idle")); + return; + } + + setAlarmStatus((prev) => (prev === "ringing" ? prev : "scheduled")); + alarmTimeoutRef.current = window.setTimeout(() => { + void startAlarmSound(); + }, delayMs); + + return () => { + clearAlarmTimeout(); + }; + }, [alarmEnabled, clearAlarmTimeout, startAlarmSound, wakeupAlarmPlan]); + + const alarmStatusLabel = useMemo(() => { + if (!alarmEnabled) { + return "OFF"; + } + if (!wakeupAlarmPlan) { + return "予定なし"; + } + if (alarmStatus === "ringing") { + return "鳴動中"; + } + if (alarmStatus === "scheduled") { + const scheduledTime = + alarmTargetMs !== null + ? toJstHHmm(new Date(alarmTargetMs).toISOString()) + : wakeUpTime; + return `${scheduledTime} に鳴動予定`; + } + return "待機中"; + }, [alarmEnabled, alarmStatus, alarmTargetMs, wakeUpTime, wakeupAlarmPlan]); const todayEvents = useMemo(() => { const byId = new Map(); @@ -152,267 +892,734 @@ export default function DashboardPage() { }; return ( - - - - - setLocationInput(e.target.value)} - onKeyDown={(e) => { - if (e.key === "Enter") { - applyLocation(); - } - }} - flex="1" - minW={{ base: "140px", md: "240px" }} - bg="white" - borderColor="gray.300" - size="md" - placeholder="現在地(例: 大阪駅)" - /> - - - - - - 出発まで - - - {departure} - - - 遅刻リスク{" "} - = 60 ? "red.600" : "green.700"} - fontWeight="semibold" - > - {lateRisk}% - - - - 現在地: {currentLocation} - - + + - + + 推奨出発 {departure} + + + - - - 降水量 {weather ? `${weather.precipitationMm} mm/h` : "--"} - - {weather?.locationName ?? "-"} - - {weather?.reason ?? "天気情報なし"} - - + + + + 今日の予定 + + + {todayEvents.length === 0 ? ( + + 予定はありません + + ) : ( + todayEvents.slice(0, TODAY_EVENTS_LIMIT).map((event) => ( + + + + + + {toJstHHmm(event.start)} + + + {eventDurationMinutes(event.start, event.end)}分 + + + + {truncateText(event.summary, 26)} + + + {truncateText(event.location ?? "場所未設定", 24)} + + + + )) + )} + + + + + + + + タスク細分化の予定 + + + {taskEventsStatus === "loading" ? ( + + 読み込み中... + + ) : upcomingTaskEvents.length === 0 ? ( + + 直近の細分化予定はありません + + ) : ( + upcomingTaskEvents.map((eventItem) => ( + + + + + + {toJstHHmm(eventItem.startAt)} + + + {eventDurationMinutes( + eventItem.startAt, + eventItem.endAt, + )} + 分 + + + + {truncateText(eventItem.subtaskTitle, 24)} + + + {truncateText(eventItem.summary, 34)} + + + 元タスク: {truncateText(eventItem.taskInput, 18)} + + + + )) + )} + {taskEventsStatus === "error" && ( + + 細分化予定の取得に失敗しました。 + + )} + + + + + + + + 天気 + + + + + {weather?.umbrellaNeeded ? "☔" : "☀️"} + + + + {weather + ? `${weather.precipitationProbability}%` + : "--%"} + + + {weather?.umbrellaNeeded ? "傘あり" : "晴れ"} + + + + + + + 降水量{" "} + {weather ? `${weather.precipitationMm} mm/h` : "--"} + + + {weather?.locationName ?? "-"} + + + {weather?.reason ?? "天気情報なし"} + + + + + - - - {state.status === "error" && ( - - - {state.errorType === "unauthorized" - ? "ログインが必要です。" - : "API取得に失敗しました。ログイン状態とバックエンド起動を確認してください。"} - - {state.errorType === "unauthorized" && ( + + {state.status === "error" && ( + + + + {state.errorType === "unauthorized" + ? "ログインが必要です。" + : "API取得に失敗しました。ログイン状態とバックエンド起動を確認してください。"} + + {state.errorType === "unauthorized" && ( + + )} + + + )} + + + + + { + setIsRoutineDrawerOpen(details.open); + if (!details.open) { + setRoutineEditorError(null); + } + }} + size={{ base: "full", md: "md" }} + > + + + + + + 朝ルーティン編集 + + 起床から出発までに必要な項目と時間を設定します。 + + + + + {routineDraft.map((item) => ( + + + handleRoutineLabelChange(item.id, e.target.value) + } + bg="white" + minW={{ base: "100%", sm: "220px" }} + maxW={{ base: "100%", sm: "260px" }} + /> + + handleRoutineMinutesChange(item.id, e.target.value) + } + w={{ base: "104px", sm: "90px" }} + bg="white" + /> + + 分 + + + + ))} + + + + + 合計 {routineDraftTotalMinutes}分 + + + + {routineEditorError ? ( + + {routineEditorError} + + ) : null} + + + + + + - )} - - )} - - + + + + + ); } @@ -430,9 +1637,11 @@ function Card({ borderRadius="2xl" p={{ base: 4, md: 5 }} minH={minH} + h="full" borderWidth="1px" borderColor="gray.200" boxShadow="sm" + overflow="hidden" > {children} diff --git a/frontend/src/app/favicon.ico b/frontend/src/app/favicon.ico index 718d6fe..fd5b413 100644 Binary files a/frontend/src/app/favicon.ico and b/frontend/src/app/favicon.ico differ diff --git a/frontend/src/app/icon.png b/frontend/src/app/icon.png new file mode 100644 index 0000000..cb0a8d0 Binary files /dev/null and b/frontend/src/app/icon.png differ diff --git a/frontend/src/app/layout.tsx b/frontend/src/app/layout.tsx index 49757ac..c2b5418 100644 --- a/frontend/src/app/layout.tsx +++ b/frontend/src/app/layout.tsx @@ -14,8 +14,9 @@ const geistMono = Geist_Mono({ }); export const metadata: Metadata = { - title: "Task Decomposer", - description: "Task decomposition tool for the hackathon project", + title: "ネボガード | NeboGuard", + description: + "ネボガード(NeboGuard)は、予定・移動・天気を統合して朝の判断を支援するアプリです。", }; export default function RootLayout({ diff --git a/frontend/src/app/page.tsx b/frontend/src/app/page.tsx index f94eff5..dd931b9 100644 --- a/frontend/src/app/page.tsx +++ b/frontend/src/app/page.tsx @@ -15,25 +15,25 @@ import { } from "@chakra-ui/react"; import NextLink from "next/link"; -const modeItems = [ +const flowItems = [ { - title: "設計モード", - subtitle: "夜に目標を行動へ変換", + title: "予定とタスクをまとめる", + subtitle: "大きなタスクを、実行できる単位へ", description: - "大きな目標をサブタスクへ細分化し、期限と所要時間を付与してカレンダーまで反映します。", + "数日かかるタスクもサブタスクに分け、期限と所要時間を付けて着手しやすい形にします。", href: "/task-decomp", - actionLabel: "タスクを分解する", - badge: "Step 1", + actionLabel: "タスクを細分化する", + badge: "タスク", color: "teal", }, { - title: "運用モード", - subtitle: "朝に現実へ再接続", + title: "朝に必要な情報を確認", + subtitle: "予定・移動・天気を一画面に集約", description: - "今日の予定と進捗を見ながら、次にやることを迷わず決められる状態へ整えます。", + "今日の予定と移動時間、天気をまとめて確認し、迷わず次の一歩を決められる状態にします。", href: "/dashboard", - actionLabel: "今日の実行を見る", - badge: "Step 2", + actionLabel: "朝の確認をする", + badge: "朝", color: "blue", }, ] as const; @@ -53,22 +53,52 @@ export default function HomePage() { - - - - Execution OS - - - Plan to Action - - - - 計画と実行の断絶を埋める + + + + ネボガード + + NeboGuard + + + + + 朝の判断を、1画面で。 - - このプロダクトが解くのは「将来の目標を、今日の行動に変換し続けられない問題」です。 - 必要なのは完璧な計画ではなく、状況が崩れても次の一歩が出る状態です。 + + 予定・移動・天気をまとめて、出発の判断を一文で提示します。情報を探し回らず、 + そのまま行動に移れる朝をつくるためのアプリです。 + + + + - - 共通課題 - - タスクが多いこと自体ではなく、毎日の予定変化のたびに優先順位を再判断する認知負荷が大きいこと。 + + + このアプリで減らせる迷い + + + 朝に必要な情報は多くありません。出発時刻、天気、移動の乱れを素早く判断できる状態を目指します。 - - - 目標はあるが、今日やることに落ちない + + + 予定ごとの出発時刻をその場で計算しなくてよい - - 予定変更で計画が崩れると、再構築に時間を使う + + 複数アプリを開いて情報を照合しなくてよい - - 結果として着手が遅れ、継続しづらくなる + + 「今すぐ何をすべきか」を一文で確認できる @@ -100,7 +152,7 @@ export default function HomePage() { - {modeItems.map((item) => ( + {flowItems.map((item) => ( - - + + {item.badge} - {item.title} - + + {item.title} + + {item.subtitle} - - {item.description} - diff --git a/frontend/src/app/privacy/page.tsx b/frontend/src/app/privacy/page.tsx index 2363331..b33d155 100644 --- a/frontend/src/app/privacy/page.tsx +++ b/frontend/src/app/privacy/page.tsx @@ -23,7 +23,7 @@ export default function PrivacyPage() { - プライバシーポリシー + ネボガード(NeboGuard)プライバシーポリシー 最終更新日: {LAST_UPDATED} @@ -38,7 +38,7 @@ export default function PrivacyPage() { > - 本サービスは、ハッカソンで開発した Google + ネボガード(以下「本サービス」)は、ハッカソンで開発した Google カレンダー予定の細分化支援のために、以下の情報を取り扱います。 diff --git a/frontend/src/app/terms/page.tsx b/frontend/src/app/terms/page.tsx index c360bd6..e9afba8 100644 --- a/frontend/src/app/terms/page.tsx +++ b/frontend/src/app/terms/page.tsx @@ -23,7 +23,7 @@ export default function TermsPage() { - 利用規約 + ネボガード(NeboGuard)利用規約 最終更新日: {LAST_UPDATED} @@ -38,7 +38,7 @@ export default function TermsPage() { > - 本サービス(以下「本サービス」)は、ハッカソンで開発した Google + ネボガード(以下「本サービス」)は、ハッカソンで開発した Google カレンダー予定の細分化支援を目的とする試作版です。 diff --git a/frontend/src/lib/backend-api.ts b/frontend/src/lib/backend-api.ts index 310c226..db97ed0 100644 --- a/frontend/src/lib/backend-api.ts +++ b/frontend/src/lib/backend-api.ts @@ -96,3 +96,41 @@ export async function fetchMorningBriefing( throw new Error(`Briefing API: ${res.status} ${await res.text()}`); return res.json(); } + +// --------------------------------------------------------------------------- +// Morning Routine (D1 persisted) +// --------------------------------------------------------------------------- + +export type MorningRoutineItem = { + id: string; + label: string; + minutes: number; +}; + +export type MorningRoutineResponse = { + items: MorningRoutineItem[]; +}; + +export async function fetchMorningRoutine(): Promise { + const res = await fetch(endpoint("/briefing/routine"), { + method: "GET", + credentials: "include", + }); + if (!res.ok) + throw new Error(`Routine API: ${res.status} ${await res.text()}`); + return (await res.json()) as MorningRoutineResponse; +} + +export async function updateMorningRoutine( + items: MorningRoutineItem[], +): Promise { + const res = await fetch(endpoint("/briefing/routine"), { + method: "PUT", + credentials: "include", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ items }), + }); + if (!res.ok) + throw new Error(`Routine API: ${res.status} ${await res.text()}`); + return (await res.json()) as MorningRoutineResponse; +}