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,
);
/**