From c8a968974af483a63e6541ef5a9c97ee9635694e Mon Sep 17 00:00:00 2001 From: nenrin Date: Sun, 22 Feb 2026 11:29:23 +0900 Subject: [PATCH 1/7] Refine dashboard widget layout for compact task events --- frontend/src/app/dashboard/page.tsx | 245 +++++++++++++++++++++++++--- 1 file changed, 218 insertions(+), 27 deletions(-) diff --git a/frontend/src/app/dashboard/page.tsx b/frontend/src/app/dashboard/page.tsx index 886202b..49b8bde 100644 --- a/frontend/src/app/dashboard/page.tsx +++ b/frontend/src/app/dashboard/page.tsx @@ -12,6 +12,10 @@ import { } from "@chakra-ui/react"; import { useEffect, useMemo, useState } from "react"; import { fetchMorningBriefing } from "@/lib/backend-api"; +import { + getTaskWorkflowHistory, + type WorkflowRecord, +} from "@/lib/task-workflow-api"; type BriefingEvent = { id: string; @@ -65,6 +69,30 @@ type State = { errorType?: "unauthorized" | "unknown"; }; +type DecomposedTaskEvent = { + key: string; + taskInput: string; + summary: string; + subtaskTitle: string; + startAt: string; + endAt: string; +}; + +const TODAY_EVENTS_LIMIT = 3; +const DECOMPOSED_EVENTS_LIMIT = 3; + +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 "--:--"; @@ -81,6 +109,33 @@ 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))); +} + export default function DashboardPage() { const [state, setState] = useState({ status: "loading", @@ -90,28 +145,63 @@ 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"); useEffect(() => { let active = true; setState((prev) => ({ ...prev, status: "loading", errorType: undefined })); + setTaskEventsStatus("loading"); - fetchMorningBriefing(currentLocation, 30, forceRefresh) - .then((raw) => { - if (!active) return; - setState({ status: "ready", data: raw as MorningBriefingResult }); - setForceRefresh(false); + Promise.allSettled([ + fetchMorningBriefing(currentLocation, 30, forceRefresh), + getTaskWorkflowHistory(50), + ]) + .then(([briefingResult, workflowResult]) => { + if (!active) { + return; + } + + 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 () => { @@ -309,7 +399,7 @@ export default function DashboardPage() { 予定はありません ) : ( - todayEvents.slice(0, 3).map((event) => ( + todayEvents.slice(0, TODAY_EVENTS_LIMIT).map((event) => ( + + + {toJstHHmm(event.start)} + + + {eventDurationMinutes(event.start, event.end)}分 + + + {truncateText(event.summary, 26)} + + - {toJstHHmm(event.start)} + {truncateText(event.location ?? "場所未設定", 22)} + + + + )) + )} + + + + + + タスク細分化の予定 + + + {taskEventsStatus === "loading" ? ( + + 読み込み中... + + ) : upcomingTaskEvents.length === 0 ? ( + + 直近の細分化予定はありません + + ) : ( + upcomingTaskEvents.map((eventItem) => ( + + + + - {event.summary} + {toJstHHmm(eventItem.startAt)} + + + {eventDurationMinutes( + eventItem.startAt, + eventItem.endAt, + )} + 分 + + + {truncateText(eventItem.subtaskTitle, 24)} - {event.location ?? "場所未設定"} /{" "} - {eventDurationMinutes(event.start, event.end)}分 + {truncateText(eventItem.summary, 36)} + + + 元タスク: {truncateText(eventItem.taskInput, 20)} )) )} + {taskEventsStatus === "error" && ( + + 細分化予定の取得に失敗しました。 + + )} From 984349116d44f5619672afa94ce60254d4ef7edd Mon Sep 17 00:00:00 2001 From: nenrin Date: Sun, 22 Feb 2026 11:40:11 +0900 Subject: [PATCH 2/7] Align dashboard background with app gradient theme --- frontend/src/app/dashboard/page.tsx | 743 +++++++++++++++------------- 1 file changed, 411 insertions(+), 332 deletions(-) diff --git a/frontend/src/app/dashboard/page.tsx b/frontend/src/app/dashboard/page.tsx index 49b8bde..a19d2bb 100644 --- a/frontend/src/app/dashboard/page.tsx +++ b/frontend/src/app/dashboard/page.tsx @@ -5,6 +5,7 @@ import { Button, Container, Grid, + GridItem, HStack, Input, Stack, @@ -242,367 +243,443 @@ 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} - - - - - - - 交通 - - + - - - - 今日の予定 - - - {todayEvents.length === 0 ? ( - - 予定はありません - - ) : ( - todayEvents.slice(0, TODAY_EVENTS_LIMIT).map((event) => ( - - - - + + + + + + 出発まで + + + {departure} + + + + 遅刻リスク{" "} = 60 ? "red.600" : "green.700"} fontWeight="semibold" - lineHeight={1.2} > - {toJstHHmm(event.start)} - - - {eventDurationMinutes(event.start, event.end)}分 + {lateRisk}% - - - {truncateText(event.summary, 26)} - - - {truncateText(event.location ?? "場所未設定", 22)} - - - )) - )} - - - - - - タスク細分化の予定 - - - {taskEventsStatus === "loading" ? ( - - 読み込み中... - - ) : upcomingTaskEvents.length === 0 ? ( - - 直近の細分化予定はありません - - ) : ( - upcomingTaskEvents.map((eventItem) => ( - + 移動 {transitMinutes}分 + + + 経路: {truncateText(transitSummary, 52)} + + + + + + + + + 交通 + + + {transitMinutes} + + 分 + + + + {truncateText(transitSummary, 22)} + + + + + + + + 余裕 + + + {Math.max(0, slack)} + + 分 + + + + {departure}に出れば + + - - + + + + + + + + 今日の予定 + + + {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 ? "☔" : "☀️"} + + - {toJstHHmm(eventItem.startAt)} + {weather + ? `${weather.precipitationProbability}%` + : "--%"} - - {eventDurationMinutes( - eventItem.startAt, - eventItem.endAt, - )} - 分 + + {weather?.umbrellaNeeded ? "傘あり" : "晴れ"} - - - {truncateText(eventItem.subtaskTitle, 24)} + + + + + + 降水量{" "} + {weather ? `${weather.precipitationMm} mm/h` : "--"} - {truncateText(eventItem.summary, 36)} + {weather?.locationName ?? "-"} - - 元タスク: {truncateText(eventItem.taskInput, 20)} + + {weather?.reason ?? "天気情報なし"} - - )) - )} - {taskEventsStatus === "error" && ( - - 細分化予定の取得に失敗しました。 - - )} - - - - - - 天気 - - - - - {weather?.umbrellaNeeded ? "☔" : "☀️"} - - - - {weather ? `${weather.precipitationProbability}%` : "--%"} - - - {weather?.umbrellaNeeded ? "傘あり" : "晴れ"} + + + + + + {state.status === "error" && ( + + + + {state.errorType === "unauthorized" + ? "ログインが必要です。" + : "API取得に失敗しました。ログイン状態とバックエンド起動を確認してください。"} + {state.errorType === "unauthorized" && ( + + )} - - - - - 降水量 {weather ? `${weather.precipitationMm} mm/h` : "--"} - - {weather?.locationName ?? "-"} - - {weather?.reason ?? "天気情報なし"} - - - - - - {state.status === "error" && ( - - - {state.errorType === "unauthorized" - ? "ログインが必要です。" - : "API取得に失敗しました。ログイン状態とバックエンド起動を確認してください。"} - - {state.errorType === "unauthorized" && ( - - )} - - )} - + + )} + + ); @@ -621,9 +698,11 @@ function Card({ borderRadius="2xl" p={{ base: 4, md: 5 }} minH={minH} + h="full" borderWidth="1px" borderColor="gray.200" boxShadow="sm" + overflow="hidden" > {children} From 0148eeaffb9fc5e9d42b3d34b2940830ebee37d1 Mon Sep 17 00:00:00 2001 From: nenrin Date: Sun, 22 Feb 2026 11:45:49 +0900 Subject: [PATCH 3/7] Add quick navigation buttons to dashboard --- frontend/src/app/dashboard/page.tsx | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/frontend/src/app/dashboard/page.tsx b/frontend/src/app/dashboard/page.tsx index a19d2bb..27aaebe 100644 --- a/frontend/src/app/dashboard/page.tsx +++ b/frontend/src/app/dashboard/page.tsx @@ -11,6 +11,7 @@ import { Stack, Text, } from "@chakra-ui/react"; +import NextLink from "next/link"; import { useEffect, useMemo, useState } from "react"; import { fetchMorningBriefing } from "@/lib/backend-api"; import { @@ -261,6 +262,15 @@ export default function DashboardPage() { > + + + + + Date: Sun, 22 Feb 2026 11:57:11 +0900 Subject: [PATCH 4/7] Add routine-based wake alarm and spoken morning guidance --- frontend/src/app/dashboard/page.tsx | 652 +++++++++++++++++++++++++++- 1 file changed, 650 insertions(+), 2 deletions(-) diff --git a/frontend/src/app/dashboard/page.tsx b/frontend/src/app/dashboard/page.tsx index 27aaebe..3c8834f 100644 --- a/frontend/src/app/dashboard/page.tsx +++ b/frontend/src/app/dashboard/page.tsx @@ -12,7 +12,7 @@ import { Text, } from "@chakra-ui/react"; import NextLink from "next/link"; -import { useEffect, useMemo, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { fetchMorningBriefing } from "@/lib/backend-api"; import { getTaskWorkflowHistory, @@ -80,8 +80,22 @@ type DecomposedTaskEvent = { endAt: string; }; +type AlarmStatus = "idle" | "scheduled" | "ringing"; +type MorningRoutineItem = { + id: string; + label: string; + minutes: number; +}; + 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 MORNING_ROUTINE_STORAGE_KEY = "dashboard:morning-routine:v1"; +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) { @@ -98,7 +112,7 @@ function truncateText(value: string, maxLength: number): string { 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}`; @@ -138,6 +152,124 @@ function collectUpcomingDecomposedEvents( .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 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 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}から${destination}。${transferText}、${transitText}。${urgent.leaveBy}出発推奨。${umbrellaText}`; +} + export default function DashboardPage() { const [state, setState] = useState({ status: "loading", @@ -153,6 +285,17 @@ export default function DashboardPage() { 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 alarmTimeoutRef = useRef(null); + const alarmIntervalRef = useRef(null); + const audioContextRef = useRef(null); useEffect(() => { let active = true; @@ -211,13 +354,369 @@ export default function DashboardPage() { }; }, [currentLocation, forceRefresh]); + 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 = w.AudioContext ?? w.webkitAudioContext; + if (!AudioContextCtor) { + return null; + } + + if (!audioContextRef.current) { + audioContextRef.current = new AudioContextCtor(); + } + + if (audioContextRef.current.state === "suspended") { + await audioContextRef.current.resume().catch(() => undefined); + } + + if (audioContextRef.current.state !== "running") { + return null; + } + + return audioContextRef.current; + }, []); + + 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); + } + + const routineRaw = window.localStorage.getItem(MORNING_ROUTINE_STORAGE_KEY); + if (!routineRaw) { + return; + } + + try { + const parsed = JSON.parse(routineRaw) as unknown; + const normalized = normalizeRoutineItems(parsed); + if (normalized) { + setMorningRoutine(normalized); + } + } catch { + // Ignore broken storage payloads and fall back to defaults. + } + }, []); + + useEffect(() => { + if (typeof window === "undefined") { + return; + } + window.localStorage.setItem( + WAKEUP_ALARM_ENABLED_KEY, + alarmEnabled ? "1" : "0", + ); + }, [alarmEnabled]); + + useEffect(() => { + if (typeof window === "undefined") { + return; + } + window.localStorage.setItem( + MORNING_ROUTINE_STORAGE_KEY, + JSON.stringify(morningRoutine), + ); + }, [morningRoutine]); + + 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 ?? "--:--"; + const routineTotalMinutes = useMemo(() => { + const total = morningRoutine.reduce( + (sum, item) => sum + clampMinutes(item.minutes), + 0, + ); + return Math.max(1, Math.min(300, total)); + }, [morningRoutine]); const lateRisk = urgent?.lateRiskPercent ?? 0; const slack = urgent?.slackMinutes ?? 0; const transitSummary = urgent?.route?.summary ?? "経路情報なし"; const transitMinutes = urgent?.transitMinutes ?? 0; const weather = state.data?.weather ?? null; + const 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 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 handleRoutineLabelChange = useCallback((id: string, value: string) => { + setMorningRoutine((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; + setMorningRoutine((prev) => + prev.map((item) => (item.id === id ? { ...item, minutes } : item)), + ); + }, + [], + ); + + const handleAddRoutineItem = useCallback(() => { + setMorningRoutine((prev) => [ + ...prev, + { + id: createRoutineItemId(), + label: "追加ルーティン", + minutes: 10, + }, + ]); + }, []); + + const handleRemoveRoutineItem = useCallback((id: string) => { + setMorningRoutine((prev) => { + if (prev.length <= 1) { + return prev; + } + return prev.filter((item) => item.id !== id); + }); + }, []); + + 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(); @@ -361,6 +860,155 @@ export default function DashboardPage() { > 経路: {truncateText(transitSummary, 52)} + + + + 朝ルーティン(起床から出発まで)合計:{" "} + {routineTotalMinutes}分 + + + + {morningRoutine.map((item) => ( + + + handleRoutineLabelChange( + item.id, + e.target.value, + ) + } + bg="white" + minW={{ base: "100%", sm: "180px" }} + maxW={{ base: "100%", sm: "220px" }} + /> + + handleRoutineMinutesChange( + item.id, + e.target.value, + ) + } + w={{ base: "92px", sm: "84px" }} + bg="white" + /> + + 分 + + + + ))} + + + + + + + + + 起床目安 {wakeUpTime} + + + アラーム {alarmStatusLabel} + + + + + {alarmEnabled ? ( + + ) : ( + + )} + + {alarmStatus === "ringing" ? ( + + ) : ( + + )} + + + {alarmMessage ? ( + + {alarmMessage} + + ) : null} + + {alarmEnabled && alarmTargetMs ? ( + + 次回鳴動予定:{" "} + {toJstHHmm(new Date(alarmTargetMs).toISOString())} + + ) : null} + + {lastGuidanceText ? ( + + 案内: {lastGuidanceText} + + ) : null} + + + ※ アラームはこの画面を開いている間に動作します。 + + From 6323d14e6358c035ac8598a19801caaf07875677 Mon Sep 17 00:00:00 2001 From: nenrin Date: Sun, 22 Feb 2026 12:11:00 +0900 Subject: [PATCH 5/7] Persist morning routines in D1 and edit via drawer --- .../0004_morning_routine_settings.sql | 6 + backend/src/app.ts | 2 + .../morning-routine.service.ts | 162 +++++++ backend/src/routes/morning-routine-routes.ts | 45 ++ backend/src/routes/root-routes.ts | 2 + frontend/src/app/dashboard/page.tsx | 398 +++++++++++++----- frontend/src/lib/backend-api.ts | 38 ++ 7 files changed, 551 insertions(+), 102 deletions(-) create mode 100644 backend/migrations/0004_morning_routine_settings.sql create mode 100644 backend/src/features/morning-routine/morning-routine.service.ts create mode 100644 backend/src/routes/morning-routine-routes.ts 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/frontend/src/app/dashboard/page.tsx b/frontend/src/app/dashboard/page.tsx index 3c8834f..5030950 100644 --- a/frontend/src/app/dashboard/page.tsx +++ b/frontend/src/app/dashboard/page.tsx @@ -4,16 +4,23 @@ import { Box, Button, Container, + Drawer, Grid, GridItem, HStack, Input, + Portal, Stack, Text, } from "@chakra-ui/react"; import NextLink from "next/link"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; -import { fetchMorningBriefing } from "@/lib/backend-api"; +import { + fetchMorningBriefing, + fetchMorningRoutine, + type MorningRoutineItem, + updateMorningRoutine, +} from "@/lib/backend-api"; import { getTaskWorkflowHistory, type WorkflowRecord, @@ -81,17 +88,11 @@ type DecomposedTaskEvent = { }; type AlarmStatus = "idle" | "scheduled" | "ringing"; -type MorningRoutineItem = { - id: string; - label: string; - minutes: number; -}; 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 MORNING_ROUTINE_STORAGE_KEY = "dashboard:morning-routine:v1"; const DEFAULT_MORNING_ROUTINE: MorningRoutineItem[] = [ { id: "prepare", label: "身支度", minutes: 20 }, { id: "breakfast", label: "朝食", minutes: 15 }, @@ -239,6 +240,39 @@ function inferTransferCount(summary: string | null | undefined): number | null { 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, @@ -252,6 +286,7 @@ function buildWakeupSpeech( urgent.destination?.trim() || urgent.event.location?.trim() || "目的地未設定"; + const speechDestination = shortenSpeechDestination(destination); const transferCount = inferTransferCount(urgent.route?.summary ?? null); const transferText = transferCount !== null @@ -267,7 +302,7 @@ function buildWakeupSpeech( ? "雨のため傘を持ってください。" : "傘は不要です。"; - return `今日は${startAt}から${destination}。${transferText}、${transitText}。${urgent.leaveBy}出発推奨。${umbrellaText}`; + return `今日は${startAt}から${speechDestination}。${transferText}、${transitText}。${urgent.leaveBy}出発推奨。${umbrellaText}`; } export default function DashboardPage() { @@ -293,17 +328,81 @@ export default function DashboardPage() { 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, 30, forceRefresh), + fetchMorningBriefing(currentLocation, routineTotalMinutes, forceRefresh), getTaskWorkflowHistory(50), ]) .then(([briefingResult, workflowResult]) => { @@ -352,7 +451,7 @@ export default function DashboardPage() { return () => { active = false; }; - }, [currentLocation, forceRefresh]); + }, [currentLocation, forceRefresh, routineStatus, routineTotalMinutes]); const clearAlarmTimeout = useCallback(() => { if (alarmTimeoutRef.current !== null) { @@ -379,24 +478,26 @@ export default function DashboardPage() { } const w = window as Window & { webkitAudioContext?: typeof AudioContext }; - const AudioContextCtor = w.AudioContext ?? w.webkitAudioContext; + const AudioContextCtor = globalThis.AudioContext ?? w.webkitAudioContext; if (!AudioContextCtor) { return null; } - if (!audioContextRef.current) { - audioContextRef.current = new AudioContextCtor(); + let context = audioContextRef.current; + if (!context) { + context = new AudioContextCtor(); + audioContextRef.current = context; } - if (audioContextRef.current.state === "suspended") { - await audioContextRef.current.resume().catch(() => undefined); + if (context.state === "suspended") { + await context.resume().catch(() => undefined); } - if (audioContextRef.current.state !== "running") { + if (context.state !== "running") { return null; } - return audioContextRef.current; + return context; }, []); const playAlarmTone = useCallback(async (): Promise => { @@ -460,21 +561,6 @@ export default function DashboardPage() { if (saved === "1") { setAlarmEnabled(true); } - - const routineRaw = window.localStorage.getItem(MORNING_ROUTINE_STORAGE_KEY); - if (!routineRaw) { - return; - } - - try { - const parsed = JSON.parse(routineRaw) as unknown; - const normalized = normalizeRoutineItems(parsed); - if (normalized) { - setMorningRoutine(normalized); - } - } catch { - // Ignore broken storage payloads and fall back to defaults. - } }, []); useEffect(() => { @@ -487,16 +573,6 @@ export default function DashboardPage() { ); }, [alarmEnabled]); - useEffect(() => { - if (typeof window === "undefined") { - return; - } - window.localStorage.setItem( - MORNING_ROUTINE_STORAGE_KEY, - JSON.stringify(morningRoutine), - ); - }, [morningRoutine]); - useEffect(() => { return () => { clearAlarmTimeout(); @@ -516,13 +592,6 @@ export default function DashboardPage() { const urgent = state.data?.urgent ?? null; const departure = urgent?.leaveBy ?? "--:--"; - const routineTotalMinutes = useMemo(() => { - const total = morningRoutine.reduce( - (sum, item) => sum + clampMinutes(item.minutes), - 0, - ); - return Math.max(1, Math.min(300, total)); - }, [morningRoutine]); const lateRisk = urgent?.lateRiskPercent ?? 0; const slack = urgent?.slackMinutes ?? 0; const transitSummary = urgent?.route?.summary ?? "経路情報なし"; @@ -634,8 +703,14 @@ export default function DashboardPage() { } }, [speakBriefing, stopAlarmSound, urgent, weather]); + const handleOpenRoutineEditor = useCallback(() => { + setRoutineDraft(morningRoutine.map((item) => ({ ...item }))); + setRoutineEditorError(null); + setIsRoutineDrawerOpen(true); + }, [morningRoutine]); + const handleRoutineLabelChange = useCallback((id: string, value: string) => { - setMorningRoutine((prev) => + setRoutineDraft((prev) => prev.map((item) => (item.id === id ? { ...item, label: value } : item)), ); }, []); @@ -644,7 +719,7 @@ export default function DashboardPage() { (id: string, value: string) => { const parsed = Number(value); const minutes = Number.isFinite(parsed) ? clampMinutes(parsed) : 0; - setMorningRoutine((prev) => + setRoutineDraft((prev) => prev.map((item) => (item.id === id ? { ...item, minutes } : item)), ); }, @@ -652,7 +727,7 @@ export default function DashboardPage() { ); const handleAddRoutineItem = useCallback(() => { - setMorningRoutine((prev) => [ + setRoutineDraft((prev) => [ ...prev, { id: createRoutineItemId(), @@ -663,7 +738,7 @@ export default function DashboardPage() { }, []); const handleRemoveRoutineItem = useCallback((id: string) => { - setMorningRoutine((prev) => { + setRoutineDraft((prev) => { if (prev.length <= 1) { return prev; } @@ -671,6 +746,35 @@ export default function DashboardPage() { }); }, []); + 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(); @@ -866,56 +970,34 @@ export default function DashboardPage() { 朝ルーティン(起床から出発まで)合計:{" "} {routineTotalMinutes}分 + {routineStatus === "loading" ? ( + + 朝ルーティンを読み込み中... + + ) : null} - + {morningRoutine.map((item) => ( - - - handleRoutineLabelChange( - item.id, - e.target.value, - ) - } - bg="white" - minW={{ base: "100%", sm: "180px" }} - maxW={{ base: "100%", sm: "220px" }} + + - - handleRoutineMinutesChange( - item.id, - e.target.value, - ) - } - w={{ base: "92px", sm: "84px" }} - bg="white" - /> - - 分 - - + {truncateText(item.label, 22)} + + + {clampMinutes(item.minutes)}分 + ))} @@ -925,12 +1007,19 @@ export default function DashboardPage() { size="xs" variant="outline" colorPalette="blue" - onClick={handleAddRoutineItem} + onClick={handleOpenRoutineEditor} + disabled={routineStatus === "loading"} > - ルーティン項目を追加 + ルーティンを編集 + {routineError ? ( + + {routineError} + + ) : null} + + 案内: {lastGuidanceText} ) : null} @@ -1339,6 +1428,111 @@ export default function DashboardPage() { + + { + 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} + + + + + + + + + + + + ); } 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; +} From 88f205af18978d8b1778c3448ba8a591329a4beb Mon Sep 17 00:00:00 2001 From: nenrin Date: Sun, 22 Feb 2026 12:22:17 +0900 Subject: [PATCH 6/7] Commit all pending workspace changes --- .DS_Store | Bin 0 -> 6148 bytes .github/.DS_Store | Bin 0 -> 6148 bytes README.md | 5 +- d76869c4364b9cc4.PNG | Bin 0 -> 20454 bytes frontend/src/app/dashboard/page.tsx | 285 ++++++++++++++++++---------- frontend/src/app/favicon.ico | Bin 25931 -> 32038 bytes frontend/src/app/icon.png | Bin 0 -> 14269 bytes frontend/src/app/layout.tsx | 5 +- frontend/src/app/page.tsx | 129 ++++++++----- frontend/src/app/privacy/page.tsx | 4 +- frontend/src/app/terms/page.tsx | 4 +- 11 files changed, 280 insertions(+), 152 deletions(-) create mode 100644 .DS_Store create mode 100644 .github/.DS_Store create mode 100644 d76869c4364b9cc4.PNG create mode 100644 frontend/src/app/icon.png diff --git a/.DS_Store b/.DS_Store new file mode 100644 index 0000000000000000000000000000000000000000..a105a55953f82e114063cb71235a7c79ee34e63c GIT binary patch literal 6148 zcmeHLzfS@&6n@1^JY`|i@ie+>SX@03H=_e_&`J3r7=9cPaB;afo0#|~sJQ6h;$WC$ zG0_+oF^sch`bB$if~}wfA@G^hxb2dhvL&;3cumjxN^U zHtG*cdVhLge>tbj4XH0og|XIkAqO7*@=(zfK)BhPrE0Nnug+)BxRxH{6}^tC!Awmb zpATVF(N}|{u5EJ-wRJI6UCTeu_tK*PtD~tuOV@Kle$hAwP=OSBm4=M2C)5ZsR6XkC^K+^4Q_RFC?zbUlvHlY|{qlZOnHAg$_|{=7WmdNhWr=i~MLuI*13tK(09ma1oF z)g?VP?7}{JQ-W$x(P6VO(Tk&MI(d9)J+r`As!M}-s;=W}{@h60Fb1yK6n0pksf`21 z0pmd10h%8iEQGE^Um%}4unOTR^;?7#cpQ=FNOUFo0(mO(cq*buWpazbL^`%RL`QTb z`T|8dFp&;SUYW@q3gcJDJcrYPxdKgX954>(4vdJ?2(AAs{rCTRk~uRD7zh591I&si z;xW7>xwZywj@DWS%QhAY;`su33RZGC<_EMC)Bg%Qu+O0bL|39O5L+J7#2HW=l literal 0 HcmV?d00001 diff --git a/.github/.DS_Store b/.github/.DS_Store new file mode 100644 index 0000000000000000000000000000000000000000..4bfc286e71aeafa88af5b0b04668a28f7a941dbb GIT binary patch literal 6148 zcmeHK!AiqG5Pe&7ND-t*FG4O}#a=vlNK1MU6ouN07o`>zOh{3r*L;fzeu|&rKX?~> zv$M1ftrjm*WTwo%&Fsu(@`hx(09^NJ)CO7rVya-H$zh4ey6A#5f<+dc-=l|1oTG;m zoGo~pVI3Ke+HK(oef|cl+^l}b=twQ|+jjrDEXJCA<8zgNQD)gNFNgGtKVEls_nzN6 zt7AsLjv3$0=d~~CH8DYn2i)TZIcF0**0Vo+U+y)Zb2K?UxOnk7FWIjsXPN%&S;M4JB%*3r7s6;j|~gFAZ2JYB*dte7JCD7fvY7-<|s>-W@Jgw9yza2I>rK=w(a# zfA9PAe?7>qi~(cdUoqgKte5}gnC645x+`t8p4Th#q^a{yiN5&dm +# ネボガード(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/d76869c4364b9cc4.PNG b/d76869c4364b9cc4.PNG new file mode 100644 index 0000000000000000000000000000000000000000..dfa1d6404a5a7b216c7ed39569dde243c13b5b2c GIT binary patch literal 20454 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 literal 0 HcmV?d00001 diff --git a/frontend/src/app/dashboard/page.tsx b/frontend/src/app/dashboard/page.tsx index 5030950..3fc1736 100644 --- a/frontend/src/app/dashboard/page.tsx +++ b/frontend/src/app/dashboard/page.tsx @@ -627,6 +627,51 @@ export default function DashboardPage() { } 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(); @@ -927,7 +972,7 @@ export default function DashboardPage() { fontSize={{ base: "sm", md: "md" }} letterSpacing="0.08em" > - 出発まで + 出発時刻 移動 {transitMinutes}分 + 余裕 {Math.max(0, slack)}分 - - 朝ルーティン(起床から出発まで)合計:{" "} - {routineTotalMinutes}分 - - {routineStatus === "loading" ? ( - - 朝ルーティンを読み込み中... - - ) : null} - - - {morningRoutine.map((item) => ( - - - - {truncateText(item.label, 22)} - - - {clampMinutes(item.minutes)}分 - - - ))} - - - - - - - {routineError ? ( - - {routineError} - - ) : null} - - 交通 - - - {transitMinutes} - - 分 - + 朝ルーティン - - {truncateText(transitSummary, 22)} + + 合計 {routineTotalMinutes}分 + {routineStatus === "loading" ? ( + + 朝ルーティンを読み込み中... + + ) : ( + + {nextRoutine ? ( + + + 次: {truncateText(nextRoutine.label, 16)} + + {nextRoutine.startMs !== null && + nextRoutine.endMs !== null ? ( + + {toJstHHmm( + new Date(nextRoutine.startMs).toISOString(), + )} + {" - "} + {toJstHHmm( + new Date(nextRoutine.endMs).toISOString(), + )} + + ) : null} + + ) : ( + + 次のルーティンはありません + + )} + + + {routineTimeline.map((item) => ( + + + + + {truncateText(item.label, 18)} + + {item.startMs !== null && item.endMs !== null ? ( + + {toJstHHmm( + new Date(item.startMs).toISOString(), + )} + {" - "} + {toJstHHmm( + new Date(item.endMs).toISOString(), + )} + + ) : null} + + + {clampMinutes(item.minutes)}分 + + + ))} + + + + + + + )} + {routineError ? ( + + {routineError} + + ) : null} - 余裕 + 交通 - {Math.max(0, slack)} + {transitMinutes} - {departure}に出れば + {truncateText(transitSummary, 22)} + + + 推奨出発 {departure} - - - diff --git a/frontend/src/app/favicon.ico b/frontend/src/app/favicon.ico index 718d6fea4835ec2d246af9800eddb7ffb276240c..fd5b413a3021080415375b3cc3d91e880428375f 100644 GIT binary patch literal 32038 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 HSOWh8Ewn(9 literal 25931 zcmeHv30#a{`}aL_*G&7qml|y<+KVaDM2m#dVr!KsA!#An?kSQM(q<_dDNCpjEux83 zLb9Z^XxbDl(w>%i@8hT6>)&Gu{h#Oeyszu?xtw#Zb1mO{pgX9699l+Qppw7jXaYf~-84xW z)w4x8?=youko|}Vr~(D$UXIbiXABHh`p1?nn8Po~fxRJv}|0e(BPs|G`(TT%kKVJAdg5*Z|x0leQq0 zkdUBvb#>9F()jo|T~kx@OM8$9wzs~t2l;K=woNssA3l6|sx2r3+kdfVW@e^8e*E}v zA1y5{bRi+3Z`uD3{F7LgFJDdvm;nJilkzDku>BwXH(8ItVCXk*-lSJnR?-2UN%hJ){&rlvg`CDTj z)Bzo!3v7Ou#83zEDEFcKt(f1E0~=rqeEbTnMvWR#{+9pg%7G8y>u1OVRUSoox-ovF z2Ydma(;=YuBY(eI|04{hXzZD6_f(v~H;C~y5=DhAC{MMS>2fm~1H_t2$56pc$NH8( z5bH|<)71dV-_oCHIrzrT`2s-5w_+2CM0$95I6X8p^r!gHp+j_gd;9O<1~CEQQGS8) zS9Qh3#p&JM-G8rHekNmKVewU;pJRcTAog68KYo^dRo}(M>36U4Us zfgYWSiHZL3;lpWT=zNAW>Dh#mB!_@Lg%$ms8N-;aPqMn+C2HqZgz&9~Eu z4|Kp<`$q)Uw1R?y(~S>ePdonHxpV1#eSP1B;Ogo+-Pk}6#0GsZZ5!||ev2MGdh}_m z{DeR7?0-1^zVs&`AV6Vt;r3`I`OI_wgs*w=eO%_#7Kepl{B@xiyCANc(l zzIyd4y|c6PXWq9-|KM8(zIk8LPk(>a)zyFWjhT!$HJ$qX1vo@d25W<fvZQ2zUz5WRc(UnFMKHwe1| zWmlB1qdbiA(C0jmnV<}GfbKtmcu^2*P^O?MBLZKt|As~ge8&AAO~2K@zbXelK|4T<{|y4`raF{=72kC2Kn(L4YyenWgrPiv z@^mr$t{#X5VuIMeL!7Ab6_kG$&#&5p*Z{+?5U|TZ`B!7llpVmp@skYz&n^8QfPJzL z0G6K_OJM9x+Wu2gfN45phANGt{7=C>i34CV{Xqlx(fWpeAoj^N0Biu`w+MVcCUyU* zDZuzO0>4Z6fbu^T_arWW5n!E45vX8N=bxTVeFoep_G#VmNlQzAI_KTIc{6>c+04vr zx@W}zE5JNSU>!THJ{J=cqjz+4{L4A{Ob9$ZJ*S1?Ggg3klFp!+Y1@K+pK1DqI|_gq z5ZDXVpge8-cs!o|;K73#YXZ3AShj50wBvuq3NTOZ`M&qtjj#GOFfgExjg8Gn8>Vq5 z`85n+9|!iLCZF5$HJ$Iu($dm?8~-ofu}tEc+-pyke=3!im#6pk_Wo8IA|fJwD&~~F zc16osQ)EBo58U7XDuMexaPRjU@h8tXe%S{fA0NH3vGJFhuyyO!Uyl2^&EOpX{9As0 zWj+P>{@}jxH)8|r;2HdupP!vie{sJ28b&bo!8`D^x}TE$%zXNb^X1p@0PJ86`dZyj z%ce7*{^oo+6%&~I!8hQy-vQ7E)0t0ybH4l%KltWOo~8cO`T=157JqL(oq_rC%ea&4 z2NcTJe-HgFjNg-gZ$6!Y`SMHrlj}Etf7?r!zQTPPSv}{so2e>Fjs1{gzk~LGeesX%r(Lh6rbhSo_n)@@G-FTQy93;l#E)hgP@d_SGvyCp0~o(Y;Ee8{ zdVUDbHm5`2taPUOY^MAGOw*>=s7=Gst=D+p+2yON!0%Hk` zz5mAhyT4lS*T3LS^WSxUy86q&GnoHxzQ6vm8)VS}_zuqG?+3td68_x;etQAdu@sc6 zQJ&5|4(I?~3d-QOAODHpZ=hlSg(lBZ!JZWCtHHSj`0Wh93-Uk)_S%zsJ~aD>{`A0~ z9{AG(e|q3g5B%wYKRxiL2Y$8(4w6bzchKuloQW#e&S3n+P- z8!ds-%f;TJ1>)v)##>gd{PdS2Oc3VaR`fr=`O8QIO(6(N!A?pr5C#6fc~Ge@N%Vvu zaoAX2&(a6eWy_q&UwOhU)|P3J0Qc%OdhzW=F4D|pt0E4osw;%<%Dn58hAWD^XnZD= z>9~H(3bmLtxpF?a7su6J7M*x1By7YSUbxGi)Ot0P77`}P3{)&5Un{KD?`-e?r21!4vTTnN(4Y6Lin?UkSM z`MXCTC1@4A4~mvz%Rh2&EwY))LeoT=*`tMoqcEXI>TZU9WTP#l?uFv+@Dn~b(>xh2 z;>B?;Tz2SR&KVb>vGiBSB`@U7VIWFSo=LDSb9F{GF^DbmWAfpms8Sx9OX4CnBJca3 zlj9(x!dIjN?OG1X4l*imJNvRCk}F%!?SOfiOq5y^mZW)jFL@a|r-@d#f7 z2gmU8L3IZq0ynIws=}~m^#@&C%J6QFo~Mo4V`>v7MI-_!EBMMtb%_M&kvAaN)@ZVw z+`toz&WG#HkWDjnZE!6nk{e-oFdL^$YnbOCN}JC&{$#$O27@|Tn-skXr)2ml2~O!5 zX+gYoxhoc7qoU?C^3~&!U?kRFtnSEecWuH0B0OvLodgUAi}8p1 zrO6RSXHH}DMc$&|?D004DiOVMHV8kXCP@7NKB zgaZq^^O<7PoKEp72kby@W0Z!Y*Ay{&vfg#C&gG@YVR9g?FEocMUi1gSN$+V+ayF45{a zuDZDTN}mS|;BO%gEf}pjBfN2-gIrU#G5~cucA;dokXW89%>AyXJJI z9X4UlIWA|ZYHgbI z5?oFk@A=Ik7lrEQPDH!H+b`7_Y~aDb_qa=B2^Y&Ow41cU=4WDd40dp5(QS-WMN-=Y z9g;6_-JdNU;|6cPwf$ak*aJIcwL@1n$#l~zi{c{EW?T;DaW*E8DYq?Umtz{nJ&w-M zEMyTDrC&9K$d|kZe2#ws6)L=7K+{ zQw{XnV6UC$6-rW0emqm8wJoeZK)wJIcV?dST}Z;G0Arq{dVDu0&4kd%N!3F1*;*pW zR&qUiFzK=@44#QGw7k1`3t_d8&*kBV->O##t|tonFc2YWrL7_eqg+=+k;!F-`^b8> z#KWCE8%u4k@EprxqiV$VmmtiWxDLgnGu$Vs<8rppV5EajBXL4nyyZM$SWVm!wnCj-B!Wjqj5-5dNXukI2$$|Bu3Lrw}z65Lc=1G z^-#WuQOj$hwNGG?*CM_TO8Bg-1+qc>J7k5c51U8g?ZU5n?HYor;~JIjoWH-G>AoUP ztrWWLbRNqIjW#RT*WqZgPJXU7C)VaW5}MiijYbABmzoru6EmQ*N8cVK7a3|aOB#O& zBl8JY2WKfmj;h#Q!pN%9o@VNLv{OUL?rixHwOZuvX7{IJ{(EdPpuVFoQqIOa7giLVkBOKL@^smUA!tZ1CKRK}#SSM)iQHk)*R~?M!qkCruaS!#oIL1c z?J;U~&FfH#*98^G?i}pA{ z9Jg36t4=%6mhY(quYq*vSxptes9qy|7xSlH?G=S@>u>Ebe;|LVhs~@+06N<4CViBk zUiY$thvX;>Tby6z9Y1edAMQaiH zm^r3v#$Q#2T=X>bsY#D%s!bhs^M9PMAcHbCc0FMHV{u-dwlL;a1eJ63v5U*?Q_8JO zT#50!RD619#j_Uf))0ooADz~*9&lN!bBDRUgE>Vud-i5ck%vT=r^yD*^?Mp@Q^v+V zG#-?gKlr}Eeqifb{|So?HM&g91P8|av8hQoCmQXkd?7wIJwb z_^v8bbg`SAn{I*4bH$u(RZ6*xUhuA~hc=8czK8SHEKTzSxgbwi~9(OqJB&gwb^l4+m`k*Q;_?>Y-APi1{k zAHQ)P)G)f|AyjSgcCFps)Fh6Bca*Xznq36!pV6Az&m{O8$wGFD? zY&O*3*J0;_EqM#jh6^gMQKpXV?#1?>$ml1xvh8nSN>-?H=V;nJIwB07YX$e6vLxH( zqYwQ>qxwR(i4f)DLd)-$P>T-no_c!LsN@)8`e;W@)-Hj0>nJ-}Kla4-ZdPJzI&Mce zv)V_j;(3ERN3_@I$N<^|4Lf`B;8n+bX@bHbcZTopEmDI*Jfl)-pFDvo6svPRoo@(x z);_{lY<;);XzT`dBFpRmGrr}z5u1=pC^S-{ce6iXQlLGcItwJ^mZx{m$&DA_oEZ)B{_bYPq-HA zcH8WGoBG(aBU_j)vEy+_71T34@4dmSg!|M8Vf92Zj6WH7Q7t#OHQqWgFE3ARt+%!T z?oLovLVlnf?2c7pTc)~cc^($_8nyKwsN`RA-23ed3sdj(ys%pjjM+9JrctL;dy8a( z@en&CQmnV(()bu|Y%G1-4a(6x{aLytn$T-;(&{QIJB9vMox11U-1HpD@d(QkaJdEb zG{)+6Dos_L+O3NpWo^=gR?evp|CqEG?L&Ut#D*KLaRFOgOEK(Kq1@!EGcTfo+%A&I z=dLbB+d$u{sh?u)xP{PF8L%;YPPW53+@{>5W=Jt#wQpN;0_HYdw1{ksf_XhO4#2F= zyPx6Lx2<92L-;L5PD`zn6zwIH`Jk($?Qw({erA$^bC;q33hv!d!>%wRhj# zal^hk+WGNg;rJtb-EB(?czvOM=H7dl=vblBwAv>}%1@{}mnpUznfq1cE^sgsL0*4I zJ##!*B?=vI_OEVis5o+_IwMIRrpQyT_Sq~ZU%oY7c5JMIADzpD!Upz9h@iWg_>>~j zOLS;wp^i$-E?4<_cp?RiS%Rd?i;f*mOz=~(&3lo<=@(nR!_Rqiprh@weZlL!t#NCc zO!QTcInq|%#>OVgobj{~ixEUec`E25zJ~*DofsQdzIa@5^nOXj2T;8O`l--(QyU^$t?TGY^7#&FQ+2SS3B#qK*k3`ye?8jUYSajE5iBbJls75CCc(m3dk{t?- zopcER9{Z?TC)mk~gpi^kbbu>b-+a{m#8-y2^p$ka4n60w;Sc2}HMf<8JUvhCL0B&Btk)T`ctE$*qNW8L$`7!r^9T+>=<=2qaq-;ll2{`{Rg zc5a0ZUI$oG&j-qVOuKa=*v4aY#IsoM+1|c4Z)<}lEDvy;5huB@1RJPquU2U*U-;gu z=En2m+qjBzR#DEJDO`WU)hdd{Vj%^0V*KoyZ|5lzV87&g_j~NCjwv0uQVqXOb*QrQ zy|Qn`hxx(58c70$E;L(X0uZZ72M1!6oeg)(cdKO ze0gDaTz+ohR-#d)NbAH4x{I(21yjwvBQfmpLu$)|m{XolbgF!pmsqJ#D}(ylp6uC> z{bqtcI#hT#HW=wl7>p!38sKsJ`r8}lt-q%Keqy%u(xk=yiIJiUw6|5IvkS+#?JTBl z8H5(Q?l#wzazujH!8o>1xtn8#_w+397*_cy8!pQGP%K(Ga3pAjsaTbbXJlQF_+m+-UpUUent@xM zg%jqLUExj~o^vQ3Gl*>wh=_gOr2*|U64_iXb+-111aH}$TjeajM+I20xw(((>fej-@CIz4S1pi$(#}P7`4({6QS2CaQS4NPENDp>sAqD z$bH4KGzXGffkJ7R>V>)>tC)uax{UsN*dbeNC*v}#8Y#OWYwL4t$ePR?VTyIs!wea+ z5Urmc)X|^`MG~*dS6pGSbU+gPJoq*^a=_>$n4|P^w$sMBBy@f*Z^Jg6?n5?oId6f{ z$LW4M|4m502z0t7g<#Bx%X;9<=)smFolV&(V^(7Cv2-sxbxopQ!)*#ZRhTBpx1)Fc zNm1T%bONzv6@#|dz(w02AH8OXe>kQ#1FMCzO}2J_mST)+ExmBr9cva-@?;wnmWMOk z{3_~EX_xadgJGv&H@zK_8{(x84`}+c?oSBX*Ge3VdfTt&F}yCpFP?CpW+BE^cWY0^ zb&uBN!Ja3UzYHK-CTyA5=L zEMW{l3Usky#ly=7px648W31UNV@K)&Ub&zP1c7%)`{);I4b0Q<)B}3;NMG2JH=X$U zfIW4)4n9ZM`-yRj67I)YSLDK)qfUJ_ij}a#aZN~9EXrh8eZY2&=uY%2N0UFF7<~%M zsB8=erOWZ>Ct_#^tHZ|*q`H;A)5;ycw*IcmVxi8_0Xk}aJA^ath+E;xg!x+As(M#0=)3!NJR6H&9+zd#iP(m0PIW8$ z1Y^VX`>jm`W!=WpF*{ioM?C9`yOR>@0q=u7o>BP-eSHqCgMDj!2anwH?s%i2p+Q7D zzszIf5XJpE)IG4;d_(La-xenmF(tgAxK`Y4sQ}BSJEPs6N_U2vI{8=0C_F?@7<(G; zo$~G=8p+076G;`}>{MQ>t>7cm=zGtfbdDXm6||jUU|?X?CaE?(<6bKDYKeHlz}DA8 zXT={X=yp_R;HfJ9h%?eWvQ!dRgz&Su*JfNt!Wu>|XfU&68iRikRrHRW|ZxzRR^`eIGt zIeiDgVS>IeExKVRWW8-=A=yA`}`)ZkWBrZD`hpWIxBGkh&f#ijr449~m`j6{4jiJ*C!oVA8ZC?$1RM#K(_b zL9TW)kN*Y4%^-qPpMP7d4)o?Nk#>aoYHT(*g)qmRUb?**F@pnNiy6Fv9rEiUqD(^O zzyS?nBrX63BTRYduaG(0VVG2yJRe%o&rVrLjbxTaAFTd8s;<<@Qs>u(<193R8>}2_ zuwp{7;H2a*X7_jryzriZXMg?bTuegABb^87@SsKkr2)0Gyiax8KQWstw^v#ix45EVrcEhr>!NMhprl$InQMzjSFH54x5k9qHc`@9uKQzvL4ihcq{^B zPrVR=o_ic%Y>6&rMN)hTZsI7I<3&`#(nl+3y3ys9A~&^=4?PL&nd8)`OfG#n zwAMN$1&>K++c{^|7<4P=2y(B{jJsQ0a#U;HTo4ZmWZYvI{+s;Td{Yzem%0*k#)vjpB zia;J&>}ICate44SFYY3vEelqStQWFihx%^vQ@Do(sOy7yR2@WNv7Y9I^yL=nZr3mb zXKV5t@=?-Sk|b{XMhA7ZGB@2hqsx}4xwCW!in#C zI@}scZlr3-NFJ@NFaJlhyfcw{k^vvtGl`N9xSo**rDW4S}i zM9{fMPWo%4wYDG~BZ18BD+}h|GQKc-g^{++3MY>}W_uq7jGHx{mwE9fZiPCoxN$+7 zrODGGJrOkcPQUB(FD5aoS4g~7#6NR^ma7-!>mHuJfY5kTe6PpNNKC9GGRiu^L31uG z$7v`*JknQHsYB!Tm_W{a32TM099djW%5e+j0Ve_ct}IM>XLF1Ap+YvcrLV=|CKo6S zb+9Nl3_YdKP6%Cxy@6TxZ>;4&nTneadr z_ES90ydCev)LV!dN=#(*f}|ZORFdvkYBni^aLbUk>BajeWIOcmHP#8S)*2U~QKI%S zyrLmtPqb&TphJ;>yAxri#;{uyk`JJqODDw%(Z=2`1uc}br^V%>j!gS)D*q*f_-qf8&D;W1dJgQMlaH5er zN2U<%Smb7==vE}dDI8K7cKz!vs^73o9f>2sgiTzWcwY|BMYHH5%Vn7#kiw&eItCqa zIkR2~Q}>X=Ar8W|^Ms41Fm8o6IB2_j60eOeBB1Br!boW7JnoeX6Gs)?7rW0^5psc- zjS16yb>dFn>KPOF;imD}e!enuIniFzv}n$m2#gCCv4jM#ArwlzZ$7@9&XkFxZ4n!V zj3dyiwW4Ki2QG{@i>yuZXQizw_OkZI^-3otXC{!(lUpJF33gI60ak;Uqitp74|B6I zgg{b=Iz}WkhCGj1M=hu4#Aw173YxIVbISaoc z-nLZC*6Tgivd5V`K%GxhBsp@SUU60-rfc$=wb>zdJzXS&-5(NRRodFk;Kxk!S(O(a0e7oY=E( zAyS;Ow?6Q&XA+cnkCb{28_1N8H#?J!*$MmIwLq^*T_9-z^&UE@A(z9oGYtFy6EZef LrJugUA?W`A8`#=m diff --git a/frontend/src/app/icon.png b/frontend/src/app/icon.png new file mode 100644 index 0000000000000000000000000000000000000000..cb0a8d0fe4a2bccd7b7bfb7d1ad8dcd46b53fca6 GIT binary patch literal 14269 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 - - - - Execution OS - - - Plan to Action - - - - 計画と実行の断絶を埋める + + + + ネボガード + + NeboGuard + + + + + 朝の判断を、1画面で。 - - このプロダクトが解くのは「将来の目標を、今日の行動に変換し続けられない問題」です。 - 必要なのは完璧な計画ではなく、状況が崩れても次の一歩が出る状態です。 + + 予定・移動・天気をまとめて、出発の判断を一文で提示します。情報を探し回らず、 + そのまま行動に移れる朝をつくるためのアプリです。 + + + + - - 共通課題 - - タスクが多いこと自体ではなく、毎日の予定変化のたびに優先順位を再判断する認知負荷が大きいこと。 + + + このアプリで減らせる迷い + + + 朝に必要な情報は多くありません。出発時刻、天気、移動の乱れを素早く判断できる状態を目指します。 - - - 目標はあるが、今日やることに落ちない + + + 予定ごとの出発時刻をその場で計算しなくてよい - - 予定変更で計画が崩れると、再構築に時間を使う + + 複数アプリを開いて情報を照合しなくてよい - - 結果として着手が遅れ、継続しづらくなる + + 「今すぐ何をすべきか」を一文で確認できる @@ -100,7 +131,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 カレンダー予定の細分化支援を目的とする試作版です。 From ba5db82c05cdf16163c81fbdf9ca70d22f608f12 Mon Sep 17 00:00:00 2001 From: nenrin Date: Sun, 22 Feb 2026 12:26:13 +0900 Subject: [PATCH 7/7] Format landing page with biome --- frontend/src/app/page.tsx | 43 ++++++++++++++++++++++++++++++++------- 1 file changed, 36 insertions(+), 7 deletions(-) diff --git a/frontend/src/app/page.tsx b/frontend/src/app/page.tsx index f3944aa..dd931b9 100644 --- a/frontend/src/app/page.tsx +++ b/frontend/src/app/page.tsx @@ -87,7 +87,12 @@ export default function HomePage() { そのまま行動に移れる朝をつくるためのアプリです。 -