diff --git a/src/app/(journal)/entry/[id].tsx b/src/app/(journal)/entry/[id].tsx index 1c1a2ec..35fe800 100644 --- a/src/app/(journal)/entry/[id].tsx +++ b/src/app/(journal)/entry/[id].tsx @@ -49,6 +49,16 @@ export default function EntryDetailScreen() { unstable_headerRightItems: editMode ? undefined : () => [ + { + type: "button", + icon: { + type: "sfSymbol", + name: entry?.bookmark ? "bookmark.fill" : "bookmark", + }, + label: entry?.bookmark ? t("entry.unbookmark") : t("entry.bookmark"), + tintColor: entry?.bookmark ? PlatformColor("systemIndigo") : undefined, + onPress: handleBookmark, + }, { type: "menu", label: t("common.options"), @@ -74,16 +84,7 @@ export default function EntryDetailScreen() { ], }, }, - { - type: "button", - icon: { - type: "sfSymbol", - name: entry?.bookmark ? "bookmark.fill" : "bookmark", - }, - label: entry?.bookmark ? t("entry.unbookmark") : t("entry.bookmark"), - tintColor: entry?.bookmark ? PlatformColor("systemIndigo") : undefined, - onPress: handleBookmark, - }, + { type: "button", label: t("common.edit"), diff --git a/src/app/(journal)/index.tsx b/src/app/(journal)/index.tsx index 1c294dd..2c5d4ac 100644 --- a/src/app/(journal)/index.tsx +++ b/src/app/(journal)/index.tsx @@ -51,6 +51,12 @@ export default function JournalScreen() { title: t("tabs.journal"), headerLargeTitleEnabled: true, unstable_headerRightItems: () => [ + { + type: "button", + label: t("journal.newJournal"), + icon: { type: "sfSymbol", name: "folder.badge.plus" }, + onPress: () => router.push("/(journal)/create"), + }, ...(activeJournal ? [ { @@ -61,6 +67,7 @@ export default function JournalScreen() { name: "ellipsis" as const, }, menu: { + title: activeJournal.name, items: [ { type: "action" as const, @@ -110,12 +117,6 @@ export default function JournalScreen() { }, ] : []), - { - type: "button", - label: t("journal.newJournal"), - icon: { type: "sfSymbol", name: "folder.badge.plus" }, - onPress: () => router.push("/(journal)/create"), - }, ], }} /> diff --git a/src/app/_layout.tsx b/src/app/_layout.tsx index f6b8f9d..08b5c48 100644 --- a/src/app/_layout.tsx +++ b/src/app/_layout.tsx @@ -1,13 +1,21 @@ import "@/i18n"; import "@/polyfills"; -import React from "react"; +import React, { useEffect } from "react"; + +import { downloadModel } from "@react-native-ai/llama"; import AppTabs from "@/components/app-tabs"; import { DrizzleProvider } from "@/components/drizzle-provider"; +import { AI_MODEL } from "@/constants/ai-models"; import { useAutoReflection } from "@/hooks/settings/use-auto-reflection"; function AppContent() { useAutoReflection(); + + useEffect(() => { + downloadModel(AI_MODEL.gguf).catch((e) => console.warn("[model-download]", e)); + }, []); + return ; } diff --git a/src/app/days/_layout.tsx b/src/app/days/_layout.tsx index c2b3317..56267be 100644 --- a/src/app/days/_layout.tsx +++ b/src/app/days/_layout.tsx @@ -21,6 +21,7 @@ export default function DaysLayout() { }} > + startOfDay()); - const [showCalendar, setShowCalendar] = useState(false); const router = useRouter(); - const glassWidth = useSharedValue(200); + const today = startOfDay(); + + const [selectedDate, setSelectedDate] = useState(() => today); + const [slotDates, setSlotDates] = useState<[Date, Date, Date]>(() => [ + addDays(today, -1), + today, + addDays(today, 1), + ]); + const [showCalendar, setShowCalendar] = useState(false); + // Refs for animation callbacks (closures) + const slotDatesRef = useRef(slotDates); + slotDatesRef.current = slotDates; + + // Shared values + const centerSlot = useSharedValue(1); const translateX = useSharedValue(0); + const glassWidth = useSharedValue(200); + const canGoNextSV = useSharedValue(addDays(selectedDate, 1) <= today); - const prevDate = addDays(selectedDate, -1); - const nextDate = addDays(selectedDate, 1); + // Header sizer: measure each slot's text width, interpolate during swipe + const slotWidth0 = useSharedValue(0); + const slotWidth1 = useSharedValue(0); + const slotWidth2 = useSharedValue(0); + const slotWidths = [slotWidth0, slotWidth1, slotWidth2] as const; - const today = startOfDay(); + const sizerStyle = useAnimatedStyle(() => { + const ws = [slotWidth0.value, slotWidth1.value, slotWidth2.value]; + const center = centerSlot.value; + const p = translateX.value / screenWidth; + const absP = Math.min(Math.abs(p), 1); + const target = p < 0 ? (center + 1) % 3 : (center + 2) % 3; + const w = ws[center] * (1 - absP) + ws[target] * absP; + return { width: w > 0 ? w : undefined }; + }); - const canGoNext = nextDate <= today; + // Derived + const canGoNext = addDays(selectedDate, 1) <= today; + canGoNextSV.value = canGoNext; - const commitDate = (timestamp: number) => { - setSelectedDate(new Date(timestamp)); - requestAnimationFrame(() => { - translateX.value = 0; - }); + // Data lifting — 1 query for entire range + const entriesByDate = useDaysEntries(selectedDate); + + // After swipe: recycle the off-screen slot, update selectedDate + const afterSwipe = (newCenter: number, direction: "left" | "right") => { + const dates = slotDatesRef.current; + const next = [...dates] as [Date, Date, Date]; + + if (direction === "left") { + const recycleSlot = (newCenter + 1) % 3; + next[recycleSlot] = addDays(dates[newCenter], 1); + } else { + const recycleSlot = (newCenter + 2) % 3; + next[recycleSlot] = addDays(dates[newCenter], -1); + } + + slotDatesRef.current = next; + setSlotDates(next); + setSelectedDate(dates[newCenter]); }; const handleSwipeEnd = (translationX: number) => { - if (translationX < -SWIPE_THRESHOLD && canGoNext) { - const ts = nextDate.getTime(); - translateX.value = withTiming(-screenWidth, { duration: 200 }, () => { - runOnJS(commitDate)(ts); + if (translationX < -SWIPE_THRESHOLD && canGoNextSV.value) { + translateX.value = withTiming(-screenWidth, { duration: 200 }, (finished) => { + if (!finished) return; + const newCenter = (centerSlot.value + 1) % 3; + centerSlot.value = newCenter; + translateX.value = 0; + runOnJS(afterSwipe)(newCenter, "left"); }); } else if (translationX > SWIPE_THRESHOLD) { - const ts = prevDate.getTime(); - translateX.value = withTiming(screenWidth, { duration: 200 }, () => { - runOnJS(commitDate)(ts); + translateX.value = withTiming(screenWidth, { duration: 200 }, (finished) => { + if (!finished) return; + const newCenter = (centerSlot.value + 2) % 3; + centerSlot.value = newCenter; + translateX.value = 0; + runOnJS(afterSwipe)(newCenter, "right"); }); } else { translateX.value = withTiming(0, { duration: 200 }); @@ -80,22 +130,16 @@ export default function DaysScreen() { runOnJS(handleSwipeEnd)(event.translationX); }); - const stripStyle = useAnimatedStyle(() => ({ - transform: [{ translateX: translateX.value }], - })); - - const currentHeaderStyle = useAnimatedStyle(() => { - const p = translateX.value / screenWidth; - return { transform: [{ translateX: p * glassWidth.value }] }; - }); - const prevHeaderStyle = useAnimatedStyle(() => { - const p = translateX.value / screenWidth; - return { transform: [{ translateX: (p - 1) * glassWidth.value }] }; - }); - const nextHeaderStyle = useAnimatedStyle(() => { - const p = translateX.value / screenWidth; - return { transform: [{ translateX: (p + 1) * glassWidth.value }] }; - }); + // Calendar picker: reset all slots + const handleCalendarDate = (date: Date) => { + const d = startOfDay(date); + const newDates: [Date, Date, Date] = [addDays(d, -1), d, addDays(d, 1)]; + slotDatesRef.current = newDates; + setSlotDates(newDates); + setSelectedDate(d); + centerSlot.value = 1; + translateX.value = 0; + }; return ( <> @@ -104,6 +148,21 @@ export default function DaysScreen() { headerLargeTitleEnabled: false, headerTitle: () => ( setShowCalendar(true)}> + {/* 幅計測用(不可視・GlassView外) */} + + {slotDates.map((date, i) => ( + { + slotWidths[i].value = e.nativeEvent.layout.width; + }} + > + {formatDateDays(date)} + + ))} + + - - {formatDateDays(selectedDate)} - + {/* 幅アニメーション用 sizer */} + - - {formatDateDays(prevDate)} - - - - {formatDateDays(selectedDate)} - - - {canGoNext && ( - - {formatDateDays(nextDate)} - - )} + {slotDates.map((date, i) => ( + + ))} @@ -146,27 +202,18 @@ export default function DaysScreen() { /> - - - - - - - - - - {canGoNext && ( - + {slotDates.map((date, i) => ( + - - - )} + + + ))} @@ -175,7 +222,7 @@ export default function DaysScreen() { @@ -193,6 +240,14 @@ const styles = StyleSheet.create({ paddingVertical: 12, borderRadius: 100, }, + headerMeasure: { + position: "absolute", + opacity: 0, + pointerEvents: "none", + }, + headerSizer: { + height: 20, + }, headerClip: { ...StyleSheet.absoluteFillObject, overflow: "hidden", @@ -200,9 +255,6 @@ const styles = StyleSheet.create({ justifyContent: "center", borderRadius: 100, }, - headerSizer: { - opacity: 0, - }, headerLabel: { position: "absolute", width: 300, @@ -216,12 +268,4 @@ const styles = StyleSheet.create({ pager: { flex: 1, }, - page: { - ...StyleSheet.absoluteFillObject, - }, - dot: { - width: 40, - height: 40, - borderRadius: 20, - }, }); diff --git a/src/components/days/days-card.tsx b/src/components/days/days-card.tsx index 2c416a7..e2171c9 100644 --- a/src/components/days/days-card.tsx +++ b/src/components/days/days-card.tsx @@ -1,85 +1,76 @@ -import { useTranslation } from "react-i18next"; import { PlatformColor } from "react-native"; -import { Button, HStack, Image, Section, Spacer, Text as SText, VStack } from "@expo/ui/swift-ui"; -import { font, foregroundStyle, listRowSeparator, opacity } from "@expo/ui/swift-ui/modifiers"; +import { Button, HStack, Image, Rectangle, Section, Spacer, Text } from "@expo/ui/swift-ui"; +import { + font, + foregroundStyle, + frame, + lineLimit, + listRowSeparator, + padding, +} from "@expo/ui/swift-ui/modifiers"; +import { useRouter } from "expo-router"; -import { EntryDetailObj } from "@/db/queries/entries"; -import { JournalObj } from "@/db/schemas"; -import { deserializeValue } from "@/hooks/entry/use-entry"; +import { type DailyEntryObj } from "@/db/queries/entries"; import { formatTime } from "@/utils/date"; +import { buildPreviewEntry } from "@/utils/entry/preview"; -import { EntryFieldItem } from "../entry/entry-field-item"; - -const PREVIEW_COUNT = 2; +import { secondary } from "../entry/entry-row"; type Props = { - entry: EntryDetailObj & { journal: JournalObj }; - isFirstOfJournal: boolean; - isExpanded: boolean; - onToggleExpand: () => void; + journalEntries: DailyEntryObj[]; }; /** - * Days画面のエントリーカード + * Days画面 — ジャーナル単位のタイムラインセクション */ -export function DaysCard({ entry, isFirstOfJournal, isExpanded, onToggleExpand }: Props) { - const { t } = useTranslation(); - const { journal, values, createdAt } = entry; - const sorted = [...values].sort((a, b) => a.field.sortOrder - b.field.sortOrder); - const hasMore = sorted.length > PREVIEW_COUNT; - const visibleFields = isExpanded ? sorted : sorted.slice(0, PREVIEW_COUNT); +export function DaysCard({ journalEntries }: Props) { + const router = useRouter(); + + const { journal } = journalEntries[0]; return (
- {isFirstOfJournal && ( - - - - {journal.name} - - - )} - - {formatTime(createdAt)} + + + + {journal.name} + } > - {visibleFields.map((v, i) => { - const isFading = !isExpanded && hasMore && i === visibleFields.length - 1; - if (isFading) { - return ( - - - - ); - } + {journalEntries.map((entry) => { + const preview = buildPreviewEntry(entry); return ( - + ); })} - {hasMore && ( -
); } diff --git a/src/components/days/days-header-slot.tsx b/src/components/days/days-header-slot.tsx new file mode 100644 index 0000000..46b6e5f --- /dev/null +++ b/src/components/days/days-header-slot.tsx @@ -0,0 +1,52 @@ +import { PlatformColor, StyleSheet, Text } from "react-native"; +import Animated, { type SharedValue, useAnimatedStyle } from "react-native-reanimated"; + +import { slotDiff } from "./slot-diff"; + +type Props = { + slotIndex: number; + centerSlot: SharedValue; + translateX: SharedValue; + screenWidth: number; + glassWidth: SharedValue; + label: string; +}; + +/** + * ヘッダー日付のローテーティングスロット + */ +export const DaysHeaderSlot = ({ + slotIndex, + centerSlot, + translateX, + screenWidth, + glassWidth, + label, +}: Props) => { + const style = useAnimatedStyle(() => { + const diff = slotDiff(slotIndex, centerSlot.value); + const p = translateX.value / screenWidth; + return { + transform: [{ translateX: (diff + p) * glassWidth.value }], + }; + }); + + return ( + + {label} + + ); +}; + +const styles = StyleSheet.create({ + headerLabel: { + position: "absolute", + width: 300, + alignItems: "center", + }, + headerText: { + fontSize: 16, + fontWeight: "600", + color: PlatformColor("label"), + }, +}); diff --git a/src/components/days/days-page-slot.tsx b/src/components/days/days-page-slot.tsx new file mode 100644 index 0000000..c4cd909 --- /dev/null +++ b/src/components/days/days-page-slot.tsx @@ -0,0 +1,41 @@ +import { StyleSheet } from "react-native"; +import Animated, { type SharedValue, useAnimatedStyle } from "react-native-reanimated"; + +import { slotDiff } from "./slot-diff"; + +type Props = { + slotIndex: number; + centerSlot: SharedValue; + translateX: SharedValue; + screenWidth: number; + children: React.ReactNode; +}; + +/** + * ローテーティングバッファのページスロット + * centerSlot と translateX から自身の位置を算出する + */ +export const DaysPageSlot = ({ + slotIndex, + centerSlot, + translateX, + screenWidth, + children, +}: Props) => { + const style = useAnimatedStyle(() => { + const diff = slotDiff(slotIndex, centerSlot.value); + return { + transform: [{ translateX: diff * screenWidth + translateX.value }], + }; + }); + + return ( + {children} + ); +}; + +const styles = StyleSheet.create({ + page: { + ...StyleSheet.absoluteFillObject, + }, +}); diff --git a/src/components/days/days-view.tsx b/src/components/days/days-view.tsx index 7e34991..84bcb4e 100644 --- a/src/components/days/days-view.tsx +++ b/src/components/days/days-view.tsx @@ -1,39 +1,38 @@ -import { useState } from "react"; import { useTranslation } from "react-i18next"; import { PlatformColor, Text, View } from "react-native"; import { Host, List } from "@expo/ui/swift-ui"; -import { animation, Animation, frame } from "@expo/ui/swift-ui/modifiers"; -import { useLiveQuery } from "drizzle-orm/expo-sqlite"; +import { frame, listSectionSpacing, listStyle } from "@expo/ui/swift-ui/modifiers"; -import { getEntriesByDateQuery } from "@/db/queries/entries"; +import { type DailyEntryObj } from "@/db/queries/entries"; import { DaysCard } from "./days-card"; import { DaysLLMReflection } from "./days-llm-reflection"; +/** エントリーをジャーナルごとにグループ化 */ +const groupByJournal = (entries: DailyEntryObj[]): [string, DailyEntryObj[]][] => { + const map = new Map(); + for (const entry of entries) { + const id = entry.journal.id; + const group = map.get(id); + if (group) group.push(entry); + else map.set(id, [entry]); + } + return [...map.entries()]; +}; + type Props = { - /** - * 日付 - */ + /** 日付 */ date: Date; + /** その日のエントリー(親から渡される) */ + entries?: DailyEntryObj[]; }; /** * Days画面 — 指定日のエントリー一覧をスクロール表示 */ -export function DaysView({ date }: Props) { +export function DaysView({ date, entries }: Props) { const { t } = useTranslation(); - const { data: entries } = useLiveQuery(getEntriesByDateQuery(date), [date.getTime()]); - const [expandedIds, setExpandedIds] = useState>(new Set()); - - const toggleExpand = (id: string) => { - setExpandedIds((prev) => { - const next = new Set(prev); - if (next.has(id)) next.delete(id); - else next.add(id); - return next; - }); - }; if (!entries || entries.length === 0) { return ( @@ -56,24 +55,17 @@ export function DaysView({ date }: Props) { return ( - + - {entries.map((entry, index) => ( - e.journal.id === entry.journal.id) === index - } - isExpanded={expandedIds.has(entry.id)} - onToggleExpand={() => toggleExpand(entry.id)} - /> + {groupByJournal(entries).map(([journalId, journalEntries]) => ( + ))} diff --git a/src/components/days/slot-diff.ts b/src/components/days/slot-diff.ts new file mode 100644 index 0000000..f247aeb --- /dev/null +++ b/src/components/days/slot-diff.ts @@ -0,0 +1,8 @@ +/** 3 スロットの循環差分(-1, 0, +1) */ +export const slotDiff = (slotIndex: number, center: number) => { + "worklet"; + let d = slotIndex - center; + if (d > 1) d -= 3; + if (d < -1) d += 3; + return d; +}; diff --git a/src/components/entry/entry-list-view.tsx b/src/components/entry/entry-list-view.tsx index d823974..2f9bbd2 100644 --- a/src/components/entry/entry-list-view.tsx +++ b/src/components/entry/entry-list-view.tsx @@ -1,7 +1,8 @@ +import { useEffect, useState } from "react"; import { PlatformColor, Pressable, StyleSheet, View } from "react-native"; import { Host, List, Section } from "@expo/ui/swift-ui"; -import { frame, listStyle } from "@expo/ui/swift-ui/modifiers"; +import { animation, Animation, frame, listStyle, opacity } from "@expo/ui/swift-ui/modifiers"; import { GlassView } from "expo-glass-effect"; import { useRouter } from "expo-router"; import { SymbolView } from "expo-symbols"; @@ -46,6 +47,14 @@ export function EntryListView({ bookmarkOnly, }); + const [fade, setFade] = useState(1); + + useEffect(() => { + setFade(0.25); + const id = requestAnimationFrame(() => setFade(1)); + return () => cancelAnimationFrame(id); + }, [activeJournalId, bookmarkOnly]); + return ( indices.forEach(async (i) => await deleteEntry(entries[i].id))} + modifiers={[opacity(fade), animation(Animation.easeInOut({ duration: 0.1 }), fade)]} > {entries.map((entry) => ( diff --git a/src/components/entry/entry-row.tsx b/src/components/entry/entry-row.tsx index 0968bdb..352ab3e 100644 --- a/src/components/entry/entry-row.tsx +++ b/src/components/entry/entry-row.tsx @@ -9,7 +9,7 @@ import { bookmarkEntry, deleteEntry } from "@/db/queries/entries"; import { formatDate } from "@/utils/date"; import { PreviewEntryObj } from "@/utils/entry/preview"; -const secondary = foregroundStyle({ type: "hierarchical", style: "secondary" }); +export const secondary = foregroundStyle({ type: "hierarchical", style: "secondary" }); type Props = { /** ジャーナル */ @@ -48,6 +48,11 @@ export function EntryRow({ journalName, entry }: Props) { + ))} diff --git a/src/components/journal/journal-chip.tsx b/src/components/journal/journal-chip.tsx index 371f8e2..cb31656 100644 --- a/src/components/journal/journal-chip.tsx +++ b/src/components/journal/journal-chip.tsx @@ -2,6 +2,8 @@ import { PlatformColor, useWindowDimensions } from "react-native"; import { HStack, Image, ScrollView, Spacer, Text } from "@expo/ui/swift-ui"; import { + animation, + Animation, defaultScrollAnchor, fixedSize, font, @@ -33,17 +35,12 @@ const estimateWidth = (journals: JournalWithCountObj[]) => { const chipBase = [padding({ vertical: 6, horizontal: CHIP_H_PAD }), font({ size: 14 })]; -const glassLabel = [ - ...chipBase, - glassEffect({ - glass: { variant: "regular", interactive: true }, - shape: "capsule", - }), - foregroundStyle(PlatformColor("systemGray")), -]; +const glassInactive = glassEffect({ + glass: { variant: "regular", interactive: true }, + shape: "capsule", +}); -const activeLabel = (color: string) => [ - ...chipBase, +const glassActive = (color: string) => glassEffect({ glass: { variant: "regular", @@ -51,9 +48,7 @@ const activeLabel = (color: string) => [ tint: color, }, shape: "capsule", - }), - foregroundStyle("white"), -]; + }); type Props = { /** ジャーナル */ @@ -102,7 +97,10 @@ export function JournalChipList({ journals, activeJournalId, onSelect, scrollToE onSelect(journal.id)), ]} > diff --git a/src/components/journal/journal-create-view.tsx b/src/components/journal/journal-create-view.tsx index 642ea94..dbdbdcd 100644 --- a/src/components/journal/journal-create-view.tsx +++ b/src/components/journal/journal-create-view.tsx @@ -15,6 +15,8 @@ import { ZStack, } from "@expo/ui/swift-ui"; import { + animation, + Animation, environment, foregroundStyle, frame, @@ -78,6 +80,7 @@ export function JournalCreateView({ modifiers={[ frame({ maxWidth: 9999, maxHeight: 9999 }), environment("editMode", "inactive"), + animation(Animation.easeInOut({ duration: 0.25 }), fields.length), ]} > {/* ジャーナル名・アイコン・カラー */} diff --git a/src/components/settings/application.tsx b/src/components/settings/application.tsx index 27199e7..6a688df 100644 --- a/src/components/settings/application.tsx +++ b/src/components/settings/application.tsx @@ -1,21 +1,9 @@ -import { useState } from "react"; import { useTranslation } from "react-i18next"; -import { Alert, Linking, PlatformColor } from "react-native"; +import { Linking, PlatformColor } from "react-native"; -import { - Button, - DatePicker, - HStack, - Picker, - Section, - Spacer, - Text, - Toggle, -} from "@expo/ui/swift-ui"; -import { disabled, foregroundStyle, tag, tint } from "@expo/ui/swift-ui/modifiers"; -import { downloadModel, removeModel } from "@react-native-ai/llama"; +import { Button, DatePicker, HStack, Section, Spacer, Text, Toggle } from "@expo/ui/swift-ui"; +import { foregroundStyle, tint } from "@expo/ui/swift-ui/modifiers"; -import { AI_MODELS, type AIModelId } from "@/constants/ai-models"; import { useAIReflectionSettings } from "@/hooks/settings/use-ai-reflection-settings"; /** @@ -23,58 +11,8 @@ import { useAIReflectionSettings } from "@/hooks/settings/use-ai-reflection-sett */ export function Application() { const { t } = useTranslation(); - const { - aiReflectionEnabled, - aiModel, - reflectionTime, - setAIReflectionEnabled, - setAIModel, - setReflectionTime, - } = useAIReflectionSettings(); - const [downloading, setDownloading] = useState(false); - const [pendingModelId, setPendingModelId] = useState(null); - const displayModelId = pendingModelId ?? aiModel.id; - - const handleModelSelect = (id: string) => { - if (downloading) return; - const modelId = id as AIModelId; - if (modelId === aiModel.id) return; - - const newModel = AI_MODELS.find((m) => m.id === modelId); - if (!newModel) return; - - setPendingModelId(modelId); - - Alert.alert( - t("settings.downloadModel"), - t("settings.downloadConfirm", { model: newModel.label }), - [ - { - text: t("common.cancel"), - style: "cancel", - onPress: () => setPendingModelId(null), - }, - { - text: t("common.ok"), - onPress: async () => { - setDownloading(true); - try { - await downloadModel(newModel.gguf); - const oldGguf = aiModel.gguf; - await setAIModel(newModel.id); - await removeModel(oldGguf).catch(() => {}); - } catch (error) { - console.warn("[model-switch]", error); - } finally { - setPendingModelId(null); - setDownloading(false); - } - }, - }, - ], - { cancelable: false }, - ); - }; + const { aiReflectionEnabled, reflectionTime, setAIReflectionEnabled, setReflectionTime } = + useAIReflectionSettings(); return (
@@ -103,18 +41,6 @@ export function Application() { label={t("settings.aiReflection")} modifiers={[tint(PlatformColor("systemIndigo"))]} /> - - {AI_MODELS.map((model) => ( - - {model.label} - - ))} - { }); }; +/** + * 日付範囲のエントリー一覧をフィールドとともに取得するクエリ + * @param start 開始日(0:00:00) + * @param end 終了日の翌日(0:00:00、この日は含まない) + */ +export const getEntriesByRangeQuery = (start: Date, end: Date) => + db.query.entries.findMany({ + where: and(gte(entries.createdAt, start.getTime()), lt(entries.createdAt, end.getTime())), + with: { + journal: true, + values: { + with: { field: true }, + }, + }, + }); + /** エントリー詳細の型 */ export type EntryDetailObj = Awaited>[number]; diff --git a/src/hooks/days/use-days-entries.ts b/src/hooks/days/use-days-entries.ts new file mode 100644 index 0000000..fe81b2d --- /dev/null +++ b/src/hooks/days/use-days-entries.ts @@ -0,0 +1,61 @@ +import { useRef } from "react"; + +import { useLiveQuery } from "drizzle-orm/expo-sqlite"; + +import { type DailyEntryObj, getEntriesByRangeQuery } from "@/db/queries/entries"; +import { addDays, startOfDay } from "@/utils/date"; + +/** 前後何日分をまとめて取得するか(片側) */ +const RANGE_DAYS = 3; + +/** 範囲の端からこの日数以内に近づいたら範囲を再計算する */ +const BOUNDARY_DAYS = 3; + +type Range = { start: Date; end: Date }; + +const computeRange = (center: Date): Range => ({ + start: addDays(center, -RANGE_DAYS), + end: addDays(center, RANGE_DAYS + 1), +}); + +const isNearBoundary = (date: Date, range: Range) => { + const msPerDay = 24 * 60 * 60 * 1000; + const daysFromStart = (date.getTime() - range.start.getTime()) / msPerDay; + const daysFromEnd = (range.end.getTime() - date.getTime()) / msPerDay; + return daysFromStart < BOUNDARY_DAYS || daysFromEnd < BOUNDARY_DAYS; +}; + +const dateKey = (ts: number) => startOfDay(new Date(ts)).getTime(); + +const groupByDate = (entries: DailyEntryObj[]) => { + const map = new Map(); + for (const entry of entries) { + const key = dateKey(entry.createdAt); + const arr = map.get(key); + if (arr) arr.push(entry); + else map.set(key, [entry]); + } + return map; +}; + +/** + * selectedDate を中心に前後 RANGE_DAYS 分のエントリーをまとめて取得し、 + * 日付ごとにグルーピングして返すフック。 + * スワイプのたびに DB 再購読は発生しない。 + */ +export const useDaysEntries = (selectedDate: Date) => { + const rangeRef = useRef(computeRange(selectedDate)); + + if (isNearBoundary(selectedDate, rangeRef.current)) { + rangeRef.current = computeRange(selectedDate); + } + + const range = rangeRef.current; + + const { data: allEntries } = useLiveQuery(getEntriesByRangeQuery(range.start, range.end), [ + range.start.getTime(), + range.end.getTime(), + ]); + + return groupByDate(allEntries); +}; diff --git a/src/hooks/settings/use-ai-reflection-settings.ts b/src/hooks/settings/use-ai-reflection-settings.ts index b2701eb..7873426 100644 --- a/src/hooks/settings/use-ai-reflection-settings.ts +++ b/src/hooks/settings/use-ai-reflection-settings.ts @@ -1,4 +1,3 @@ -import { AI_MODELS, type AIModelId, DEFAULT_MODEL_ID } from "@/constants/ai-models"; import { setSetting, useSettingsQuery } from "@/db/queries/settings"; /** デフォルトの振り返り時間 */ @@ -10,7 +9,6 @@ const defaultReflectionTime = () => { const KEYS = { aiReflectionEnabled: "ai_reflection_enabled", - aiModel: "ai_model", reflectionTime: "reflection_time", } as const; @@ -18,10 +16,8 @@ const KEYS = { * AI Reflection の設定を読み書きするフック * @returns * - aiReflectionEnabled 振り返り機能の有効フラグ - * - aiModel 選択中のモデル * - reflectionTime 振り返りを生成する時刻 * - setAIReflectionEnabled 有効/無効を切り替えて DB に保存する - * - setAIModel 使用モデルを切り替えて DB に保存する * - setReflectionTime 振り返りを生成する時刻を DB に保存する */ export function useAIReflectionSettings() { @@ -31,11 +27,6 @@ export function useAIReflectionSettings() { const aiReflectionEnabled = get(KEYS.aiReflectionEnabled) !== "false"; - const storedModelId = get(KEYS.aiModel) as AIModelId | null; - const aiModel = - AI_MODELS.find((m) => m.id === storedModelId) ?? - AI_MODELS.find((m) => m.id === DEFAULT_MODEL_ID)!; - const setAIReflectionEnabled = async (enabled: boolean) => { await setSetting(KEYS.aiReflectionEnabled, String(enabled)); }; @@ -43,20 +34,14 @@ export function useAIReflectionSettings() { const storedTime = get(KEYS.reflectionTime); const reflectionTime = storedTime ? new Date(Number(storedTime)) : defaultReflectionTime(); - const setAIModel = async (modelId: AIModelId) => { - await setSetting(KEYS.aiModel, modelId); - }; - const setReflectionTime = async (date: Date) => { await setSetting(KEYS.reflectionTime, String(date.getTime())); }; return { aiReflectionEnabled, - aiModel, reflectionTime, setAIReflectionEnabled, - setAIModel, setReflectionTime, } as const; } diff --git a/src/hooks/settings/use-auto-reflection.ts b/src/hooks/settings/use-auto-reflection.ts index 2807160..ec72040 100644 --- a/src/hooks/settings/use-auto-reflection.ts +++ b/src/hooks/settings/use-auto-reflection.ts @@ -18,7 +18,7 @@ import { getReflection } from "@/utils/days/reflection/get-reflection"; * 4. 今日のエントリーが1件以上ある */ export const useAutoReflection = () => { - const { aiReflectionEnabled, aiModel, reflectionTime } = useAIReflectionSettings(); + const { aiReflectionEnabled, reflectionTime } = useAIReflectionSettings(); const today = useMemo(() => startOfDay(), []); const { data: entries } = useLiveQuery(getEntriesByDateQuery(today), [today.getTime()]); @@ -35,7 +35,7 @@ export const useAutoReflection = () => { generating.current = true; (async () => { try { - const result = await getReflection(entries, aiModel.gguf); + const result = await getReflection(entries); if (result) await storeReflection(today, result); } catch (error) { console.warn("[auto-reflection]", error); @@ -43,5 +43,5 @@ export const useAutoReflection = () => { generating.current = false; } })(); - }, [entries, reflection, aiReflectionEnabled, reflectionTime, aiModel.gguf, today]); + }, [entries, reflection, aiReflectionEnabled, reflectionTime, today]); }; diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 7530ab1..e34147e 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -57,11 +57,7 @@ "title": "Settings", "notification": "Notifications", "aiReflection": "AI Reflection", - "downloading": "Downloading\u2026", - "model": "Model", "reflectionTime": "Reflection Time", - "downloadModel": "Download Model", - "downloadConfirm": "Download {{model}}? The current model will be removed.", "language": "Language", "currentLanguage": "English", "exportDialogTitle": "Export Journal Template", diff --git a/src/i18n/locales/ja.json b/src/i18n/locales/ja.json index 3c4675d..0e08ba9 100644 --- a/src/i18n/locales/ja.json +++ b/src/i18n/locales/ja.json @@ -57,11 +57,7 @@ "title": "設定", "notification": "通知", "aiReflection": "AI振り返り", - "downloading": "ダウンロード中\u2026", - "model": "モデル", "reflectionTime": "振り返りの時刻", - "downloadModel": "モデルをダウンロード", - "downloadConfirm": "{{model}}をダウンロードしますか?現在のモデルは削除されます。", "language": "言語", "currentLanguage": "日本語", "exportDialogTitle": "ジャーナルテンプレートを書き出す", diff --git a/src/utils/days/reflection/get-reflection.ts b/src/utils/days/reflection/get-reflection.ts index 8404836..c071f16 100644 --- a/src/utils/days/reflection/get-reflection.ts +++ b/src/utils/days/reflection/get-reflection.ts @@ -1,6 +1,7 @@ import { downloadModel, llama } from "@react-native-ai/llama"; import { generateText } from "ai"; +import { AI_MODEL } from "@/constants/ai-models"; import { type ReflectionResult, buildSystemPrompt, @@ -33,13 +34,9 @@ const categoryList = Object.entries(reflectionCategories) /** * その日の記録をもとに AI Reflection を生成する * @param entries その日のエントリーの一覧 - * @param gguf HuggingFaceのGGUFモデルパス */ -export const getReflection = async ( - entries: DailyEntryObj[], - gguf: string, -): Promise => { - const modelPath = await downloadModel(gguf); +export const getReflection = async (entries: DailyEntryObj[]): Promise => { + const modelPath = await downloadModel(AI_MODEL.gguf); const model = llama.languageModel(modelPath); try {