diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..276ba34 --- /dev/null +++ b/.env.example @@ -0,0 +1 @@ +EXPO_PUBLIC_SIGNING_SECRET= diff --git a/CLAUDE.md b/CLAUDE.md index 65e2897..0a9985f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -44,22 +44,30 @@ src/app/ entry/ create.tsx # Create entry /entry/create?journalId=...&journalName=... [id].tsx # Entry detail /entry/[id]?journalName=... - explore.tsx # Report tab + days/ + _layout.tsx # Stack — scoped to days tab + index.tsx # Daily reflection list + settings.tsx # AI reflection settings (model, time, enabled) + search/ + _layout.tsx # Stack — scoped to search tab + index.tsx # Journal entry search ``` **Navigation flow:** Journal list → `[id]` (entry list) → `entry/[id]` (entry detail) -**Key rule:** `NativeTabs` is the root navigator; `Stack` lives inside `(journal)` group. This keeps the tab bar visible when pushing screens. +**Key rule:** `NativeTabs` is the root navigator; `Stack` lives inside each tab group (`(journal)`, `days`, `search`). This keeps the tab bar visible when pushing screens. ### Database Layer Drizzle ORM + expo-sqlite. Schema is split by domain in `src/db/schemas/`: -| File | Tables | -| ------------- | ---------------------------------------- | -| `journals.ts` | `journals` | -| `fields.ts` | `fields` (field definitions per journal) | -| `entries.ts` | `entries`, `entry_values` | +| File | Tables | +| ---------------- | ------------------------------------------------- | +| `journals.ts` | `journals` | +| `fields.ts` | `fields` (field definitions per journal) | +| `entries.ts` | `entries`, `entry_values` | +| `reflections.ts` | `reflections` (one AI-generated reflection/day) | +| `settings.ts` | `settings` (KVS — key/value pairs for app config) | ```mermaid erDiagram @@ -95,6 +103,22 @@ erDiagram TEXT value } + reflections { + TEXT id PK + INTEGER date "unique, midnight timestamp" + TEXT title + TEXT firstCategory + TEXT firstContent + TEXT secondCategory + TEXT secondContent + INTEGER createdAt + } + + settings { + TEXT key PK + TEXT value + } + journals ||--o{ fields : "has" journals ||--o{ entries : "has" entries ||--o{ entry_values : "has" @@ -160,6 +184,24 @@ All field values are stored as `text | null` in `entry_values.value`. Serializat - Remaining field values joined with spaces = preview (also used for search) - Entries grouped by month for the list view (`groupByMonth`) +### AI Reflection Pipeline + +On-device LLM generates daily reflections from journal entries: + +1. **Entry collection** — `src/db/queries/entries.ts` fetches all entries for a given day (`DailyEntryObj[]`) +2. **Text conversion** — `src/utils/days/reflection/get-reflection.ts` formats entries into structured text (journal name + field labels/values) +3. **LLM inference** — `@react-native-ai/llama` downloads a GGUF model, Vercel `ai` package's `generateText()` runs inference with a system prompt +4. **Parsing** — JSON output is extracted via regex and validated with `reflectionSchema` (Zod) +5. **Storage** — Result saved to `reflections` table (one per day, keyed by midnight timestamp) + +**Supported models** (defined in `src/constants/ai-models.ts`): Gemma 3 4B, Qwen 3 4B, Phi-4 Mini — all Q4_K_M quantization from HuggingFace. + +**Settings** are stored in the `settings` KVS table, managed by `src/hooks/settings/use-ai-reflection-settings.ts`: + +- `aiReflectionEnabled` — on/off toggle +- `aiReflectionModelId` — selected GGUF model +- `aiReflectionTime` — time of day to auto-generate (via `use-auto-reflection.ts`) + ### 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. @@ -176,6 +218,8 @@ Always call `Keyboard.dismiss()` **before** any `async` save operation that trig | `expo-sqlite` + `drizzle-orm` | Local SQLite persistence | | `expo-crypto` | `Crypto.randomUUID()` for ID generation at the app layer | | `PlatformColor` | Adaptive system colors: `"label"`, `"systemBackground"`, `"systemIndigo"` | +| `@react-native-ai/llama` | On-device GGUF model download + inference via `downloadModel()` and `llama.languageModel()` | +| `ai` (Vercel AI SDK) | `generateText()` with structured prompts — used with llama model provider | ### SwiftUI Component Rules diff --git a/drizzle/0001_loud_sally_floyd.sql b/drizzle/0001_loud_sally_floyd.sql new file mode 100644 index 0000000..3e34b18 --- /dev/null +++ b/drizzle/0001_loud_sally_floyd.sql @@ -0,0 +1,4 @@ +CREATE TABLE `settings` ( + `key` text PRIMARY KEY NOT NULL, + `value` text +); diff --git a/drizzle/0002_tiresome_brother_voodoo.sql b/drizzle/0002_tiresome_brother_voodoo.sql new file mode 100644 index 0000000..05b5943 --- /dev/null +++ b/drizzle/0002_tiresome_brother_voodoo.sql @@ -0,0 +1,12 @@ +CREATE TABLE `reflections` ( + `id` text PRIMARY KEY NOT NULL, + `date` integer NOT NULL, + `title` text NOT NULL, + `firstCategory` text NOT NULL, + `firstContent` text NOT NULL, + `secondCategory` text NOT NULL, + `secondContent` text NOT NULL, + `createdAt` integer NOT NULL +); +--> statement-breakpoint +CREATE UNIQUE INDEX `reflections_date_unique` ON `reflections` (`date`); \ No newline at end of file diff --git a/drizzle/meta/0001_snapshot.json b/drizzle/meta/0001_snapshot.json new file mode 100644 index 0000000..14294ec --- /dev/null +++ b/drizzle/meta/0001_snapshot.json @@ -0,0 +1,268 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "c7f00b54-9b8b-464c-a516-f825371e613b", + "prevId": "3d118337-e09e-4635-9ca5-d9d5a5c94bf2", + "tables": { + "entries": { + "name": "entries", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "journalId": { + "name": "journalId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "bookmark": { + "name": "bookmark", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "createdAt": { + "name": "createdAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updatedAt": { + "name": "updatedAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "entries_journalId_journals_id_fk": { + "name": "entries_journalId_journals_id_fk", + "tableFrom": "entries", + "tableTo": "journals", + "columnsFrom": ["journalId"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "entry_values": { + "name": "entry_values", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "entryId": { + "name": "entryId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "fieldId": { + "name": "fieldId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "entry_field_unique": { + "name": "entry_field_unique", + "columns": ["entryId", "fieldId"], + "isUnique": true + } + }, + "foreignKeys": { + "entry_values_entryId_entries_id_fk": { + "name": "entry_values_entryId_entries_id_fk", + "tableFrom": "entry_values", + "tableTo": "entries", + "columnsFrom": ["entryId"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "entry_values_fieldId_fields_id_fk": { + "name": "entry_values_fieldId_fields_id_fk", + "tableFrom": "entry_values", + "tableTo": "fields", + "columnsFrom": ["fieldId"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "fields": { + "name": "fields", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "journalId": { + "name": "journalId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "label": { + "name": "label", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "sortOrder": { + "name": "sortOrder", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "fields_journalId_journals_id_fk": { + "name": "fields_journalId_journals_id_fk", + "tableFrom": "fields", + "tableTo": "journals", + "columnsFrom": ["journalId"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "journals": { + "name": "journals", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "icon": { + "name": "icon", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "color": { + "name": "color", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updatedAt": { + "name": "updatedAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "settings": { + "name": "settings", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} diff --git a/drizzle/meta/0002_snapshot.json b/drizzle/meta/0002_snapshot.json new file mode 100644 index 0000000..195a65d --- /dev/null +++ b/drizzle/meta/0002_snapshot.json @@ -0,0 +1,340 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "8a42d699-6379-4a82-bbbe-cbc89fe1ee99", + "prevId": "c7f00b54-9b8b-464c-a516-f825371e613b", + "tables": { + "entries": { + "name": "entries", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "journalId": { + "name": "journalId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "bookmark": { + "name": "bookmark", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "createdAt": { + "name": "createdAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updatedAt": { + "name": "updatedAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "entries_journalId_journals_id_fk": { + "name": "entries_journalId_journals_id_fk", + "tableFrom": "entries", + "tableTo": "journals", + "columnsFrom": ["journalId"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "entry_values": { + "name": "entry_values", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "entryId": { + "name": "entryId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "fieldId": { + "name": "fieldId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "entry_field_unique": { + "name": "entry_field_unique", + "columns": ["entryId", "fieldId"], + "isUnique": true + } + }, + "foreignKeys": { + "entry_values_entryId_entries_id_fk": { + "name": "entry_values_entryId_entries_id_fk", + "tableFrom": "entry_values", + "tableTo": "entries", + "columnsFrom": ["entryId"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "entry_values_fieldId_fields_id_fk": { + "name": "entry_values_fieldId_fields_id_fk", + "tableFrom": "entry_values", + "tableTo": "fields", + "columnsFrom": ["fieldId"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "fields": { + "name": "fields", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "journalId": { + "name": "journalId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "label": { + "name": "label", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "sortOrder": { + "name": "sortOrder", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "fields_journalId_journals_id_fk": { + "name": "fields_journalId_journals_id_fk", + "tableFrom": "fields", + "tableTo": "journals", + "columnsFrom": ["journalId"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "journals": { + "name": "journals", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "icon": { + "name": "icon", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "color": { + "name": "color", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updatedAt": { + "name": "updatedAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "settings": { + "name": "settings", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "reflections": { + "name": "reflections", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "date": { + "name": "date", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "firstCategory": { + "name": "firstCategory", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "firstContent": { + "name": "firstContent", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "secondCategory": { + "name": "secondCategory", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "secondContent": { + "name": "secondContent", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "reflections_date_unique": { + "name": "reflections_date_unique", + "columns": ["date"], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index bec3a13..091fc06 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -8,6 +8,20 @@ "when": 1777997392852, "tag": "0000_young_bulldozer", "breakpoints": true + }, + { + "idx": 1, + "version": "6", + "when": 1786688489216, + "tag": "0001_loud_sally_floyd", + "breakpoints": true + }, + { + "idx": 2, + "version": "6", + "when": 1786863241243, + "tag": "0002_tiresome_brother_voodoo", + "breakpoints": true } ] } diff --git a/drizzle/migrations.js b/drizzle/migrations.js index 0fc3525..1cc3864 100644 --- a/drizzle/migrations.js +++ b/drizzle/migrations.js @@ -1,11 +1,15 @@ // This file is required for Expo/React Native SQLite migrations - https://orm.drizzle.team/quick-sqlite/expo import m0000 from "./0000_young_bulldozer.sql"; +import m0001 from "./0001_loud_sally_floyd.sql"; +import m0002 from "./0002_tiresome_brother_voodoo.sql"; import journal from "./meta/_journal.json"; export default { journal, migrations: { m0000, + m0001, + m0002, }, }; diff --git a/package.json b/package.json index 54b42e8..9a75a07 100644 --- a/package.json +++ b/package.json @@ -16,9 +16,13 @@ }, "dependencies": { "@expo/ui": "~55.0.17", + "@react-native-ai/llama": "^0.12.0", "@react-navigation/bottom-tabs": "^7.15.5", "@react-navigation/elements": "^2.9.10", "@react-navigation/native": "^7.1.33", + "@stardazed/streams-text-encoding": "^1.0.2", + "@ungap/structured-clone": "^1.3.3", + "ai": "^7.0.58", "babel-plugin-inline-import": "^3.0.0", "drizzle-kit": "^0.31.10", "drizzle-orm": "^0.45.2", @@ -44,6 +48,7 @@ "expo-symbols": "~55.0.7", "expo-system-ui": "~55.0.16", "expo-web-browser": "~55.0.14", + "llama.rn": "0.12.0-rc.9", "react": "19.2.0", "react-dom": "19.2.0", "react-native": "0.83.6", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e1b27a4..1c2f335 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -21,6 +21,9 @@ importers: '@expo/ui': specifier: ~55.0.17 version: 55.0.17(expo@55.0.18)(react-native@0.83.6(@babel/core@7.29.0)(@types/react@19.2.14)(react@19.2.0))(react@19.2.0) + '@react-native-ai/llama': + specifier: ^0.12.0 + version: 0.12.0(llama.rn@0.12.0-rc.9(react-native@0.83.6(@babel/core@7.29.0)(@types/react@19.2.14)(react@19.2.0))(react@19.2.0))(react-native@0.83.6(@babel/core@7.29.0)(@types/react@19.2.14)(react@19.2.0))(react@19.2.0) '@react-navigation/bottom-tabs': specifier: ^7.15.5 version: 7.15.11(@react-navigation/native@7.2.2(react-native@0.83.6(@babel/core@7.29.0)(@types/react@19.2.14)(react@19.2.0))(react@19.2.0))(react-native-safe-area-context@5.6.2(react-native@0.83.6(@babel/core@7.29.0)(@types/react@19.2.14)(react@19.2.0))(react@19.2.0))(react-native-screens@4.23.0(react-native@0.83.6(@babel/core@7.29.0)(@types/react@19.2.14)(react@19.2.0))(react@19.2.0))(react-native@0.83.6(@babel/core@7.29.0)(@types/react@19.2.14)(react@19.2.0))(react@19.2.0) @@ -30,6 +33,15 @@ importers: '@react-navigation/native': specifier: ^7.1.33 version: 7.2.2(react-native@0.83.6(@babel/core@7.29.0)(@types/react@19.2.14)(react@19.2.0))(react@19.2.0) + '@stardazed/streams-text-encoding': + specifier: ^1.0.2 + version: 1.0.2 + '@ungap/structured-clone': + specifier: ^1.3.3 + version: 1.3.3 + ai: + specifier: ^7.0.58 + version: 7.0.58(zod@4.4.3) babel-plugin-inline-import: specifier: ^3.0.0 version: 3.0.0 @@ -105,6 +117,9 @@ importers: expo-web-browser: specifier: ~55.0.14 version: 55.0.14(expo@55.0.18)(react-native@0.83.6(@babel/core@7.29.0)(@types/react@19.2.14)(react@19.2.0)) + llama.rn: + specifier: 0.12.0-rc.9 + version: 0.12.0-rc.9(react-native@0.83.6(@babel/core@7.29.0)(@types/react@19.2.14)(react@19.2.0))(react@19.2.0) react: specifier: 19.2.0 version: 19.2.0 @@ -154,6 +169,32 @@ importers: packages: + '@ai-sdk/gateway@4.0.46': + resolution: {integrity: sha512-LIAO6kAG8fpXQb9L0iwPk1FIbXftvqnyC56v5NEAzeWTeL8fUsy/Hx86VPBTWEDFdwbVprjWifJOAqS6AOj3mA==} + engines: {node: '>=22'} + peerDependencies: + zod: ^3.25.76 || ^4.1.8 + + '@ai-sdk/provider-utils@4.0.42': + resolution: {integrity: sha512-Q07lZsq4ir+xwAGVfSbY2WNGLrbfWINFaL2I/vTBFrkuXeP7VZh+UtQgLOKZS6Z/6flNFW22jDYdbINvwTV/PA==} + engines: {node: '>=18'} + peerDependencies: + zod: ^3.25.76 || ^4.1.8 + + '@ai-sdk/provider-utils@5.0.25': + resolution: {integrity: sha512-xscPPHCSjCHWrdhai25sbHCJeKNLW/3D1uSpUZa4cEtTKXA8OnPQ3+Rfu1SmM5Ea/Mf8Dfn3cllw9zeMzo/zFA==} + engines: {node: '>=22'} + peerDependencies: + zod: ^3.25.76 || ^4.1.8 + + '@ai-sdk/provider@3.0.14': + resolution: {integrity: sha512-5X1k57JBJ4H7H1QjX7CnJYAB1I19r/trVZTMcSms7/kLNZ8RaU4Nt2agcwZzv82Hfx6Q7/TOLU7agAKeFfc8cA==} + engines: {node: '>=18'} + + '@ai-sdk/provider@4.0.7': + resolution: {integrity: sha512-6or44XprPzKbr8zkmzosowSE0pxkvJcoojBL+mCZvPUt3kvXp3XSNqeVun9golb1acEfSo6yaEBRT18h2VU+1Q==} + engines: {node: '>=22'} + '@babel/code-frame@7.10.4': resolution: {integrity: sha512-vG6SvB6oYEhvgisZNFRmRCUkLz11c7rp+tbNTynGqc6mS1d5ATd/sGyV6W0KZZnXRKMTzZDRgQT3Ou9jhpAfUg==} @@ -1383,6 +1424,10 @@ packages: resolution: {integrity: sha512-wC562eD3gS6vO2tWHToFhlFnmHKfKHgF1oyvojeSkLK/ZYop1bMU+7cOMiF9Sq70CzcsLy/EMRy/uRc76QmNRw==} hasBin: true + '@fastify/busboy@2.1.1': + resolution: {integrity: sha512-vBZP4NlzfOlerQTnba4aqZoMhE/a9HY7HRqoOPaETQcSQuWEIyZMHGfVu6w9wGtGK5fED5qRs2DteVCjOH60sA==} + engines: {node: '>=14'} + '@humanfs/core@0.19.2': resolution: {integrity: sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==} engines: {node: '>=18.18.0'} @@ -1991,6 +2036,12 @@ packages: '@types/react': optional: true + '@react-native-ai/llama@0.12.0': + resolution: {integrity: sha512-v/zhk9SEAk7P4/iELHMkAfIEEIn1TaMdIwfaFv41Yu3wm3sRbws7qRufTeq3nFBnSLj3MtVMZy4Vew7Fy5uyaA==} + peerDependencies: + llama.rn: ^0.10.1 + react-native: '>=0.76.0' + '@react-native/assets-registry@0.83.6': resolution: {integrity: sha512-iljb4ue1yWJ3EhySz7EjV6CzSVrI2uNtR8BI2jzP5+QS5E4Cl3fdIJRmVwDEx1pu8uE97PGEusGRHnoaZ9Q3jg==} engines: {node: '>= 20.19.4'} @@ -2212,6 +2263,9 @@ packages: '@standard-schema/spec@1.1.0': resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} + '@stardazed/streams-text-encoding@1.0.2': + resolution: {integrity: sha512-f2Z15BId3t44a/u21yYSGXFAkCyKocmAyduoAy7swnZ4xIfbaZlOWsgly/jDNNOuj6hYQN72UaBRe3Z/tOHfqg==} + '@testing-library/dom@10.4.1': resolution: {integrity: sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==} engines: {node: '>=18'} @@ -2346,6 +2400,10 @@ packages: '@ungap/structured-clone@1.3.0': resolution: {integrity: sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==} + deprecated: Potential CWE-502 - Update to 1.3.1 or higher + + '@ungap/structured-clone@1.3.3': + resolution: {integrity: sha512-60YRaenCQcVjYEKOcG824+DRGGIQ3VKErcBoAEDJZz5bKIs2ZG+X/H9Nk+Q6EVkwJk5QNApxbrc5QtBSwtrXAg==} '@unrs/resolver-binding-android-arm-eabi@1.11.1': resolution: {integrity: sha512-ppLRUgHVaGRWUx0R0Ut06Mjo9gBaBkg3v/8AxusGLhsIotbBLuRk51rAzqLC8gq6NyyAojEXglNjzf6R948DNw==} @@ -2450,6 +2508,10 @@ packages: cpu: [x64] os: [win32] + '@vercel/oidc@3.2.0': + resolution: {integrity: sha512-UycprH3T6n3jH0k44NHMa7pnFHGu/N05MjojYr+Mc6I7obkoLIJujSWwin1pCvdy/eOxrI/l3uDLQsmcrOb4ug==} + engines: {node: '>= 20'} + '@vitest/browser-preview@4.1.10': resolution: {integrity: sha512-14MJrL59ZFkqXLjwfSk6RzTDy5Czf9UG4+8q8L6Gxjs2aPjEce/cVNYV14bXAc2BvMjUNu904+ZEZA1Xc1wtvQ==} peerDependencies: @@ -2692,6 +2754,9 @@ packages: cpu: [x64] os: [win32] + '@workflow/serde@4.1.0': + resolution: {integrity: sha512-pav4F2BoirECWR7Nf1TKt+2eETcBj7jj4cBefQ8VXQCA6NPkaKeLfj/zMgi+3zYV5ZIBT4GuUiphsj0/b9hPQQ==} + '@xmldom/xmldom@0.8.13': resolution: {integrity: sha512-KRYzxepc14G/CEpEGc3Yn+JKaAeT63smlDr+vjB8jRfgTBBI9wRj/nkQEO+ucV8p8I9bfKLWp37uHgFrbntPvw==} engines: {node: '>=10.0.0'} @@ -2851,6 +2916,12 @@ packages: resolution: {integrity: sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==} engines: {node: '>= 14'} + ai@7.0.58: + resolution: {integrity: sha512-GfgO90CQQ0yYuoxJAUOeQ6tviyYw1BUIDygSZ1q3Ce6kSc93tYmB5eltKY/NxC0YOouAax7JvDqVnYxvIAr04Q==} + engines: {node: '>=22'} + peerDependencies: + zod: ^3.25.76 || ^4.1.8 + ajv@6.15.0: resolution: {integrity: sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==} @@ -3031,6 +3102,9 @@ packages: resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==} engines: {node: 18 || 20 || >=22} + base-64@1.0.0: + resolution: {integrity: sha512-kwDPIFCGx0NZHog36dj+tHiwP4QMzsZ3AgMViUBKI0+V5n4U0ufTCUMhnQ04diaRI8EX/QcPfql7zlhZ7j4zgg==} + base64-js@1.5.1: resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==} @@ -3650,6 +3724,10 @@ packages: resolution: {integrity: sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==} engines: {node: '>=6'} + eventsource-parser@3.1.0: + resolution: {integrity: sha512-kJezFj9YFAMLeORyi7aCLxLbD5/qWMQnoMVlVPyHIll7lgRJCc3JVln9Vgl9nwQi0YkMnhdGTMNn7CkRRAptMg==} + engines: {node: '>=18.0.0'} + expo-asset@55.0.16: resolution: {integrity: sha512-5IJyfJtYqvKGg04NKGQWiCIoK/fULDL9m15mXPPyfabD1jsToVj2hnWmo1r2SWNNmMwtQxi6jTpcGwVo2nLDxg==} peerDependencies: @@ -4336,6 +4414,9 @@ packages: json-schema-traverse@0.4.1: resolution: {integrity: sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==} + json-schema@0.4.0: + resolution: {integrity: sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA==} + json-stable-stringify-without-jsonify@1.0.1: resolution: {integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==} @@ -4522,6 +4603,14 @@ packages: resolution: {integrity: sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==} engines: {node: '>= 12.0.0'} + llama.rn@0.12.0-rc.9: + resolution: {integrity: sha512-6qmo0sOGs7mk7stAGPtQ13R002yV2sYWR5z0YmUNlBUWVeOVeSXfsG2CTAfbyEr6fjXhFNgZVPYEt0Hd7Yd27A==} + engines: {node: '>= 16.0.0'} + hasBin: true + peerDependencies: + react: '*' + react-native: '*' + locate-path@5.0.0: resolution: {integrity: sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==} engines: {node: '>=8'} @@ -5059,6 +5148,12 @@ packages: react-is@19.2.5: resolution: {integrity: sha512-Dn0t8IQhCmeIT3wu+Apm1/YVsJXsGWi6k4sPdnBIdqMVtHtv0IGi6dcpNpNkNac0zB2uUAqNX3MHzN8c+z2rwQ==} + react-native-blob-util@0.24.10: + resolution: {integrity: sha512-4yazgoCstXgt2/CRURtowGP3furAzbWdfMQ4jyUPDo5xtLx7KE2F/xMz3oU3shLwfL4id0iEKsBywoxvJSHPzQ==} + peerDependencies: + react: '*' + react-native: '*' + react-native-gesture-handler@2.30.1: resolution: {integrity: sha512-xIUBDo5ktmJs++0fZlavQNvDEE4PsihWhSeJsJtoz4Q6p0MiTM9TgrTgfEgzRR36qGPytFoeq+ShLrVwGdpUdA==} peerDependencies: @@ -5618,6 +5713,14 @@ packages: undici-types@7.19.2: resolution: {integrity: sha512-qYVnV5OEm2AW8cJMCpdV20CDyaN3g0AjDlOGf1OW4iaDEx8MwdtChUp4zu4H0VP3nDRF/8RKWH+IPp9uW0YGZg==} + undici@5.29.0: + resolution: {integrity: sha512-raqeBD6NQK4SkWhQzeYKd1KmIG6dllBOTt55Rmkt4HtI9mwdWtJljnrXjAFUBLTSN67HWrOIZ3EPF4kjUw80Bg==} + engines: {node: '>=14.0'} + + undici@7.29.0: + resolution: {integrity: sha512-IDxfleLmmbSskfWSUATiN1nfn2rDuvnMOqb5CWR92iIfojA0Ud+ulOAAEQ57LPr9rWmsreUyf5lwyao+7GNNVw==} + engines: {node: '>=20.18.1'} + unicode-canonical-property-names-ecmascript@2.0.1: resolution: {integrity: sha512-dA8WbNeb2a6oQzAQ55YlT5vQAWGV9WXOsi3SskE3bcCdM0P4SDd+24zS/OCacdRq5BkdsRj9q3Pg6YyQoxIGqg==} engines: {node: '>=4'} @@ -5900,6 +6003,38 @@ packages: snapshots: + '@ai-sdk/gateway@4.0.46(zod@4.4.3)': + dependencies: + '@ai-sdk/provider': 4.0.7 + '@ai-sdk/provider-utils': 5.0.25(zod@4.4.3) + '@vercel/oidc': 3.2.0 + zod: 4.4.3 + + '@ai-sdk/provider-utils@4.0.42(zod@4.4.3)': + dependencies: + '@ai-sdk/provider': 3.0.14 + '@standard-schema/spec': 1.1.0 + eventsource-parser: 3.1.0 + undici: 5.29.0 + zod: 4.4.3 + + '@ai-sdk/provider-utils@5.0.25(zod@4.4.3)': + dependencies: + '@ai-sdk/provider': 4.0.7 + '@standard-schema/spec': 1.1.0 + '@workflow/serde': 4.1.0 + eventsource-parser: 3.1.0 + undici: 7.29.0 + zod: 4.4.3 + + '@ai-sdk/provider@3.0.14': + dependencies: + json-schema: 0.4.0 + + '@ai-sdk/provider@4.0.7': + dependencies: + json-schema: 0.4.0 + '@babel/code-frame@7.10.4': dependencies: '@babel/highlight': 7.25.9 @@ -7241,6 +7376,8 @@ snapshots: chalk: 4.1.2 js-yaml: 4.1.1 + '@fastify/busboy@2.1.1': {} + '@humanfs/core@0.19.2': dependencies: '@humanfs/types': 0.15.0 @@ -7693,6 +7830,17 @@ snapshots: optionalDependencies: '@types/react': 19.2.14 + '@react-native-ai/llama@0.12.0(llama.rn@0.12.0-rc.9(react-native@0.83.6(@babel/core@7.29.0)(@types/react@19.2.14)(react@19.2.0))(react@19.2.0))(react-native@0.83.6(@babel/core@7.29.0)(@types/react@19.2.14)(react@19.2.0))(react@19.2.0)': + dependencies: + '@ai-sdk/provider': 3.0.14 + '@ai-sdk/provider-utils': 4.0.42(zod@4.4.3) + llama.rn: 0.12.0-rc.9(react-native@0.83.6(@babel/core@7.29.0)(@types/react@19.2.14)(react@19.2.0))(react@19.2.0) + react-native: 0.83.6(@babel/core@7.29.0)(@types/react@19.2.14)(react@19.2.0) + react-native-blob-util: 0.24.10(react-native@0.83.6(@babel/core@7.29.0)(@types/react@19.2.14)(react@19.2.0))(react@19.2.0) + zod: 4.4.3 + transitivePeerDependencies: + - react + '@react-native/assets-registry@0.83.6': {} '@react-native/babel-plugin-codegen@0.83.6(@babel/core@7.29.0)': @@ -7941,6 +8089,8 @@ snapshots: '@standard-schema/spec@1.1.0': {} + '@stardazed/streams-text-encoding@1.0.2': {} + '@testing-library/dom@10.4.1': dependencies: '@babel/code-frame': 7.29.0 @@ -8122,6 +8272,8 @@ snapshots: '@ungap/structured-clone@1.3.0': {} + '@ungap/structured-clone@1.3.3': {} + '@unrs/resolver-binding-android-arm-eabi@1.11.1': optional: true @@ -8181,6 +8333,8 @@ snapshots: '@unrs/resolver-binding-win32-x64-msvc@1.11.1': optional: true + '@vercel/oidc@3.2.0': {} + '@vitest/browser-preview@4.1.10(@voidzero-dev/vite-plus-test@0.1.24(@types/node@25.6.0)(esbuild@0.27.7)(terser@5.46.2)(tsx@4.21.0)(typescript@5.9.3)(vite@8.2.0(@types/node@25.6.0)(esbuild@0.27.7)(terser@5.46.2)(tsx@4.21.0)(yaml@2.8.3))(yaml@2.8.3))(vite@8.2.0(@types/node@25.6.0)(esbuild@0.27.7)(terser@5.46.2)(tsx@4.21.0)(yaml@2.8.3))': dependencies: '@testing-library/dom': 10.4.1 @@ -8347,6 +8501,8 @@ snapshots: '@voidzero-dev/vite-plus-win32-x64-msvc@0.2.7': optional: true + '@workflow/serde@4.1.0': {} + '@xmldom/xmldom@0.8.13': {} '@xmldom/xmldom@0.9.10': {} @@ -8441,6 +8597,13 @@ snapshots: agent-base@7.1.4: {} + ai@7.0.58(zod@4.4.3): + dependencies: + '@ai-sdk/gateway': 4.0.46(zod@4.4.3) + '@ai-sdk/provider': 4.0.7 + '@ai-sdk/provider-utils': 5.0.25(zod@4.4.3) + zod: 4.4.3 + ajv@6.15.0: dependencies: fast-deep-equal: 3.1.3 @@ -8708,6 +8871,8 @@ snapshots: balanced-match@4.0.4: {} + base-64@1.0.0: {} + base64-js@1.5.1: {} baseline-browser-mapping@2.10.24: {} @@ -9419,6 +9584,8 @@ snapshots: event-target-shim@5.0.1: {} + eventsource-parser@3.1.0: {} + expo-asset@55.0.16(expo@55.0.18)(react-native@0.83.6(@babel/core@7.29.0)(@types/react@19.2.14)(react@19.2.0))(react@19.2.0)(typescript@5.9.3): dependencies: '@expo/image-utils': 0.8.13(typescript@5.9.3) @@ -10196,6 +10363,8 @@ snapshots: json-schema-traverse@0.4.1: {} + json-schema@0.4.0: {} + json-stable-stringify-without-jsonify@1.0.1: {} json5@1.0.2: @@ -10331,6 +10500,11 @@ snapshots: lightningcss-win32-arm64-msvc: 1.33.0 lightningcss-win32-x64-msvc: 1.33.0 + llama.rn@0.12.0-rc.9(react-native@0.83.6(@babel/core@7.29.0)(@types/react@19.2.14)(react@19.2.0))(react@19.2.0): + dependencies: + react: 19.2.0 + react-native: 0.83.6(@babel/core@7.29.0)(@types/react@19.2.14)(react@19.2.0) + locate-path@5.0.0: dependencies: p-locate: 4.1.0 @@ -10963,6 +11137,13 @@ snapshots: react-is@19.2.5: {} + react-native-blob-util@0.24.10(react-native@0.83.6(@babel/core@7.29.0)(@types/react@19.2.14)(react@19.2.0))(react@19.2.0): + dependencies: + base-64: 1.0.0 + glob: 13.0.6 + react: 19.2.0 + react-native: 0.83.6(@babel/core@7.29.0)(@types/react@19.2.14)(react@19.2.0) + react-native-gesture-handler@2.30.1(react-native@0.83.6(@babel/core@7.29.0)(@types/react@19.2.14)(react@19.2.0))(react@19.2.0): dependencies: '@egjs/hammerjs': 2.0.17 @@ -11627,6 +11808,12 @@ snapshots: undici-types@7.19.2: {} + undici@5.29.0: + dependencies: + '@fastify/busboy': 2.1.1 + + undici@7.29.0: {} + unicode-canonical-property-names-ecmascript@2.0.1: {} unicode-match-property-ecmascript@2.0.0: diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 386d7b1..b24ec0a 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -1,10 +1,11 @@ allowBuilds: esbuild: true + llama.rn: true unrs-resolver: true catalog: vite: npm:@voidzero-dev/vite-plus-core@latest - vitest: npm:@voidzero-dev/vite-plus-test@latest vite-plus: latest + vitest: npm:@voidzero-dev/vite-plus-test@latest overrides: vite: "catalog:" vitest: "catalog:" diff --git a/src/app/(journal)/create.tsx b/src/app/(journal)/create.tsx index dd8242d..eede295 100644 --- a/src/app/(journal)/create.tsx +++ b/src/app/(journal)/create.tsx @@ -1,14 +1,14 @@ import { useState } from "react"; -import { Alert, Keyboard, PlatformColor } from "react-native"; +import { Keyboard, PlatformColor } from "react-native"; import * as Crypto from "expo-crypto"; import { Stack, useRouter } from "expo-router"; -import { z } from "zod"; import { JournalCreateView } from "@/components/journal/journal-create-view"; +import { useJournalField } from "@/hooks/journal/use-journal-field"; import { importJournal } from "@/utils/days/import-journal"; +import { handleSaveError } from "@/utils/handle-save-error"; import { setCreatedJournalId } from "@/utils/journal/created-journal"; -import { useJournalField } from "@/utils/journal/use-journal-field"; /** * ジャーナル作成 @@ -39,9 +39,7 @@ export default function JournalCreateScreen() { setCreatedJournalId(id); router.back(); } catch (error) { - if (error instanceof z.ZodError) { - Alert.alert("Validation Error", error.issues[0].message); - } + handleSaveError(error); } }; diff --git a/src/app/(journal)/edit.tsx b/src/app/(journal)/edit.tsx index 76b18f2..08d2e75 100644 --- a/src/app/(journal)/edit.tsx +++ b/src/app/(journal)/edit.tsx @@ -1,5 +1,5 @@ import { useState } from "react"; -import { Alert as RNAlert, Keyboard, PlatformColor } from "react-native"; +import { Keyboard, PlatformColor } from "react-native"; import { Alert, Button, Host, Text } from "@expo/ui/swift-ui"; import { useLiveQuery } from "drizzle-orm/expo-sqlite"; @@ -9,9 +9,10 @@ import { z } from "zod"; import { JournalCreateView } from "@/components/journal/journal-create-view"; import { deleteJournal, getJournalDetailQuery, JournalDetail } from "@/db/queries/journals"; import { FieldObj } from "@/db/schemas"; +import { FieldDraftObj, useJournalField } from "@/hooks/journal/use-journal-field"; +import { useValidatedParams } from "@/hooks/use-validated-params"; import { exportJournal } from "@/utils/days/export-journal"; -import { FieldDraftObj, useJournalField } from "@/utils/journal/use-journal-field"; -import { useValidatedParams } from "@/utils/params"; +import { handleSaveError } from "@/utils/handle-save-error"; type FormProps = { journal: JournalDetail; @@ -54,9 +55,7 @@ function JournalEditForm({ journal }: FormProps) { await updateJournal(journal.id); router.back(); } catch (error) { - if (error instanceof z.ZodError) { - RNAlert.alert("Validation Error", error.issues[0].message); - } + handleSaveError(error); } }; @@ -155,7 +154,9 @@ function JournalEditForm({ journal }: FormProps) { * ジャーナル編集 */ export default function JournalEditScreen() { - const { journalId } = useValidatedParams(z.object({ journalId: z.string() })); + const schema = z.object({ journalId: z.string() }); + const { journalId } = useValidatedParams(schema); + const { data: journal } = useLiveQuery(getJournalDetailQuery(journalId), [journalId]); return <>{journal && }; diff --git a/src/app/(journal)/entry/[id].tsx b/src/app/(journal)/entry/[id].tsx index 7e189c1..02a09a4 100644 --- a/src/app/(journal)/entry/[id].tsx +++ b/src/app/(journal)/entry/[id].tsx @@ -1,13 +1,14 @@ import { useState } from "react"; -import { Alert, PlatformColor } from "react-native"; +import { PlatformColor } from "react-native"; +import { useLiveQuery } from "drizzle-orm/expo-sqlite"; import { Stack, useRouter } from "expo-router"; import { z } from "zod"; -import { EntryCreateView } from "@/components/entry/entry-create-view"; import { EntryDetailView } from "@/components/entry/entry-detail"; -import { useEntryDetail } from "@/utils/entry/use-entry-detail"; -import { useValidatedParams } from "@/utils/params"; +import { EntryEditView } from "@/components/entry/entry-edit-view"; +import { bookmarkEntry, deleteEntry, getEntryDetailQuery } from "@/db/queries/entries"; +import { useValidatedParams } from "@/hooks/use-validated-params"; /** * エントリー詳細 @@ -15,106 +16,87 @@ import { useValidatedParams } from "@/utils/params"; export default function EntryDetailScreen() { const router = useRouter(); - const { - id: entryId, - journalName, - edit, - } = useValidatedParams( - z.object({ - id: z.string(), - journalName: z.string(), - edit: z - .string() - .optional() - .transform((v) => v === "true"), - }), - ); + const shema = z.object({ + id: z.string(), + journalName: z.string(), + edit: z + .string() + .optional() + .transform((v) => v === "true"), + }); + const { id: entryId, journalName, edit } = useValidatedParams(shema); const [editMode, setEditMode] = useState(edit); - - const { entry, valuesRef, setValue, save, bookmark, remove } = useEntryDetail(entryId); - - const handleSave = async () => { - try { - await save(); - setEditMode(false); - } catch (error) { - if (error instanceof z.ZodError) { - Alert.alert("Validation Error", error.issues[0].message); - } - } - }; + const { data: entry } = useLiveQuery(getEntryDetailQuery(entryId), [entryId]); const handleDelete = async () => { - await remove(); + await deleteEntry(entryId); router.back(); }; + const handleBookmark = async () => { + if (entry) await bookmarkEntry(entry.id, !entry.bookmark); + }; + return ( <> - editMode - ? [ - { - type: "button", - label: "Cancel", - onPress: () => setEditMode(false), - }, - { - type: "button", - label: "Save", - icon: { type: "sfSymbol", name: "checkmark" }, - tintColor: PlatformColor("systemIndigo"), - variant: "prominent", - onPress: handleSave, - }, - ] - : [ - { - type: "menu", - label: "Options", - icon: { type: "sfSymbol", name: "ellipsis" }, - menu: { - items: [ - { - type: "action", - icon: { - type: "sfSymbol", - name: entry?.bookmark ? "bookmark.slash" : "bookmark", - }, - label: entry?.bookmark ? "Unbookmark" : "Bookmark", - onPress: bookmark, - }, - { - type: "action", - label: "Delete Entry", - icon: { type: "sfSymbol", name: "trash" }, - destructive: true, - onPress: handleDelete, + unstable_headerRightItems: editMode + ? undefined + : () => [ + { + type: "menu", + label: "Options", + icon: { type: "sfSymbol", name: "ellipsis" }, + menu: { + items: [ + { + type: "action", + icon: { + type: "sfSymbol", + name: "square.and.arrow.up", }, - ], - }, + label: "Export Entry", + onPress: () => console.log("Export Entry"), + }, + { + type: "action", + label: "Delete Entry", + icon: { type: "sfSymbol", name: "trash" }, + destructive: true, + onPress: handleDelete, + }, + ], }, - { - type: "button", - label: "Edit", - onPress: () => setEditMode(true), + }, + { + type: "button", + icon: { + type: "sfSymbol", + name: entry?.bookmark ? "bookmark.fill" : "bookmark", }, - ], + label: entry?.bookmark ? "Unbookmark" : "Bookmark", + tintColor: entry?.bookmark ? PlatformColor("systemIndigo") : undefined, + onPress: handleBookmark, + }, + { + type: "button", + label: "Edit", + onPress: () => setEditMode(true), + }, + ], }} /> {entry && (editMode ? ( - setEditMode(false)} + onCancel={() => setEditMode(false)} /> ) : ( diff --git a/src/app/(journal)/entry/create.tsx b/src/app/(journal)/entry/create.tsx index c090ee5..6f34b3e 100644 --- a/src/app/(journal)/entry/create.tsx +++ b/src/app/(journal)/entry/create.tsx @@ -1,4 +1,4 @@ -import { Alert, Keyboard, PlatformColor } from "react-native"; +import { Keyboard, PlatformColor } from "react-native"; import { useLiveQuery } from "drizzle-orm/expo-sqlite"; import { Stack, useRouter } from "expo-router"; @@ -6,8 +6,9 @@ import { z } from "zod"; import { EntryCreateView } from "@/components/entry/entry-create-view"; import { getFieldsQuery } from "@/db/queries/fields"; -import { useEntry } from "@/utils/entry/use-entry"; -import { useValidatedParams } from "@/utils/params"; +import { useEntry } from "@/hooks/entry/use-entry"; +import { useValidatedParams } from "@/hooks/use-validated-params"; +import { handleSaveError } from "@/utils/handle-save-error"; /** * エントリー作成 @@ -15,9 +16,8 @@ import { useValidatedParams } from "@/utils/params"; export default function EntryCreateScreen() { const router = useRouter(); - const { journalId, journalName } = useValidatedParams( - z.object({ journalId: z.string(), journalName: z.string() }), - ); + const schema = z.object({ journalId: z.string(), journalName: z.string() }); + const { journalId, journalName } = useValidatedParams(schema); const { data: fields } = useLiveQuery(getFieldsQuery(journalId), [journalId]); const { valuesRef, setValue, createEntry } = useEntry(fields); @@ -29,9 +29,7 @@ export default function EntryCreateScreen() { const { id: newEntryId } = await createEntry(journalId); router.replace(`/(journal)/entry/${newEntryId}?journalName=${journalName}`); } catch (error) { - if (error instanceof z.ZodError) { - Alert.alert("Validation Error", error.issues[0].message); - } + handleSaveError(error); } }; diff --git a/src/app/(journal)/index.tsx b/src/app/(journal)/index.tsx index ead0df1..ec4a7e7 100644 --- a/src/app/(journal)/index.tsx +++ b/src/app/(journal)/index.tsx @@ -64,7 +64,7 @@ export default function JournalScreen() { type: "action" as const, icon: { type: "sfSymbol" as const, - name: bookmarkOnly ? ("bookmark.fill" as const) : ("bookmark" as const), + name: bookmarkOnly ? ("bookmark" as const) : ("bookmark.fill" as const), }, label: bookmarkOnly ? "Show All" : "Bookmarked Only", onPress: () => setBookmarkOnly((prev) => !prev), diff --git a/src/app/_layout.tsx b/src/app/_layout.tsx index 5d4f83c..f69d5eb 100644 --- a/src/app/_layout.tsx +++ b/src/app/_layout.tsx @@ -1,12 +1,19 @@ +import "@/polyfills"; import React from "react"; import AppTabs from "@/components/app-tabs"; import { DrizzleProvider } from "@/components/drizzle-provider"; +import { useAutoReflection } from "@/hooks/settings/use-auto-reflection"; + +function AppContent() { + useAutoReflection(); + return ; +} export default function RootLayout() { return ( - + ); } diff --git a/src/app/days/_layout.tsx b/src/app/days/_layout.tsx index dd76c45..0efa613 100644 --- a/src/app/days/_layout.tsx +++ b/src/app/days/_layout.tsx @@ -17,7 +17,17 @@ export default function DaysLayout() { }, headerBackButtonDisplayMode: "minimal", }} - /> + > + + + ); } diff --git a/src/app/days/index.tsx b/src/app/days/index.tsx index 4b6880a..47ae70f 100644 --- a/src/app/days/index.tsx +++ b/src/app/days/index.tsx @@ -18,10 +18,10 @@ import Animated, { import { BottomSheet, DatePicker, Host } from "@expo/ui/swift-ui"; import { datePickerStyle, tint } from "@expo/ui/swift-ui/modifiers"; import { GlassView } from "expo-glass-effect"; -import { Stack } from "expo-router"; +import { Stack, useRouter } from "expo-router"; import { DaysView } from "@/components/days/days-view"; -import { addDays, formatDateDays } from "@/utils/date"; +import { addDays, formatDateDays, startOfDay } from "@/utils/date"; const SWIPE_THRESHOLD = 50; @@ -30,12 +30,9 @@ const SWIPE_THRESHOLD = 50; */ export default function DaysScreen() { const { width: screenWidth } = useWindowDimensions(); - const [selectedDate, setSelectedDate] = useState(() => { - const d = new Date(); - d.setHours(0, 0, 0, 0); - return d; - }); + const [selectedDate, setSelectedDate] = useState(() => startOfDay()); const [showCalendar, setShowCalendar] = useState(false); + const router = useRouter(); const glassWidth = useSharedValue(200); @@ -44,8 +41,7 @@ export default function DaysScreen() { const prevDate = addDays(selectedDate, -1); const nextDate = addDays(selectedDate, 1); - const today = new Date(); - today.setHours(0, 0, 0, 0); + const today = startOfDay(); const canGoNext = nextDate <= today; @@ -141,7 +137,7 @@ export default function DaysScreen() { type: "button", label: "Save", icon: { type: "sfSymbol", name: "gearshape" }, - onPress: () => console.log("gear"), + onPress: () => router.navigate("/days/settings"), }, ], }} diff --git a/src/app/days/settings.tsx b/src/app/days/settings.tsx new file mode 100644 index 0000000..fca7b6e --- /dev/null +++ b/src/app/days/settings.tsx @@ -0,0 +1,26 @@ +import { PlatformColor } from "react-native"; + +import { Host, List } from "@expo/ui/swift-ui"; +import { frame } from "@expo/ui/swift-ui/modifiers"; + +import { Application } from "@/components/settings/application"; +import { EntrySettings } from "@/components/settings/entry"; +import { Support } from "@/components/settings/support"; + +/** + * Nicky 設定画面 + */ +export default function SettingsScreen() { + return ( + + + + + + + + ); +} diff --git a/src/components/days/days-card.tsx b/src/components/days/days-card.tsx index b2fff1b..0c58e03 100644 --- a/src/components/days/days-card.tsx +++ b/src/components/days/days-card.tsx @@ -5,8 +5,8 @@ import { font, foregroundStyle, listRowSeparator, opacity } from "@expo/ui/swift import { EntryDetailObj } from "@/db/queries/entries"; import { JournalObj } from "@/db/schemas"; +import { deserializeValue } from "@/hooks/entry/use-entry"; import { formatTime } from "@/utils/date"; -import { deserializeValue } from "@/utils/entry/use-entry"; import { EntryFieldItem } from "../entry/entry-field-item"; diff --git a/src/components/days/days-llm-fb.tsx b/src/components/days/days-llm-fb.tsx deleted file mode 100644 index 5168098..0000000 --- a/src/components/days/days-llm-fb.tsx +++ /dev/null @@ -1,38 +0,0 @@ -import { PlatformColor } from "react-native"; - -import { HStack, Image, Section, Text } from "@expo/ui/swift-ui"; -import { font, foregroundStyle } from "@expo/ui/swift-ui/modifiers"; - -import { FieldWrapper } from "../field/field-wrapper"; - -/** - * LLM によるフィードバックを表示する - */ -export function DaysLLMFB() { - return ( -
- - - - {`Today's reflection`} - - - - } - > - - hogehoge - - - fugafuga - -
- ); -} diff --git a/src/components/days/days-llm-reflection.tsx b/src/components/days/days-llm-reflection.tsx new file mode 100644 index 0000000..90d5699 --- /dev/null +++ b/src/components/days/days-llm-reflection.tsx @@ -0,0 +1,52 @@ +import { PlatformColor } from "react-native"; + +import { HStack, Image, Section, Text } from "@expo/ui/swift-ui"; +import { font, foregroundStyle } from "@expo/ui/swift-ui/modifiers"; +import { useLiveQuery } from "drizzle-orm/expo-sqlite"; + +import { getReflectionByDateQuery } from "@/db/queries/reflections"; +import { useAIReflectionSettings } from "@/hooks/settings/use-ai-reflection-settings"; + +import { FieldWrapper } from "../field/field-wrapper"; + +type Props = { + /** 表示対象の日付 */ + date: Date; +}; + +/** + * AI Reflection を表示する + */ +export function DaysLLMReflection({ date }: Props) { + const { aiReflectionEnabled } = useAIReflectionSettings(); + const { data: reflection } = useLiveQuery(getReflectionByDateQuery(date), [date.getTime()]); + + if (!aiReflectionEnabled || !reflection) return null; + + return ( +
+ + + + {reflection.title} + + + + } + > + + {reflection.firstContent} + + + {reflection.secondContent} + +
+ ); +} diff --git a/src/components/days/days-view.tsx b/src/components/days/days-view.tsx index 81cab05..f46d0ba 100644 --- a/src/components/days/days-view.tsx +++ b/src/components/days/days-view.tsx @@ -8,9 +8,12 @@ import { useLiveQuery } from "drizzle-orm/expo-sqlite"; import { getEntriesByDateQuery } from "@/db/queries/entries"; import { DaysCard } from "./days-card"; -import { DaysLLMFB } from "./days-llm-fb"; +import { DaysLLMReflection } from "./days-llm-reflection"; type Props = { + /** + * 日付 + */ date: Date; }; @@ -56,7 +59,7 @@ export function DaysView({ date }: Props) { animation(Animation.default, expandedIds.size), ]} > - + {entries.map((entry, index) => ( -
- {formatDate(entry.createdAt)} - - {entry.bookmark && ( - - )} - - } - > +
{formatDate(entry.createdAt)}}> {sorted.map((v) => ( void; + onCancel: () => void; +}) { + const { fields, initialValues } = buildEntryFormData(entry); + const { valuesRef, setValue, updateEntry } = useEntry(fields, initialValues); + + const handleSave = async () => { + try { + Keyboard.dismiss(); + await updateEntry(entry.id); + onSave(); + } catch (error) { + handleSaveError(error); + } + }; + + return ( + <> + [ + { + type: "button", + label: "Cancel", + onPress: onCancel, + }, + { + type: "button", + label: "Save", + icon: { type: "sfSymbol", name: "checkmark" }, + tintColor: PlatformColor("systemIndigo"), + variant: "prominent", + onPress: handleSave, + }, + ], + }} + /> + + + ); +} diff --git a/src/components/entry/entry-field-item.tsx b/src/components/entry/entry-field-item.tsx index c29183e..80cfe9e 100644 --- a/src/components/entry/entry-field-item.tsx +++ b/src/components/entry/entry-field-item.tsx @@ -7,7 +7,7 @@ import { EntryMedia } from "@/components/field/entry-media"; import { EntryText } from "@/components/field/entry-text"; import { EntryTime } from "@/components/field/entry-time"; import type { FieldObj } from "@/db/schemas"; -import { type FieldValue } from "@/utils/entry/use-entry"; +import { type FieldValue } from "@/hooks/entry/use-entry"; type Props = { /** フィールド定義 */ diff --git a/src/components/entry/entry-list-view.tsx b/src/components/entry/entry-list-view.tsx index 76be9d3..d823974 100644 --- a/src/components/entry/entry-list-view.tsx +++ b/src/components/entry/entry-list-view.tsx @@ -9,7 +9,7 @@ import { SymbolView } from "expo-symbols"; import { JournalChipList } from "@/components/journal/journal-chip"; import { deleteEntry } from "@/db/queries/entries"; import { type JournalWithCountObj } from "@/db/queries/journals"; -import { useEntryList } from "@/utils/entry/use-entry-list"; +import { useEntryList } from "@/hooks/entry/use-entry-list"; import { EntryRow } from "./entry-row"; diff --git a/src/components/entry/entry-row.tsx b/src/components/entry/entry-row.tsx index ee6ed5d..86ecb5b 100644 --- a/src/components/entry/entry-row.tsx +++ b/src/components/entry/entry-row.tsx @@ -55,7 +55,7 @@ export function EntryRow({ journalName, entry }: Props) { /> + + +
+ ); +} diff --git a/src/components/settings/support.tsx b/src/components/settings/support.tsx new file mode 100644 index 0000000..b1edb02 --- /dev/null +++ b/src/components/settings/support.tsx @@ -0,0 +1,14 @@ +import { Section, Text } from "@expo/ui/swift-ui"; +import { padding } from "@expo/ui/swift-ui/modifiers"; + +export function Support() { + return ( +
Nicky Version 1.0.0}> + 支援 + App Store で評価 + 利用規約 + プライバシーポリシー + 謝辞 +
+ ); +} diff --git a/src/constants/ai-models.ts b/src/constants/ai-models.ts new file mode 100644 index 0000000..7e1c90c --- /dev/null +++ b/src/constants/ai-models.ts @@ -0,0 +1,24 @@ +/** + * AI Reflection に使用できるモデルの定義 + */ +export const AI_MODELS = [ + { + id: "gemma-3-4b", + label: "Gemma 3 4B", + gguf: "ggml-org/gemma-3-4b-it-GGUF/gemma-3-4b-it-Q4_K_M.gguf", + }, + { + id: "qwen-3-4b", + label: "Qwen 3 4B", + gguf: "ggml-org/Qwen3-4B-GGUF/Qwen3-4B-Q4_K_M.gguf", + }, + { + id: "phi-4-mini", + label: "Phi-4 Mini", + gguf: "ggml-org/Phi-4-mini-instruct-GGUF/Phi-4-mini-instruct-Q4_K_M.gguf", + }, +] as const; + +export type AIModelId = (typeof AI_MODELS)[number]["id"]; + +export const DEFAULT_MODEL_ID: AIModelId = "gemma-3-4b"; diff --git a/src/core/constants.ts b/src/constants/journal.ts similarity index 100% rename from src/core/constants.ts rename to src/constants/journal.ts diff --git a/src/constants/reflection.ts b/src/constants/reflection.ts new file mode 100644 index 0000000..2c080b0 --- /dev/null +++ b/src/constants/reflection.ts @@ -0,0 +1,73 @@ +import { z } from "zod"; + +export const reflectionCategories = { + Highlights: "Pick out memorable or positive moments from the day", + Emotions: "Read emotional changes and mental states from the records", + Achievements: "Find accomplishments and efforts in the records", + Challenges: "Summarize difficulties and challenges found in the records", + Insights: "Draw insights and lessons from the records", + Relationships: "Reflect on interpersonal interactions in the records", + Habits: "Analyze habits and behavior patterns such as sleep, exercise, and meals", + Balance: "Evaluate the day's balance between work, hobbies, and rest", + Unresolved: "Extract unresolved concerns or unclear matters", + Tomorrow: "Suggest actionable takeaways and next steps", +} as const; + +const LANGUAGE_INSTRUCTIONS = { + ja: 'Output language: Japanese. Use a friendly tone with endings like "〜ですね" or "〜でしたね".', + en: "Output language: English. Use a warm, conversational tone.", +} as const; + +export const buildSystemPrompt = ( + categoryList: string, + lang: "ja" | "en", +) => `You are a daily reflection assistant. You analyze journal entries and generate a warm, personal reflection. + +Rules: +- Write from a third-person perspective, addressing the user directly. +- Only use facts found in the records. Never invent or assume anything. +- Do not make medical or psychological diagnoses. +- Do not predict the future. +- Do not force a positive interpretation. +- Do not include category names or meta phrases like "from the records" in the output. Talk about the content directly. +- Each content must be within 80 characters. +- ${LANGUAGE_INSTRUCTIONS[lang]} +- Output ONLY the specified JSON format. No other text. + +Categories: +${categoryList} + +Output format (JSON): +{ + "title": "A single sentence that captures the day", + "items": [ + {"category": "CategoryName", "content": "Reflection text"}, + {"category": "CategoryName", "content": "Reflection text"} + ] +} + +title is required. +For items, choose the 2 most relevant categories from the list above and write a reflection for each.`; + +export type ReflectionCategory = keyof typeof reflectionCategories; + +/** + * AI Reflection の出力スキーマ + */ +export const reflectionSchema = z.object({ + /** その日を象徴する語りかけの一文 */ + title: z.string().min(1).max(15), + /** 振り返り2項目 */ + items: z + .array( + z.object({ + category: z + .string() + .refine((v): v is ReflectionCategory => Object.hasOwn(reflectionCategories, v)), + content: z.string().min(1).max(80), + }), + ) + .length(2), +}); + +export type ReflectionResult = z.infer; diff --git a/src/db/queries/entries.ts b/src/db/queries/entries.ts index 4519f4d..55f9c9a 100644 --- a/src/db/queries/entries.ts +++ b/src/db/queries/entries.ts @@ -1,6 +1,7 @@ import { and, eq, gte, lt } from "drizzle-orm"; import { db } from "@/db/client"; +import { addDays, startOfDay } from "@/utils/date"; import { entries, EntryObj, EntryValueObj, entryValues } from "../schemas"; @@ -101,11 +102,8 @@ export const deleteAllEntries = async (journalId?: string) => { * @param date 対象日(時刻は無視される) */ export const getEntriesByDateQuery = (date: Date) => { - const start = new Date(date); - start.setHours(0, 0, 0, 0); - - const end = new Date(start); - end.setDate(end.getDate() + 1); + const start = startOfDay(date); + const end = addDays(start, 1); return db.query.entries.findMany({ where: and(gte(entries.createdAt, start.getTime()), lt(entries.createdAt, end.getTime())), @@ -120,3 +118,6 @@ export const getEntriesByDateQuery = (date: Date) => { /** エントリー詳細の型 */ export type EntryDetailObj = Awaited>[number]; + +/** 日付ベースのエントリー詳細の型(journal を含む) */ +export type DailyEntryObj = Awaited>[number]; diff --git a/src/db/queries/journals.ts b/src/db/queries/journals.ts index 697bb20..c1525d4 100644 --- a/src/db/queries/journals.ts +++ b/src/db/queries/journals.ts @@ -2,7 +2,7 @@ import { and, eq, notInArray, sql } from "drizzle-orm"; import { db } from "@/db/client"; import { entries, fields, JournalObj, journals } from "@/db/schemas"; -import { FieldWithSortObj, JournalMetaObj } from "@/utils/journal/use-journal-field"; +import { FieldWithSortObj, JournalMetaObj } from "@/hooks/journal/use-journal-field"; /** * ジャーナル一覧を取得するクエリ diff --git a/src/db/queries/reflections.ts b/src/db/queries/reflections.ts new file mode 100644 index 0000000..be57fd6 --- /dev/null +++ b/src/db/queries/reflections.ts @@ -0,0 +1,53 @@ +import { eq } from "drizzle-orm"; +import * as Crypto from "expo-crypto"; + +import type { ReflectionResult } from "@/constants/reflection"; +import { db } from "@/db/client"; +import { reflections } from "@/db/schemas"; +import { startOfDayTimestamp } from "@/utils/date"; + +/** + * 指定日の振り返りを取得するクエリ(useLiveQuery 用) + */ +export const getReflectionByDateQuery = (date: Date) => + db.query.reflections.findFirst({ + where: eq(reflections.date, startOfDayTimestamp(date)), + }); + +/** + * 振り返りを保存する (upsert) + */ +export const storeReflection = async (date: Date, result: ReflectionResult): Promise => { + await db.transaction(async (tx) => { + await tx + .insert(reflections) + .values({ + id: Crypto.randomUUID(), + date: startOfDayTimestamp(date), + title: result.title, + firstCategory: result.items[0].category, + firstContent: result.items[0].content, + secondCategory: result.items[1].category, + secondContent: result.items[1].content, + }) + .onConflictDoUpdate({ + target: reflections.date, + set: { + title: result.title, + firstCategory: result.items[0].category, + firstContent: result.items[0].content, + secondCategory: result.items[1].category, + secondContent: result.items[1].content, + }, + }); + }); +}; + +/** + * 指定日の振り返りを削除する + */ +export const deleteReflection = async (date: Date): Promise => { + await db.transaction(async (tx) => { + await tx.delete(reflections).where(eq(reflections.date, startOfDayTimestamp(date))); + }); +}; diff --git a/src/db/queries/settings.ts b/src/db/queries/settings.ts new file mode 100644 index 0000000..92afa27 --- /dev/null +++ b/src/db/queries/settings.ts @@ -0,0 +1,18 @@ +import { useLiveQuery } from "drizzle-orm/expo-sqlite"; + +import { db } from "@/db/client"; +import { settings } from "@/db/schemas"; + +/** + * 設定値を取得するライブクエリ + */ +export const useSettingsQuery = () => useLiveQuery(db.select().from(settings)); + +/** + * 設定値を書き込む (upsert) + */ +export const setSetting = (key: string, value: string | null) => + db + .insert(settings) + .values({ key, value }) + .onConflictDoUpdate({ target: settings.key, set: { value } }); diff --git a/src/db/schemas/fields.ts b/src/db/schemas/fields.ts index 54d1da6..401000e 100644 --- a/src/db/schemas/fields.ts +++ b/src/db/schemas/fields.ts @@ -4,8 +4,8 @@ import { relations } from "drizzle-orm"; import { integer, sqliteTable, text } from "drizzle-orm/sqlite-core"; import { createInsertSchema, createSelectSchema } from "drizzle-zod"; -import { fieldTypeSchema } from "@/core/constants"; -import type { FieldType } from "@/core/constants"; +import { fieldTypeSchema } from "@/constants/journal"; +import type { FieldType } from "@/constants/journal"; import { entryValues } from "./entries"; import { journals } from "./journals"; diff --git a/src/db/schemas/index.ts b/src/db/schemas/index.ts index 1090c31..402a6b8 100644 --- a/src/db/schemas/index.ts +++ b/src/db/schemas/index.ts @@ -1,3 +1,5 @@ export * from "./journals"; export * from "./fields"; export * from "./entries"; +export * from "./settings"; +export * from "./reflections"; diff --git a/src/db/schemas/reflections.ts b/src/db/schemas/reflections.ts new file mode 100644 index 0000000..4f4ba43 --- /dev/null +++ b/src/db/schemas/reflections.ts @@ -0,0 +1,31 @@ +import type { z } from "zod"; + +import { integer, sqliteTable, text } from "drizzle-orm/sqlite-core"; +import { createSelectSchema } from "drizzle-zod"; + +import type { ReflectionCategory } from "@/constants/reflection"; + +/** + * AI Reflection テーブル — 1日1件の振り返りを保存 + */ +export const reflections = sqliteTable("reflections", { + id: text().primaryKey(), + /** 日付 (00:00:00 にリセットした timestamp) */ + date: integer().notNull().unique(), + /** その日を表す一文 */ + title: text().notNull(), + /** 1つ目の振り返りカテゴリー */ + firstCategory: text().notNull().$type(), + /** 1つ目の振り返り内容 */ + firstContent: text().notNull(), + /** 2つ目の振り返りカテゴリー */ + secondCategory: text().notNull().$type(), + /** 2つ目の振り返り内容 */ + secondContent: text().notNull(), + createdAt: integer() + .notNull() + .$defaultFn(() => Date.now()), +}); + +export const reflectionSelectSchema = createSelectSchema(reflections); +export type ReflectionObj = z.infer; diff --git a/src/db/schemas/settings.ts b/src/db/schemas/settings.ts new file mode 100644 index 0000000..aa19f33 --- /dev/null +++ b/src/db/schemas/settings.ts @@ -0,0 +1,9 @@ +import { sqliteTable, text } from "drizzle-orm/sqlite-core"; + +/** + * アプリ設定テーブル (KVS形式) + */ +export const settings = sqliteTable("settings", { + key: text().primaryKey(), + value: text(), +}); diff --git a/src/db/seed.ts b/src/db/seed.ts index 5e0f5b9..d470d05 100644 --- a/src/db/seed.ts +++ b/src/db/seed.ts @@ -1,7 +1,7 @@ import { count } from "drizzle-orm"; import * as Crypto from "expo-crypto"; -import type { FieldType } from "@/core/constants"; +import type { FieldType } from "@/constants/journal"; import { db } from "./client"; import { entries, entryValues, fields, journals } from "./schemas"; diff --git a/src/utils/entry/use-entry-list.ts b/src/hooks/entry/use-entry-list.ts similarity index 83% rename from src/utils/entry/use-entry-list.ts rename to src/hooks/entry/use-entry-list.ts index 2831391..47ef9da 100644 --- a/src/utils/entry/use-entry-list.ts +++ b/src/hooks/entry/use-entry-list.ts @@ -1,8 +1,7 @@ import { useLiveQuery } from "drizzle-orm/expo-sqlite"; import { getEntriesQuery } from "@/db/queries/entries"; - -import { buildPreviewEntry } from "./preview"; +import { buildPreviewEntry } from "@/utils/entry/preview"; type Params = { /** ジャーナル id */ @@ -14,7 +13,7 @@ type Params = { /** * エントリー一覧を取得し、フィルターまで行うフック */ -export function useEntryList({ journalId, bookmarkOnly = false }: Params) { +export const useEntryList = ({ journalId, bookmarkOnly = false }: Params) => { const { data: entries } = useLiveQuery(getEntriesQuery(journalId), [journalId]); const previewEntries = entries.map(buildPreviewEntry); @@ -25,4 +24,4 @@ export function useEntryList({ journalId, bookmarkOnly = false }: Params) { const sorted = [...filtered].sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime()); return { entries: sorted }; -} +}; diff --git a/src/utils/entry/use-entry.ts b/src/hooks/entry/use-entry.ts similarity index 69% rename from src/utils/entry/use-entry.ts rename to src/hooks/entry/use-entry.ts index e2d456a..bb9929b 100644 --- a/src/utils/entry/use-entry.ts +++ b/src/hooks/entry/use-entry.ts @@ -3,7 +3,6 @@ import { useRef } from "react"; import * as Crypto from "expo-crypto"; import { z } from "zod"; -import type { FieldType } from "@/core/constants"; import { storeEntry, updateEntryValues } from "@/db/queries/entries"; import { entrySelectSchema, @@ -12,58 +11,10 @@ import { type EntryValueObj, type FieldObj, } from "@/db/schemas"; +import { type FieldValue, getDefaultValue, serializeValue } from "@/utils/entry/field-value"; -export const fieldValueSchema = z.union([z.string(), z.number(), z.boolean(), z.date(), z.null()]); -export type FieldValue = z.infer; - -/** - * 各エントリー入力のデフォルト値 - * @param type フィールドタイプ - */ -const getDefaultValue = (type: FieldType): FieldValue => { - switch (type) { - case "text": - case "longText": - return ""; - case "check": - return false; - case "date": - case "time": - return new Date(); - default: - return null; - } -}; - -/** FieldValue を DB の text 型に変換 */ -const serializeValue = (value: FieldValue): string | null => { - if (value === null) return null; - if (value instanceof Date) return String(value.getTime()); - return String(value); -}; - -/** - * DB の text 型を FieldValue に変換 - * @param value DB の値 - * @param type フィールドタイプ - */ -export const deserializeValue = (value: string | null, type: FieldType): FieldValue => { - if (value === null) return getDefaultValue(type); - switch (type) { - case "number": - return Number(value); - case "check": - return value === "true"; - case "date": - case "time": - return new Date(Number(value)); - case "media": - case "location": - return null; - default: - return value; - } -}; +export type { FieldValue } from "@/utils/entry/field-value"; +export { deserializeValue } from "@/utils/entry/field-value"; /** * エントリーフォームの値を管理するフック diff --git a/src/utils/journal/use-journal-field.ts b/src/hooks/journal/use-journal-field.ts similarity index 78% rename from src/utils/journal/use-journal-field.ts rename to src/hooks/journal/use-journal-field.ts index ec71ec9..3ed74b5 100644 --- a/src/utils/journal/use-journal-field.ts +++ b/src/hooks/journal/use-journal-field.ts @@ -3,44 +3,23 @@ import { useState } from "react"; import * as Crypto from "expo-crypto"; import { z } from "zod"; -import { - fieldTypeSchema, - type FieldType, - journalIconSchema, - JOURNAL_ICONS, -} from "@/core/constants"; +import { type FieldType, JOURNAL_ICONS } from "@/constants/journal"; import { storeJournal, updateJournal as updateJournalQuery } from "@/db/queries/journals"; -import { fieldInsertSchema, type JournalObj } from "@/db/schemas"; -import { hexColorSchema } from "@/utils/journal/color"; - -/** - * ジャーナルメタ情報のスキーマ - */ -export const journalMetaSchema = z.object({ - name: z.string().trim().min(1).max(20), // 20文字まで - color: hexColorSchema, - icon: journalIconSchema, -}); -export type JournalMetaObj = z.infer; - -/** - * ジャーナル作成フォームのフィールド下書き(journalId・sortOrder なし) - */ -export const fieldDraftSchema = fieldInsertSchema - .omit({ journalId: true, sortOrder: true }) - .extend({ label: z.string().trim().min(1).max(30) }); // 30 文字まで -export type FieldDraftObj = z.infer; - -/** - * sortOrder 確定済み・journalId 未割当のフィールド(DB 保存直前) - */ -export const fieldWithSortSchema = fieldInsertSchema.omit({ journalId: true }); -export type FieldWithSortObj = z.infer; - -/** - * 全 FieldType の配列 - */ -export const FIELD_TYPES: FieldType[] = fieldTypeSchema.options; +import { type JournalObj } from "@/db/schemas"; +import { + type FieldDraftObj, + type FieldWithSortObj, + type JournalMetaObj, + fieldDraftSchema, + journalMetaSchema, +} from "@/utils/journal/journal-field"; + +export type { + FieldDraftObj, + FieldWithSortObj, + JournalMetaObj, +} from "@/utils/journal/journal-field"; +export { FIELD_TYPES } from "@/utils/journal/journal-field"; const defaultMeta: JournalMetaObj = { name: "", diff --git a/src/hooks/settings/use-ai-reflection-settings.ts b/src/hooks/settings/use-ai-reflection-settings.ts new file mode 100644 index 0000000..b2701eb --- /dev/null +++ b/src/hooks/settings/use-ai-reflection-settings.ts @@ -0,0 +1,62 @@ +import { AI_MODELS, type AIModelId, DEFAULT_MODEL_ID } from "@/constants/ai-models"; +import { setSetting, useSettingsQuery } from "@/db/queries/settings"; + +/** デフォルトの振り返り時間 */ +const defaultReflectionTime = () => { + const d = new Date(); + d.setHours(21, 0, 0, 0); + return d; +}; + +const KEYS = { + aiReflectionEnabled: "ai_reflection_enabled", + aiModel: "ai_model", + reflectionTime: "reflection_time", +} as const; + +/** + * AI Reflection の設定を読み書きするフック + * @returns + * - aiReflectionEnabled 振り返り機能の有効フラグ + * - aiModel 選択中のモデル + * - reflectionTime 振り返りを生成する時刻 + * - setAIReflectionEnabled 有効/無効を切り替えて DB に保存する + * - setAIModel 使用モデルを切り替えて DB に保存する + * - setReflectionTime 振り返りを生成する時刻を DB に保存する + */ +export function useAIReflectionSettings() { + const { data: rows } = useSettingsQuery(); + + const get = (key: string) => rows.find((r) => r.key === key)?.value ?? null; + + 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)); + }; + + 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 new file mode 100644 index 0000000..2807160 --- /dev/null +++ b/src/hooks/settings/use-auto-reflection.ts @@ -0,0 +1,47 @@ +import { useEffect, useMemo, useRef } from "react"; + +import { useLiveQuery } from "drizzle-orm/expo-sqlite"; + +import { getEntriesByDateQuery } from "@/db/queries/entries"; +import { getReflectionByDateQuery, storeReflection } from "@/db/queries/reflections"; +import { useAIReflectionSettings } from "@/hooks/settings/use-ai-reflection-settings"; +import { isPastTime, startOfDay } from "@/utils/date"; +import { getReflection } from "@/utils/days/reflection/get-reflection"; + +/** + * アプリ起動時に今日の AI Reflection を自動生成するフック + * + * 以下の条件をすべて満たす場合に生成: + * 1. aiReflectionEnabled が true + * 2. 現在時刻が reflectionTime を過ぎている + * 3. 今日の reflection が DB に未保存 + * 4. 今日のエントリーが1件以上ある + */ +export const useAutoReflection = () => { + const { aiReflectionEnabled, aiModel, reflectionTime } = useAIReflectionSettings(); + const today = useMemo(() => startOfDay(), []); + + const { data: entries } = useLiveQuery(getEntriesByDateQuery(today), [today.getTime()]); + const { data: reflection } = useLiveQuery(getReflectionByDateQuery(today), [today.getTime()]); + const generating = useRef(false); + + useEffect(() => { + if (!aiReflectionEnabled) return; + if (reflection) return; + if (!entries || entries.length === 0) return; + if (!isPastTime(reflectionTime)) return; + if (generating.current) return; + + generating.current = true; + (async () => { + try { + const result = await getReflection(entries, aiModel.gguf); + if (result) await storeReflection(today, result); + } catch (error) { + console.warn("[auto-reflection]", error); + } finally { + generating.current = false; + } + })(); + }, [entries, reflection, aiReflectionEnabled, reflectionTime, aiModel.gguf, today]); +}; diff --git a/src/hooks/use-theme.ts b/src/hooks/use-theme.ts index 4b6210d..c82f200 100644 --- a/src/hooks/use-theme.ts +++ b/src/hooks/use-theme.ts @@ -6,9 +6,9 @@ import { Colors } from "@/constants/theme"; import { useColorScheme } from "@/hooks/use-color-scheme"; -export function useTheme() { +export const useTheme = () => { const scheme = useColorScheme(); const theme = scheme === "unspecified" ? "light" : scheme; return Colors[theme]; -} +}; diff --git a/src/utils/params.ts b/src/hooks/use-validated-params.ts similarity index 73% rename from src/utils/params.ts rename to src/hooks/use-validated-params.ts index d550fe7..56491e8 100644 --- a/src/utils/params.ts +++ b/src/hooks/use-validated-params.ts @@ -5,7 +5,7 @@ import { useLocalSearchParams } from "expo-router"; /** * useLocalSearchParams を Zod スキーマでバリデーションするフック */ -export function useValidatedParams(schema: T): z.infer { +export const useValidatedParams = (schema: T): z.infer => { const params = useLocalSearchParams(); return schema.parse(params); -} +}; diff --git a/src/polyfills.ts b/src/polyfills.ts new file mode 100644 index 0000000..1bc66af --- /dev/null +++ b/src/polyfills.ts @@ -0,0 +1,2 @@ +import "@ungap/structured-clone"; +import "@stardazed/streams-text-encoding"; diff --git a/src/utils/date.ts b/src/utils/date.ts index 932af46..32669b8 100644 --- a/src/utils/date.ts +++ b/src/utils/date.ts @@ -35,6 +35,24 @@ export const formatDateDays = (date: Date) => ? date.toLocaleDateString("en-US", { month: "long", day: "numeric" }) : date.toLocaleDateString("en-US", { month: "long", day: "numeric", year: "numeric" }); +/** + * 日付を 00:00:00.000 に正規化した Date を返す + * @param date 基準日付(省略時は今日) + * @returns 正規化した日付 + */ +export const startOfDay = (date: Date = new Date()): Date => { + const d = new Date(date); + d.setHours(0, 0, 0, 0); + return d; +}; + +/** + * 日付を 00:00:00.000 に正規化したタイムスタンプを返す + * @param date 基準日付 + * @returns 正規化したタイムスタンプ + */ +export const startOfDayTimestamp = (date: Date): number => startOfDay(date).getTime(); + /** 日付を n 日ずらす * @param date 基準日付 * @param days 日数 @@ -42,9 +60,18 @@ export const formatDateDays = (date: Date) => */ export const addDays = (date: Date, days: number) => { const d = new Date(date); - d.setDate(d.getDate() + days); - d.setHours(0, 0, 0, 0); + return startOfDay(d); +}; - return d; +/** + * 現在時刻が指定時刻を過ぎているか(時・分のみ比較) + * @param time 比較対象の時刻 + */ +export const isPastTime = (time: Date): boolean => { + const now = new Date(); + return ( + now.getHours() > time.getHours() || + (now.getHours() === time.getHours() && now.getMinutes() >= time.getMinutes()) + ); }; diff --git a/src/utils/days/import-journal.ts b/src/utils/days/import-journal.ts index 936df22..5fc6201 100644 --- a/src/utils/days/import-journal.ts +++ b/src/utils/days/import-journal.ts @@ -4,7 +4,7 @@ import * as DocumentPicker from "expo-document-picker"; import { File } from "expo-file-system"; import { z } from "zod"; -import { journalIconSchema } from "@/core/constants"; +import { journalIconSchema } from "@/constants/journal"; import type { JournalDetail } from "@/db/queries/journals"; import { fieldSelectSchema, journalSelectSchema } from "@/db/schemas"; import { generateSignature } from "@/utils/days/export-journal"; diff --git a/src/utils/days/reflection/get-reflection.ts b/src/utils/days/reflection/get-reflection.ts new file mode 100644 index 0000000..82e54b4 --- /dev/null +++ b/src/utils/days/reflection/get-reflection.ts @@ -0,0 +1,67 @@ +import { downloadModel, llama } from "@react-native-ai/llama"; +import { generateText } from "ai"; + +import { + type ReflectionResult, + buildSystemPrompt, + reflectionCategories, + reflectionSchema, +} from "@/constants/reflection"; +import { DailyEntryObj } from "@/db/queries/entries"; +import { formatFieldValue } from "@/utils/entry/preview"; + +/** + * 日毎のエントリーをLLMに渡すテキストに変換する + */ +const entriesToText = (entries: DailyEntryObj[]): string => + entries + .map((entry) => { + const header = `[${entry.journal.name}]`; + const values = [...entry.values] + .sort((a, b) => a.field.sortOrder - b.field.sortOrder) + .map((v) => `${v.field.label}: ${formatFieldValue(v.value, v.field.type)}`) + .join("\n"); + return `${header}\n${values}`; + }) + .join("\n---\n"); + +const categoryList = Object.entries(reflectionCategories) + .map(([key, desc]) => `- ${key}: ${desc}`) + .join("\n"); + +/** + * その日の記録をもとに AI Reflection を生成する + * @param entries その日のエントリーの一覧 + * @param gguf HuggingFaceのGGUFモデルパス + */ +export const getReflection = async ( + entries: DailyEntryObj[], + gguf: string, +): Promise => { + const modelPath = await downloadModel(gguf); + const model = llama.languageModel(modelPath); + + try { + await model.prepare(); + + const entriesText = entriesToText(entries); + const prompt = `Here are today's journal entries. Generate a reflection based on these records.\n\n${entriesText}`; + + const { text } = await generateText({ + model, + system: buildSystemPrompt(categoryList, "ja"), + prompt, + }); + const json = text.match(/\{[\s\S]*\}/)?.[0]; + + if (!json) return null; + const result = reflectionSchema.safeParse(JSON.parse(json)); + + return result.success ? result.data : null; + } catch (error) { + console.warn("[reflection]", error); + return null; + } finally { + await model.unload(); + } +}; diff --git a/src/utils/entry/entry-form.ts b/src/utils/entry/entry-form.ts index 95df779..61979ef 100644 --- a/src/utils/entry/entry-form.ts +++ b/src/utils/entry/entry-form.ts @@ -1,7 +1,6 @@ import { EntryDetailObj } from "@/db/queries/entries"; import { FieldObj } from "@/db/schemas"; - -import { deserializeValue, FieldValue } from "./use-entry"; +import { deserializeValue, FieldValue } from "@/utils/entry/field-value"; /** * エントリー詳細からフォーム用のフィールド一覧と初期値を導出する diff --git a/src/utils/entry/field-value.ts b/src/utils/entry/field-value.ts new file mode 100644 index 0000000..4342148 --- /dev/null +++ b/src/utils/entry/field-value.ts @@ -0,0 +1,55 @@ +import { z } from "zod"; + +import type { FieldType } from "@/constants/journal"; + +export const fieldValueSchema = z.union([z.string(), z.number(), z.boolean(), z.date(), z.null()]); +export type FieldValue = z.infer; + +/** + * 各エントリー入力のデフォルト値 + * @param type フィールドタイプ + */ +export const getDefaultValue = (type: FieldType): FieldValue => { + switch (type) { + case "text": + case "longText": + return ""; + case "check": + return false; + case "date": + case "time": + return new Date(); + default: + return null; + } +}; + +/** FieldValue を DB の text 型に変換 */ +export const serializeValue = (value: FieldValue): string | null => { + if (value === null) return null; + if (value instanceof Date) return String(value.getTime()); + return String(value); +}; + +/** + * DB の text 型を FieldValue に変換 + * @param value DB の値 + * @param type フィールドタイプ + */ +export const deserializeValue = (value: string | null, type: FieldType): FieldValue => { + if (value === null) return getDefaultValue(type); + switch (type) { + case "number": + return Number(value); + case "check": + return value === "true"; + case "date": + case "time": + return new Date(Number(value)); + case "media": + case "location": + return null; + default: + return value; + } +}; diff --git a/src/utils/entry/preview.ts b/src/utils/entry/preview.ts index 54ea4cb..2c072b2 100644 --- a/src/utils/entry/preview.ts +++ b/src/utils/entry/preview.ts @@ -1,4 +1,4 @@ -import { FieldType } from "@/core/constants"; +import { FieldType } from "@/constants/journal"; import { EntryDetailObj } from "@/db/queries/entries"; import { formatDate, formatTime } from "../date"; diff --git a/src/utils/entry/use-entry-detail.ts b/src/utils/entry/use-entry-detail.ts deleted file mode 100644 index 44d9e15..0000000 --- a/src/utils/entry/use-entry-detail.ts +++ /dev/null @@ -1,40 +0,0 @@ -import { Keyboard } from "react-native"; - -import { useLiveQuery } from "drizzle-orm/expo-sqlite"; - -import { bookmarkEntry, deleteEntry, getEntryDetailQuery } from "@/db/queries/entries"; - -import { buildEntryFormData } from "./entry-form"; -import { useEntry } from "./use-entry"; - -/** - * エントリー詳細画面のデータ取得・フォーム状態・アクションをまとめたフック - * @param entryId エントリー id - */ -export function useEntryDetail(entryId: string) { - const { data: entry } = useLiveQuery(getEntryDetailQuery(entryId), [entryId]); - - const { fields, initialValues } = entry - ? buildEntryFormData(entry) - : { fields: [], initialValues: null }; - - const { valuesRef, setValue, updateEntry } = useEntry(fields, initialValues); - - /** 編集内容を保存する */ - const save = async () => { - Keyboard.dismiss(); - await updateEntry(entryId); - }; - - /** ブックマーク状態を切り替える */ - const bookmark = async () => { - if (entry) await bookmarkEntry(entry.id, !entry.bookmark); - }; - - /** エントリーを削除する */ - const remove = async () => { - await deleteEntry(entryId); - }; - - return { entry, valuesRef, setValue, save, bookmark, remove }; -} diff --git a/src/utils/handle-save-error.ts b/src/utils/handle-save-error.ts new file mode 100644 index 0000000..68f344d --- /dev/null +++ b/src/utils/handle-save-error.ts @@ -0,0 +1,16 @@ +import { Alert } from "react-native"; + +import { z } from "zod"; + +/** + * 保存・更新処理の共通エラーハンドリング + * - ZodError → バリデーションエラーを表示 + * - その他 → 汎用エラーを表示 + */ +export const handleSaveError = (error: unknown) => { + if (error instanceof z.ZodError) { + Alert.alert("Validation Error", error.issues[0].message); + } else { + Alert.alert("Error", "An unexpected error occurred. Please try again."); + } +}; diff --git a/src/utils/journal/journal-field.ts b/src/utils/journal/journal-field.ts new file mode 100644 index 0000000..c8693f9 --- /dev/null +++ b/src/utils/journal/journal-field.ts @@ -0,0 +1,35 @@ +import { z } from "zod"; + +import { fieldTypeSchema, type FieldType, journalIconSchema } from "@/constants/journal"; +import { fieldInsertSchema } from "@/db/schemas"; + +import { hexColorSchema } from "./color"; + +/** + * ジャーナルメタ情報のスキーマ + */ +export const journalMetaSchema = z.object({ + name: z.string().trim().min(1).max(20), // 20文字まで + color: hexColorSchema, + icon: journalIconSchema, +}); +export type JournalMetaObj = z.infer; + +/** + * ジャーナル作成フォームのフィールド下書き(journalId・sortOrder なし) + */ +export const fieldDraftSchema = fieldInsertSchema + .omit({ journalId: true, sortOrder: true }) + .extend({ label: z.string().trim().min(1).max(30) }); // 30 文字まで +export type FieldDraftObj = z.infer; + +/** + * sortOrder 確定済み・journalId 未割当のフィールド(DB 保存直前) + */ +export const fieldWithSortSchema = fieldInsertSchema.omit({ journalId: true }); +export type FieldWithSortObj = z.infer; + +/** + * 全 FieldType の配列 + */ +export const FIELD_TYPES: FieldType[] = fieldTypeSchema.options;