diff --git a/CLAUDE.md b/CLAUDE.md index dc99fad..6172147 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -9,11 +9,11 @@ Nicky is a React Native journaling app built with Expo and Expo Router. It featu ## Development Commands ```bash -pnpm expo run:ios # Build and run on iOS simulator -pnpm expo start --clear # Start Expo dev server (clear cache) -pnpm lint # Run ESLint -pnpm lint-fix # Run ESLint with auto-fix -pnpm typecheck # TypeScript type check +pnpm expo run:ios # Build and run on iOS simulator +pnpm expo start --clear # Start Expo dev server (clear cache) +pnpm lint # Run ESLint +pnpm lint-fix # Run ESLint with auto-fix +pnpm typecheck # TypeScript type check pnpm drizzle-kit generate # Generate migration files from schema ``` @@ -39,9 +39,11 @@ src/app/ _layout.tsx # Stack — scoped to journal tab index.tsx # Journal list / create.tsx # Create journal /create + edit.tsx # Edit journal /edit?journalId=... [id].tsx # Entry list /[id] entry/ - [id].tsx # Entry detail /entry/[id] + create.tsx # Create entry /entry/create?journalId=...&journalName=... + [id].tsx # Entry detail /entry/[id]?journalName=... explore.tsx # Report tab ``` @@ -66,14 +68,65 @@ Drizzle ORM + expo-sqlite. Schema is split by domain in `src/db/schemas/`: **Drizzle config notes:** - Schema files must not import React Native packages (`expo-crypto`, `expo-symbols` runtime imports) — drizzle-kit runs in Node.js. Use `import type` for RN types. - `$defaultFn` with `Crypto.randomUUID()` cannot be used in schema — generate IDs at the application layer instead. +- SQLite column names in Drizzle are **camelCase** (e.g. `sortOrder`, not `sort_order`). When writing raw SQL in `onConflictDoUpdate`, quote them: `excluded."sortOrder"`. + +### `useLiveQuery` Reactivity Rules + +`useLiveQuery` from `drizzle-orm/expo-sqlite` **only watches the root table** of a query — it does NOT automatically detect changes to tables joined via `with:`. The second argument is just `useEffect` deps, not additional table watchers. + +**Pattern used in this codebase:** When a transaction modifies a related table (e.g. `fields`), also `touch` the root-table row so `useLiveQuery` re-runs: + +```ts +// In updateJournal — touch entries.updatedAt so entry-list useLiveQuery detects the change +await tx.update(entries).set({ updatedAt: Date.now() }).where(eq(entries.journalId, journalId)); +``` + +This means: +- `getEntriesQuery` (`entries` root) re-runs after any `fields` or `entry_values` change because those transactions touch `entries.updatedAt` +- `getFieldsQuery` (`fields` root) re-runs directly when `fields` is written — no touch needed + +### Sub-component Pattern for Forms with `useRef` State + +Hooks that use `useRef` + an `initialized` flag (e.g. `useEntry`, `useJournalField`) only initialize once per mount. To ensure they pick up fresh data when switching to edit mode, **mount the form as a separate child component** rather than toggling visibility on the parent: + +```tsx +// ✅ Correct — EntryEditForm mounts fresh each time editMode becomes true +{editMode && } + +// ❌ Wrong — form is always mounted; useRef init won't re-run with new data + // toggled visible/hidden +``` + +This pattern is used in `entry/[id].tsx` (`EntryEditForm`) and `edit.tsx` (`JournalEditForm`). + +### Field Value Serialization + +All field values are stored as `text | null` in `entry_values.value`. Serialization/deserialization lives in `src/utils/entry/use-entry.ts`: +- `serializeValue(FieldValue) → string | null` — converts to DB text +- `deserializeValue(string | null, FieldType) → FieldValue` — converts from DB text +- `FieldType` values: `"text" | "longText" | "number" | "media" | "check" | "date" | "time" | "location"` +- Dates/times are stored as millisecond timestamps (`String(date.getTime())`) + +`buildEntryFormData` in `src/utils/entry/entry-form.ts` extracts fields (sorted by `sortOrder`) and initial values from an `EntryDetailObj`. + +### Entry Preview Pipeline + +`EntryDetailObj` (raw DB join) → `PreviewEntryObj` (display) via `buildPreviewEntry` in `src/utils/entry/preview.ts`: +- First field value = title (falls back to formatted `createdAt`) +- Remaining field values joined with spaces = preview (also used for search) +- Entries grouped by month for the list view (`groupByMonth`) + +### Keyboard Dismiss Rule + +Always call `Keyboard.dismiss()` **before** any `async` save operation that triggers navigation. Skipping this causes a `RemoteTextInput` session crash on iOS when the keyboard is mid-input as the screen unmounts. ### Key Technologies | Package | Usage | |---|---| | `expo-router` | File-based routing, `useRouter`, `useLocalSearchParams` | -| `@expo/ui/swift-ui` | SwiftUI components: `Host`, `ZStack`, `VStack`, `Grid`, `ScrollView`, `List`, `Section`, `Button`, `Image`, `Text`, `RoundedRectangle`, `ColorPicker`, `BottomSheet` | -| `@expo/ui/swift-ui/modifiers` | `frame`, `padding`, `foregroundStyle`, `onTapGesture`, `listStyle`, `presentationDetents`, `environment`, `fixedSize` | +| `@expo/ui/swift-ui` | SwiftUI components: `Host`, `ZStack`, `VStack`, `HStack`, `Spacer`, `Grid`, `ScrollView`, `List`, `Section`, `Button`, `Image`, `Text`, `RoundedRectangle`, `ColorPicker`, `BottomSheet`, `ContextMenu` | +| `@expo/ui/swift-ui/modifiers` | `frame`, `padding`, `foregroundStyle`, `onTapGesture`, `listStyle`, `presentationDetents`, `environment`, `fixedSize`, `font`, `lineLimit` | | `expo-router/unstable-native-tabs` | `NativeTabs` — iOS native tab bar | | `expo-symbols` | `SymbolView` — SF Symbols in RN (non-SwiftUI) header components | | `expo-sqlite` + `drizzle-orm` | Local SQLite persistence | @@ -89,6 +142,7 @@ Drizzle ORM + expo-sqlite. Schema is split by domain in `src/db/schemas/`: - Adaptive colors: use `PlatformColor("label")` directly — no need for `useColorScheme` - `fixedSize()` on a component prevents it from stretching in an HStack, allowing siblings to fill remaining space - Gradients: `RoundedRectangle` + `foregroundStyle({ type: "linearGradient", ... })` + `clipShape` on parent `ZStack` +- Native navigation bar buttons: use `unstable_headerRightItems` on `Stack.Screen` (not `headerRight` + RN `Pressable`) ### Naming Conventions @@ -104,3 +158,4 @@ Drizzle ORM + expo-sqlite. Schema is split by domain in `src/db/schemas/`: - **TypeScript:** strict mode enabled - **Formatter:** Prettier (enforced via ESLint) - **React Compiler:** enabled — do not manually add `useMemo`/`useCallback` unless there is a specific reason +- **Event handler note:** extract `e.nativeEvent.text` synchronously before passing to async state updaters (React synthetic event pooling) diff --git a/eslint.config.js b/eslint.config.js index 1cb1f2d..2a2cf36 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -17,6 +17,16 @@ module.exports = defineConfig([ "unused-imports": unusedImports, }, rules: { + // const 以外の型アサーションを禁止 + "no-restricted-syntax": [ + "error", + { + selector: + "TSAsExpression:not([typeAnnotation.typeName.name='const'])", + message: + "Type assertions using 'as' are not allowed. Use type guards or proper type definitions instead ('as const' is permitted).", + }, + ], // Import/未使用変数 "no-unused-vars": "off", "@typescript-eslint/no-unused-vars": "off", diff --git a/src/app/(journal)/create.tsx b/src/app/(journal)/create.tsx index 4038bb7..b13e196 100644 --- a/src/app/(journal)/create.tsx +++ b/src/app/(journal)/create.tsx @@ -44,6 +44,7 @@ export default function JournalCreateScreen() { tintColor: formDisabled ? PlatformColor("tertiaryLabel") : PlatformColor("systemIndigo"), + variant: formDisabled ? undefined : "prominent", disabled: formDisabled, onPress: formDisabled ? () => {} : handleJournalCreate, }, diff --git a/src/app/(journal)/edit.tsx b/src/app/(journal)/edit.tsx index b3a958b..e27071d 100644 --- a/src/app/(journal)/edit.tsx +++ b/src/app/(journal)/edit.tsx @@ -68,6 +68,7 @@ function JournalEditForm({ journal }: FormProps) { tintColor: formDisabled ? PlatformColor("tertiaryLabel") : PlatformColor("systemIndigo"), + variant: formDisabled ? undefined : "prominent", disabled: formDisabled, onPress: formDisabled ? () => {} : handleSave, }, diff --git a/src/app/(journal)/entry/[id].tsx b/src/app/(journal)/entry/[id].tsx index bac0acd..3f3d869 100644 --- a/src/app/(journal)/entry/[id].tsx +++ b/src/app/(journal)/entry/[id].tsx @@ -74,6 +74,7 @@ export default function EntryDetailScreen() { label: "Save", icon: { type: "sfSymbol", name: "checkmark" }, tintColor: PlatformColor("systemIndigo"), + variant: "prominent", onPress: handleSave, }, ] diff --git a/src/app/(journal)/entry/create.tsx b/src/app/(journal)/entry/create.tsx index 67a0064..304af8f 100644 --- a/src/app/(journal)/entry/create.tsx +++ b/src/app/(journal)/entry/create.tsx @@ -40,6 +40,7 @@ export default function EntryCreateScreen() { label: "Create New Entry", icon: { type: "sfSymbol", name: "checkmark" }, tintColor: PlatformColor("systemIndigo"), + variant: "prominent", onPress: handleEntryCreate, }, ], diff --git a/src/app/explore.tsx b/src/app/explore.tsx index dc8b8ad..3da9555 100644 --- a/src/app/explore.tsx +++ b/src/app/explore.tsx @@ -3,7 +3,6 @@ import { PlatformColor, View } from "react-native"; import { Host, VStack } from "@expo/ui/swift-ui"; import { AllformList } from "@/components/all-form-list"; -import ChartDemo from "@/components/chart-demo"; export default function ExploreScreen() { return ( @@ -14,7 +13,6 @@ export default function ExploreScreen() { > - diff --git a/src/app/insights/_layout.tsx b/src/app/insights/_layout.tsx new file mode 100644 index 0000000..fbd1cbb --- /dev/null +++ b/src/app/insights/_layout.tsx @@ -0,0 +1,20 @@ +import { useColorScheme } from "react-native"; + +import { Stack } from "expo-router"; + +export default function InsightsLayout() { + const colorScheme = useColorScheme(); + const titleColor = colorScheme === "dark" ? "#ffffff" : "#000000"; + + return ( + + ); +} diff --git a/src/app/insights/index.tsx b/src/app/insights/index.tsx new file mode 100644 index 0000000..967279a --- /dev/null +++ b/src/app/insights/index.tsx @@ -0,0 +1,31 @@ +import { Stack } from "expo-router"; + +import { InsightsView } from "@/components/insights/insights-view"; + +/** + * インサイト画面 + */ +export default function InsightsScreen() { + // const router = useRouter(); + + return ( + <> + [ + { + type: "button", + label: "Create New Journal", + icon: { type: "sfSymbol", name: "gearshape" }, + // onPress: () => router.push("/(journal)/create"), + onPress: () => console.log("test"), + }, + ], + }} + /> + + + ); +} diff --git a/src/components/app-tabs.tsx b/src/components/app-tabs.tsx index c2d05d7..fa06ca1 100644 --- a/src/components/app-tabs.tsx +++ b/src/components/app-tabs.tsx @@ -20,8 +20,8 @@ export default function AppTabs() { - - Insite + + Insights diff --git a/src/components/chart-demo.tsx b/src/components/chart-demo.tsx deleted file mode 100644 index 28bfe9e..0000000 --- a/src/components/chart-demo.tsx +++ /dev/null @@ -1,74 +0,0 @@ -import { PlatformColor } from "react-native"; - -import { Chart, ScrollView, Text, VStack } from "@expo/ui/swift-ui"; -import { font, foregroundStyle, frame } from "@expo/ui/swift-ui/modifiers"; - -const weeklyData = [ - { x: "Mon", y: 3 }, - { x: "Tue", y: 7 }, - { x: "Wed", y: 2 }, - { x: "Thu", y: 5 }, - { x: "Fri", y: 8 }, - { x: "Sat", y: 4 }, - { x: "Sun", y: 6 }, -]; - -const trendData = [ - { x: 1, y: 12 }, - { x: 2, y: 18 }, - { x: 3, y: 9 }, - { x: 4, y: 24 }, - { x: 5, y: 20 }, - { x: 6, y: 30 }, - { x: 7, y: 27 }, - { x: 8, y: 35 }, -]; - -export default function ChartDemo() { - return ( - - - {/* 曜日別エントリー数 */} - - - Entries by Day - - - - - {/* エントリー推移 */} - - - Entry Trend - - - - - - ); -} diff --git a/src/components/entry/entry-field-item.tsx b/src/components/entry/entry-field-item.tsx index 9c4dd23..5ee2d66 100644 --- a/src/components/entry/entry-field-item.tsx +++ b/src/components/entry/entry-field-item.tsx @@ -6,7 +6,6 @@ import { EntryLongText } from "@/components/field/entry-long-text"; import { EntryMedia } from "@/components/field/entry-media"; import { EntryText } from "@/components/field/entry-text"; import { EntryTime } from "@/components/field/entry-time"; -import { FieldType } from "@/core/constants"; import type { FieldObj } from "@/db/schemas"; import { type FieldValue } from "@/utils/entry/use-entry"; @@ -39,19 +38,49 @@ export function EntryFieldItem({ edit, }; - switch (field.type as FieldType) { + switch (field.type) { case "text": - return ; + return ( + + ); case "longText": - return ; + return ( + + ); case "number": - return ; + return ( + + ); case "check": - return ; + return ( + + ); case "date": - return ; + return ( + + ); case "time": - return ; + return ( + + ); case "media": return ; case "location": diff --git a/src/components/entry/entry-list-view.tsx b/src/components/entry/entry-list-view.tsx index e75754b..6707f69 100644 --- a/src/components/entry/entry-list-view.tsx +++ b/src/components/entry/entry-list-view.tsx @@ -100,7 +100,7 @@ export function EntryListView({ > diff --git a/src/components/insights/insights-view.tsx b/src/components/insights/insights-view.tsx new file mode 100644 index 0000000..b0895d9 --- /dev/null +++ b/src/components/insights/insights-view.tsx @@ -0,0 +1,67 @@ +import { useState } from "react"; +import { PlatformColor, View } from "react-native"; + +import { Host, List, Section } from "@expo/ui/swift-ui"; +import { + frame, + listRowBackground, + listStyle, +} from "@expo/ui/swift-ui/modifiers"; +import { useLiveQuery } from "drizzle-orm/expo-sqlite"; + +import { getJournalsQuery } from "@/db/queries/journals"; + +import { JournalChipList } from "./journal-chip"; +import { StatsList } from "./stats-list"; + +/** + * インサイト画面 + */ +export function InsightsView() { + const { data: journals } = useLiveQuery(getJournalsQuery); + + const [activeJournal, setActiveJournal] = useState(""); + const activeJournalId = activeJournal || (journals[0]?.id ?? ""); + + if (journals.length === 0) return null; + + const activeJournalData = journals.find((j) => j.id === activeJournalId); + const accentColor = + activeJournalData?.color ?? PlatformColor("systemIndigo").toString(); + + return ( + + + +
setActiveJournal(id)} + /> + } + modifiers={[ + listRowBackground(PlatformColor("systemGroupedBackground")), + ]} + > + +
+
+
+
+ ); +} diff --git a/src/components/insights/journal-chip.tsx b/src/components/insights/journal-chip.tsx new file mode 100644 index 0000000..3eb43ed --- /dev/null +++ b/src/components/insights/journal-chip.tsx @@ -0,0 +1,113 @@ +import { PlatformColor } from "react-native"; + +import { HStack, Image, ScrollView, Spacer, Text } from "@expo/ui/swift-ui"; +import { + fixedSize, + font, + foregroundStyle, + glassEffect, + listRowInsets, + onTapGesture, + padding, + shadow, +} from "@expo/ui/swift-ui/modifiers"; + +import { JournalWithCountObj } from "@/db/queries/journals"; + +const chipBase = [padding({ vertical: 6, horizontal: 10 }), font({ size: 14 })]; + +const chipShadow = shadow({ radius: 20, color: "#00000020" }); + +const glassLabel = [ + ...chipBase, + glassEffect({ + glass: { variant: "clear", interactive: true }, + shape: "roundedRectangle", + cornerRadius: 100, + }), + foregroundStyle(PlatformColor("systemGray")), + chipShadow, +]; + +const activeLabel = (color: string) => [ + ...chipBase, + glassEffect({ + glass: { + variant: "clear", + interactive: true, + tint: color, + }, + shape: "roundedRectangle", + cornerRadius: 100, + }), + foregroundStyle("white"), + chipShadow, +]; + +type Props = { + /** ジャーナル */ + journals: JournalWithCountObj[]; + /** 表示するジャーナルのid */ + activeJournalId: string; + /** 表示するジャーナルをセットする関数 */ + onSelect: (id: string) => void; +}; + +/** + * 表示するジャーナルの統計を選択するチップ + */ +export function JournalChipList({ + journals, + activeJournalId, + onSelect, +}: Props) { + return ( + + + {journals.map((journal) => { + const isActive = journal.id === activeJournalId; + return ( + onSelect(journal.id)), + ]} + > + + + {journal.name} + + ); + })} + + + ); +} diff --git a/src/components/insights/stat-card-check.tsx b/src/components/insights/stat-card-check.tsx new file mode 100644 index 0000000..e049f84 --- /dev/null +++ b/src/components/insights/stat-card-check.tsx @@ -0,0 +1,127 @@ +import { PlatformColor } from "react-native"; + +import { + Chart, + HStack, + Image, + RoundedRectangle, + Spacer, + Text, + VStack, + ZStack, +} from "@expo/ui/swift-ui"; +import { + font, + foregroundStyle, + frame, + listRowBackground, + listRowInsets, + listRowSeparator, + padding, +} from "@expo/ui/swift-ui/modifiers"; + +import { FIELD_ICONS } from "@/core/constants"; +import { chartCheckFormat } from "@/utils/insights/chart-format"; + +import { FieldValueEntry } from "./stat-filed-item"; + +type Props = { + /** ラベル */ + label: string; + /** アクセントカラー */ + accentColor: string; + /** フィールド値一覧 */ + fieldValues: FieldValueEntry[]; +}; + +/** + * ステータスカード - チェック(チェック率 + 棒グラフ) + */ +export function StatCardCheck({ label, accentColor, fieldValues }: Props) { + const filled = fieldValues.filter((fv) => fv.value !== null); + const checkRate = + filled.length > 0 + ? Math.round( + (filled.filter((fv) => fv.value === "true").length / filled.length) * + 100, + ) + : 0; + + const chartData = chartCheckFormat(fieldValues, accentColor); + + return ( + + + + + + + {label} + + + + + + rate + + + + {checkRate} + + + % + + + + + + + + + ); +} diff --git a/src/components/insights/stat-card-date.tsx b/src/components/insights/stat-card-date.tsx new file mode 100644 index 0000000..a8a2712 --- /dev/null +++ b/src/components/insights/stat-card-date.tsx @@ -0,0 +1,125 @@ +import { PlatformColor } from "react-native"; + +import { + Chart, + HStack, + Image, + RoundedRectangle, + Spacer, + Text, + VStack, + ZStack, +} from "@expo/ui/swift-ui"; +import { + font, + foregroundStyle, + frame, + listRowBackground, + listRowInsets, + listRowSeparator, + padding, +} from "@expo/ui/swift-ui/modifiers"; + +import { FIELD_ICONS } from "@/core/constants"; +import { chartCheckFormat } from "@/utils/insights/chart-format"; + +import { FieldValueEntry } from "./stat-filed-item"; + +type Props = { + /** ラベル */ + label: string; + /** アクセントカラー */ + accentColor: string; + /** フィールド値一覧 */ + fieldValues: FieldValueEntry[]; +}; + +/** + * ステータスカード - 日付(記録数 + 棒グラフ) + */ +export function StatCardDate({ label, accentColor, fieldValues }: Props) { + const totalCount = fieldValues.filter((fv) => fv.value !== null).length; + + // チェックフォーマットを流用(非null = 記録あり で1扱い) + const filledAsBool = fieldValues.map((fv) => ({ + ...fv, + value: fv.value !== null ? "true" : "false", + })); + const chartData = chartCheckFormat(filledAsBool, accentColor); + + return ( + + + + + + + {label} + + + + + + total + + + + {totalCount} + + + entries + + + + + + + + + ); +} diff --git a/src/components/insights/stat-card-long-text.tsx b/src/components/insights/stat-card-long-text.tsx new file mode 100644 index 0000000..917ddf8 --- /dev/null +++ b/src/components/insights/stat-card-long-text.tsx @@ -0,0 +1,126 @@ +import { PlatformColor } from "react-native"; + +import { + Chart, + HStack, + Image, + RoundedRectangle, + Spacer, + Text, + VStack, + ZStack, +} from "@expo/ui/swift-ui"; +import { + font, + foregroundStyle, + frame, + listRowBackground, + listRowInsets, + listRowSeparator, + padding, +} from "@expo/ui/swift-ui/modifiers"; + +import { FIELD_ICONS } from "@/core/constants"; +import { chartFieldValueFormat } from "@/utils/insights/chart-format"; + +import { FieldValueEntry } from "./stat-filed-item"; + +type Props = { + /** ラベル */ + label: string; + /** アクセントカラー */ + accentColor: string; + /** フィールド値一覧 */ + fieldValues: FieldValueEntry[]; +}; + +/** + * ステータスカード -ロングテキスト(平均文字数 + 折れ線グラフ) + */ +export function StatCardLongText({ label, accentColor, fieldValues }: Props) { + const avgCharCount = + fieldValues.length > 0 + ? Math.round( + fieldValues.reduce((sum, fv) => sum + (fv.value?.length ?? 0), 0) / + fieldValues.length, + ) + : 0; + + const chartData = chartFieldValueFormat(fieldValues, accentColor); + + return ( + + + + + + + {label} + + + + + + avg + + + + {avgCharCount} + + + chars + + + + + + + + + ); +} diff --git a/src/components/insights/stat-card-number.tsx b/src/components/insights/stat-card-number.tsx new file mode 100644 index 0000000..604384d --- /dev/null +++ b/src/components/insights/stat-card-number.tsx @@ -0,0 +1,116 @@ +import { PlatformColor } from "react-native"; + +import { + Chart, + HStack, + Image, + RoundedRectangle, + Spacer, + Text, + VStack, + ZStack, +} from "@expo/ui/swift-ui"; +import { + font, + foregroundStyle, + frame, + listRowBackground, + listRowInsets, + listRowSeparator, + padding, +} from "@expo/ui/swift-ui/modifiers"; + +import { FIELD_ICONS } from "@/core/constants"; +import { chartNumberFormat } from "@/utils/insights/chart-format"; + +import { FieldValueEntry } from "./stat-filed-item"; + +type Props = { + /** ラベル */ + label: string; + /** アクセントカラー */ + accentColor: string; + /** フィールド値一覧 */ + fieldValues: FieldValueEntry[]; +}; + +/** + * ステータスカード - 数値(平均値 + 棒グラフ) + */ +export function StatCardNumber({ label, accentColor, fieldValues }: Props) { + const filled = fieldValues.filter((fv) => fv.value !== null); + const avg = + filled.length > 0 + ? filled.reduce((sum, fv) => sum + Number(fv.value ?? "0"), 0) / + filled.length + : 0; + const avgDisplay = Number.isInteger(avg) ? String(avg) : avg.toFixed(1); + + const chartData = chartNumberFormat(fieldValues, accentColor); + + return ( + + + + + + + {label} + + + + + + avg + + + {avgDisplay} + + + + + + + + ); +} diff --git a/src/components/insights/stat-card-text.tsx b/src/components/insights/stat-card-text.tsx new file mode 100644 index 0000000..274bd22 --- /dev/null +++ b/src/components/insights/stat-card-text.tsx @@ -0,0 +1,126 @@ +import { PlatformColor } from "react-native"; + +import { + Chart, + HStack, + Image, + RoundedRectangle, + Spacer, + Text, + VStack, + ZStack, +} from "@expo/ui/swift-ui"; +import { + font, + foregroundStyle, + frame, + listRowBackground, + listRowInsets, + listRowSeparator, + padding, +} from "@expo/ui/swift-ui/modifiers"; + +import { FIELD_ICONS } from "@/core/constants"; +import { chartFieldValueFormat } from "@/utils/insights/chart-format"; + +import { FieldValueEntry } from "./stat-filed-item"; + +type Props = { + /** ラベル */ + label: string; + /** アクセントカラー */ + accentColor: string; + /** フィールド値一覧 */ + fieldValues: FieldValueEntry[]; +}; + +/** + * ステータスカード - テキスト(平均文字数 + 折れ線グラフ) + */ +export function StatCardText({ label, accentColor, fieldValues }: Props) { + const avgCharCount = + fieldValues.length > 0 + ? Math.round( + fieldValues.reduce((sum, fv) => sum + (fv.value?.length ?? 0), 0) / + fieldValues.length, + ) + : 0; + + const chartData = chartFieldValueFormat(fieldValues, accentColor); + + return ( + + + + + + + {label} + + + + + + avg + + + + {avgCharCount} + + + chars + + + + + + + + + ); +} diff --git a/src/components/insights/stat-card-time.tsx b/src/components/insights/stat-card-time.tsx new file mode 100644 index 0000000..e29fb7c --- /dev/null +++ b/src/components/insights/stat-card-time.tsx @@ -0,0 +1,120 @@ +import { PlatformColor } from "react-native"; + +import { + Chart, + HStack, + Image, + RoundedRectangle, + Spacer, + Text, + VStack, + ZStack, +} from "@expo/ui/swift-ui"; +import { + font, + foregroundStyle, + frame, + listRowBackground, + listRowInsets, + listRowSeparator, + padding, +} from "@expo/ui/swift-ui/modifiers"; + +import { FIELD_ICONS } from "@/core/constants"; +import { chartTimeScatterFormat } from "@/utils/insights/chart-format"; + +import { FieldValueEntry } from "./stat-filed-item"; + +type Props = { + /** ラベル */ + label: string; + /** アクセントカラー */ + accentColor: string; + /** フィールド値一覧 */ + fieldValues: FieldValueEntry[]; +}; + +/** + * ステータスカード - 時刻(平均時刻 + 散布図) + */ +export function StatCardTime({ label, accentColor, fieldValues }: Props) { + const filled = fieldValues.filter((fv) => fv.value !== null); + const avgTime = (() => { + if (filled.length === 0) return "--:--"; + const avgMs = + filled.reduce((sum, fv) => sum + Number(fv.value ?? "0"), 0) / + filled.length; + const date = new Date(avgMs); + const h = date.getHours().toString().padStart(2, "0"); + const m = date.getMinutes().toString().padStart(2, "0"); + return `${h}:${m}`; + })(); + + const chartData = chartTimeScatterFormat(fieldValues, accentColor); + + return ( + + + + + + + {label} + + + + + + avg + + + {avgTime} + + + + + + + + ); +} diff --git a/src/components/insights/stat-card.tsx b/src/components/insights/stat-card.tsx new file mode 100644 index 0000000..feee918 --- /dev/null +++ b/src/components/insights/stat-card.tsx @@ -0,0 +1,110 @@ +import { PlatformColor } from "react-native"; + +import { + HStack, + Image, + RoundedRectangle, + Spacer, + Text, + VStack, + ZStack, +} from "@expo/ui/swift-ui"; +import { + font, + foregroundStyle, + frame, + listRowInsets, + listRowSeparator, + padding, +} from "@expo/ui/swift-ui/modifiers"; + +import { FIELD_ICONS, FieldType } from "@/core/constants"; + +type Props = { + /** ラベル */ + label: string; + /** 値 */ + value: string; + /** 単位 */ + unit: string; + /** フィールドタイプ */ + fieldType: FieldType; + /** アクセントカラー */ + accentColor: string; +}; + +/** + * ステータスカード + */ +export function StatCard({ + label, + value, + unit, + fieldType, + accentColor, +}: Props) { + return ( + + + + + + + {label} + + + + + + Average + + + + {value} + + + {unit} + + + + + + ); +} diff --git a/src/components/insights/stat-filed-item.tsx b/src/components/insights/stat-filed-item.tsx new file mode 100644 index 0000000..29889c0 --- /dev/null +++ b/src/components/insights/stat-filed-item.tsx @@ -0,0 +1,54 @@ +import { EntryDetailObj } from "@/db/queries/entries"; +import { FieldObj } from "@/db/schemas"; + +import { StatCardCheck } from "./stat-card-check"; +import { StatCardDate } from "./stat-card-date"; +import { StatCardLongText } from "./stat-card-long-text"; +import { StatCardNumber } from "./stat-card-number"; +import { StatCardText } from "./stat-card-text"; +import { StatCardTime } from "./stat-card-time"; + +export type FieldValueEntry = { + /** エントリーの作成日時 */ + createdAt: number; + /** DB に格納されたフィールド値 */ + value: string | null; +}; + +type Props = { + /** フィールド定義 */ + field: FieldObj; + /** アクセントカラー */ + accentColor: string; + /** エントリー一覧 */ + entries: EntryDetailObj[]; +}; + +/** + * フィールドタイプによってフィールドの統計を切り替えるコンポーネント + */ +export function StatFieldItem({ field, accentColor, entries }: Props) { + const fieldValues: FieldValueEntry[] = entries + .filter((entry) => entry.values.some((v) => v.fieldId === field.id)) + .map((entry) => ({ + createdAt: entry.createdAt, + value: entry.values.find((v) => v.fieldId === field.id)?.value ?? null, + })); + + const shared = { label: field.label, accentColor, fieldValues }; + + switch (field.type) { + case "text": + return ; + case "longText": + return ; + case "number": + return ; + case "check": + return ; + case "date": + return ; + case "time": + return ; + } +} diff --git a/src/components/insights/stats-list.tsx b/src/components/insights/stats-list.tsx new file mode 100644 index 0000000..7e4a762 --- /dev/null +++ b/src/components/insights/stats-list.tsx @@ -0,0 +1,61 @@ +import { Text } from "@expo/ui/swift-ui"; +import { font, listRowSeparator, padding } from "@expo/ui/swift-ui/modifiers"; +import { useLiveQuery } from "drizzle-orm/expo-sqlite"; + +import { getEntriesQuery } from "@/db/queries/entries"; +import { getFieldsQuery } from "@/db/queries/fields"; + +import { StatFieldItem } from "./stat-filed-item"; +import { WeeklySummaryCard } from "./weekly-summary-card"; + +type Props = { + /** アクティブなジャーナル id */ + activeJournalId: string; + /** アクセントカラー */ + accentColor: string; +}; + +/** + * ステータリスト + */ +export function StatsList({ activeJournalId, accentColor }: Props) { + const { data: fields } = useLiveQuery(getFieldsQuery(activeJournalId), [ + activeJournalId, + ]); + + const { data: entries } = useLiveQuery(getEntriesQuery(activeJournalId), [ + activeJournalId, + ]); + + return ( + <> + + Last 7 Days + + + + Field Stats + + {fields.map((field) => ( + + ))} + + ); +} diff --git a/src/components/insights/weekly-summary-card.tsx b/src/components/insights/weekly-summary-card.tsx new file mode 100644 index 0000000..77d8d04 --- /dev/null +++ b/src/components/insights/weekly-summary-card.tsx @@ -0,0 +1,105 @@ +import { PlatformColor } from "react-native"; + +import { + Chart, + HStack, + Image, + RoundedRectangle, + Spacer, + Text, + VStack, + ZStack, +} from "@expo/ui/swift-ui"; +import { + font, + foregroundStyle, + frame, + listRowInsets, + padding, +} from "@expo/ui/swift-ui/modifiers"; + +import { EntryDetailObj } from "@/db/queries/entries"; +import { chartDateFormat } from "@/utils/insights/chart-format"; + +type Props = { + /** ジャーナルカラー */ + accentColor: string; + /** エントリー一覧 */ + entries: EntryDetailObj[]; +}; + +/** + * 週間まとめカード(横軸: 曜日、縦軸: 時刻) + */ +export function WeeklySummaryCard({ accentColor, entries }: Props) { + const entryTimestamps = entries.map((e) => e.createdAt); + + const { data: chartData, count } = chartDateFormat( + entryTimestamps, + accentColor, + ); + + return ( + + + + + + + Total Entries + + + + + + {count} + + + entries / wk + + + + + + + + ); +} diff --git a/src/utils/entry/preview.ts b/src/utils/entry/preview.ts index 5e631f5..0d6cf9c 100644 --- a/src/utils/entry/preview.ts +++ b/src/utils/entry/preview.ts @@ -53,7 +53,7 @@ export const buildPreviewEntry = (entry: EntryDetailObj): PreviewEntryObj => { // 1つめのフィールドをタイトルに設定し、値がなければ作成日にフォールバック const titleFromField = sorted[0] - ? formatFieldValue(sorted[0].value, sorted[0].field.type as FieldType) + ? formatFieldValue(sorted[0].value, sorted[0].field.type) : ""; const title = titleFromField || formatDate(new Date(createdAt)); diff --git a/src/utils/insights/chart-format.ts b/src/utils/insights/chart-format.ts new file mode 100644 index 0000000..436ffe7 --- /dev/null +++ b/src/utils/insights/chart-format.ts @@ -0,0 +1,225 @@ +import { PlatformColor } from "react-native"; + +import { ChartDataPoint } from "@expo/ui/swift-ui"; + +const DAY_LABELS = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"]; + +type FieldValueEntry = { createdAt: number; value: string | null }; + +/** + * 週間のエントリーをチャートに表示するためのフォーマット関数 + * 過去7日間を固定ウィンドウとして表示(横軸: 曜日、縦軸: 時刻) + * @param timestamps エントリーのタイムスタンプリスト + * @param accentColor 今日のポイントに使うアクセントカラー + */ +export const chartDateFormat = ( + timestamps: number[], + accentColor: string, +): { data: ChartDataPoint[]; count: number } => { + const today = new Date(); + today.setHours(0, 0, 0, 0); + + const data: ChartDataPoint[] = []; + let count = 0; + + for (let i = 0; i < 7; i++) { + const day = new Date(today); + day.setDate(today.getDate() - (6 - i)); + const dayStr = day.toDateString(); + const isToday = i === 6; + const label = DAY_LABELS[(day.getDay() + 6) % 7]; + const color = isToday ? accentColor : PlatformColor("systemGray3"); + + const dayTimestamps = timestamps.filter( + (ts) => new Date(ts).toDateString() === dayStr, + ); + + count += dayTimestamps.length; + + for (const ts of dayTimestamps) { + const date = new Date(ts); + data.push({ + x: label, + y: date.getHours() + date.getMinutes() / 60, + color, + }); + } + + // エントリーがない日もプレースホルダーを入れて7日分を維持 + if (dayTimestamps.length === 0) { + data.push({ + x: label, + y: 0, + color: PlatformColor("secondarySystemGroupedBackground"), + }); + } + } + + return { data, count }; +}; + +/** + * 数値フィールドの値を棒グラフ用にフォーマット(1日の平均値) + * @param fieldValues フィールド値一覧 + * @param accentColor 今日のバーに使うアクセントカラー + */ +export const chartNumberFormat = ( + fieldValues: FieldValueEntry[], + accentColor: string, +): ChartDataPoint[] => { + const today = new Date(); + today.setHours(0, 0, 0, 0); + + return Array.from({ length: 7 }, (_, i) => { + const day = new Date(today); + day.setDate(today.getDate() - (6 - i)); + const dayStr = day.toDateString(); + const isToday = i === 6; + + const dayEntries = fieldValues.filter( + (fv) => + new Date(fv.createdAt).toDateString() === dayStr && fv.value !== null, + ); + const avgY = + dayEntries.length > 0 + ? dayEntries.reduce((sum, fv) => sum + Number(fv.value ?? "0"), 0) / + dayEntries.length + : 0; + + return { + x: DAY_LABELS[(day.getDay() + 6) % 7], + y: avgY, + color: + dayEntries.length === 0 + ? PlatformColor("secondarySystemGroupedBackground") + : isToday + ? accentColor + : PlatformColor("systemGray3"), + }; + }); +}; + +/** + * チェックフィールドの値を棒グラフ用にフォーマット(1日のチェック数) + * @param fieldValues フィールド値一覧 + * @param accentColor 今日のバーに使うアクセントカラー + */ +export const chartCheckFormat = ( + fieldValues: FieldValueEntry[], + accentColor: string, +): ChartDataPoint[] => { + const today = new Date(); + today.setHours(0, 0, 0, 0); + + return Array.from({ length: 7 }, (_, i) => { + const day = new Date(today); + day.setDate(today.getDate() - (6 - i)); + const dayStr = day.toDateString(); + const isToday = i === 6; + + const dayEntries = fieldValues.filter( + (fv) => new Date(fv.createdAt).toDateString() === dayStr, + ); + const checkCount = dayEntries.filter((fv) => fv.value === "true").length; + + return { + x: DAY_LABELS[(day.getDay() + 6) % 7], + y: checkCount, + color: + checkCount === 0 + ? PlatformColor("secondarySystemGroupedBackground") + : isToday + ? accentColor + : PlatformColor("systemGray3"), + }; + }); +}; + +/** + * 日付・時刻フィールドの値を散布図用にフォーマット + * y軸は時間(0〜24の小数) + * @param fieldValues フィールド値一覧 + * @param accentColor 今日のポイントに使うアクセントカラー + */ +export const chartTimeScatterFormat = ( + fieldValues: FieldValueEntry[], + accentColor: string, +): ChartDataPoint[] => { + const today = new Date(); + today.setHours(0, 0, 0, 0); + + const data: ChartDataPoint[] = []; + + for (let i = 0; i < 7; i++) { + const day = new Date(today); + day.setDate(today.getDate() - (6 - i)); + const dayStr = day.toDateString(); + const isToday = i === 6; + const label = DAY_LABELS[(day.getDay() + 6) % 7]; + const color = isToday ? accentColor : PlatformColor("systemGray3"); + + const dayEntries = fieldValues.filter( + (fv) => + new Date(fv.createdAt).toDateString() === dayStr && fv.value !== null, + ); + + if (dayEntries.length === 0) { + data.push({ + x: label, + y: 0, + color: PlatformColor("secondarySystemGroupedBackground"), + }); + } else { + for (const fv of dayEntries) { + const date = new Date(Number(fv.value)); + data.push({ + x: label, + y: date.getHours() + date.getMinutes() / 60, + color, + }); + } + } + } + + return data; +}; + +/** + * 過去7日間のフィールド値を棒グラフ用にフォーマットする関数 + * @param fieldValues フィールド値一覧 + * @param accentColor 今日のバーに使うアクセントカラー + */ +export const chartFieldValueFormat = ( + fieldValues: FieldValueEntry[], + accentColor: string, +): ChartDataPoint[] => { + const today = new Date(); + today.setHours(0, 0, 0, 0); + + return Array.from({ length: 7 }, (_, i) => { + const day = new Date(today); + day.setDate(today.getDate() - (6 - i)); + const dayStr = day.toDateString(); + const isToday = i === 6; + + const dayEntries = fieldValues.filter( + (fv) => new Date(fv.createdAt).toDateString() === dayStr, + ); + const avgY = + dayEntries.length > 0 + ? dayEntries.reduce((sum, fv) => sum + (fv.value?.length ?? 0), 0) / + dayEntries.length + : 0; + + return { + x: DAY_LABELS[(day.getDay() + 6) % 7], + y: avgY, + color: + dayEntries.length === 0 + ? PlatformColor("secondarySystemGroupedBackground") + : isToday + ? accentColor + : PlatformColor("systemGray3"), + }; + }); +}; diff --git a/src/utils/journal/use-journal-field.ts b/src/utils/journal/use-journal-field.ts index 8e34fa9..3a9b654 100644 --- a/src/utils/journal/use-journal-field.ts +++ b/src/utils/journal/use-journal-field.ts @@ -32,10 +32,19 @@ export type FieldDraftObj = Omit; */ export type FieldWithSortObj = Omit; +const isFieldType = (value: string): value is FieldType => value in FIELD_ICONS; + /** * 全 FieldType の配列 */ -export const FIELD_TYPES = Object.keys(FIELD_ICONS) as FieldType[]; +export const FIELD_TYPES: FieldType[] = + Object.keys(FIELD_ICONS).filter(isFieldType); + +const defaultMeta: JournalMetaObj = { + name: "", + color: "#6d7ce1", + icon: JOURNAL_ICONS[0], +}; /** * ジャーナルフィールドに関するフック @@ -61,7 +70,7 @@ export const useJournalField = (initialData?: { initialData?.fields ?? [], ); const [meta, setMeta] = useState( - initialData?.meta ?? { name: "", color: "#007AFF", icon: JOURNAL_ICONS[0] }, + initialData?.meta ?? defaultMeta, ); /**