diff --git a/drizzle/migrations.js b/drizzle/migrations.js index dbad105..0fc3525 100644 --- a/drizzle/migrations.js +++ b/drizzle/migrations.js @@ -1,12 +1,11 @@ // This file is required for Expo/React Native SQLite migrations - https://orm.drizzle.team/quick-sqlite/expo -import journal from './meta/_journal.json'; -import m0000 from './0000_young_bulldozer.sql'; +import m0000 from "./0000_young_bulldozer.sql"; +import journal from "./meta/_journal.json"; - export default { - journal, - migrations: { - m0000 - } - } - \ No newline at end of file +export default { + journal, + migrations: { + m0000, + }, +}; diff --git a/src/app/(journal)/[id].tsx b/src/app/(journal)/[id].tsx index f9a654d..1f6259f 100644 --- a/src/app/(journal)/[id].tsx +++ b/src/app/(journal)/[id].tsx @@ -1,37 +1,33 @@ import { useState } from "react"; -import { Stack, useLocalSearchParams } from "expo-router"; +import { Stack, useLocalSearchParams, useRouter } from "expo-router"; import { EntryListView } from "@/components/entry/entry-list-view"; - -/** - * ソート一覧 - */ -export type SortKey = - | "dateDesc" - | "dateAsc" - | "titleAsc" - | "titleDesc" - | "bookmark"; - -/** - * ソートラベル - */ -const SORT_LABELS: Record = { - dateDesc: "Newest First", - dateAsc: "Oldest First", - titleAsc: "Title (A→Z)", - titleDesc: "Title (Z→A)", - bookmark: "Bookmarked", -}; +import { deleteAllEntries } from "@/db/queries/entries"; +import { SortKey } from "@/utils/entry/consts"; /** * ジャーナル詳細(エントリー一覧) */ export default function JournalScreen() { - const { id, name } = useLocalSearchParams<{ id: string; name: string }>(); - const [searchText, setSearchText] = useState(""); - const [sortKey, setSortKey] = useState("dateDesc"); + const router = useRouter(); + + const { id: journalId, name } = useLocalSearchParams<{ + id: string; + name: string; + }>(); + + const [filter, setFilter] = useState<{ + searchText: string; + sortKey: SortKey; + }>({ + searchText: "", + sortKey: "dateDesc", + }); + + const handleDeleteAll = async () => { + await deleteAllEntries(journalId).catch(console.error); + }; return ( <> @@ -42,10 +38,61 @@ export default function JournalScreen() { headerSearchBarOptions: { placeholder: "Search", hideWhenScrolling: false, - onChangeText: (e) => setSearchText(e.nativeEvent.text), - onCancelButtonPress: () => setSearchText(""), + onChangeText: (e) => { + const text = e.nativeEvent?.text ?? ""; + setFilter((prev) => ({ ...prev, searchText: text })); + }, + onCancelButtonPress: () => + setFilter((prev) => ({ ...prev, searchText: "" })), }, unstable_headerRightItems: () => [ + { + type: "menu", + label: "Sort", + icon: { + type: "sfSymbol", + name: "arrow.up.arrow.down", + }, + menu: { + items: [ + { + type: "action", + label: "Newest First", + state: filter.sortKey === "dateDesc" ? "on" : "off", + onPress: () => + setFilter((prev) => ({ ...prev, sortKey: "dateDesc" })), + }, + { + type: "action", + label: "Oldest First", + state: filter.sortKey === "dateAsc" ? "on" : "off", + onPress: () => + setFilter((prev) => ({ ...prev, sortKey: "dateAsc" })), + }, + { + type: "action", + label: "Title (A→Z)", + state: filter.sortKey === "titleAsc" ? "on" : "off", + onPress: () => + setFilter((prev) => ({ ...prev, sortKey: "titleAsc" })), + }, + { + type: "action", + label: "Title (Z→A)", + state: filter.sortKey === "titleDesc" ? "on" : "off", + onPress: () => + setFilter((prev) => ({ ...prev, sortKey: "titleDesc" })), + }, + { + type: "action", + label: "Bookmarked", + state: filter.sortKey === "bookmark" ? "on" : "off", + onPress: () => + setFilter((prev) => ({ ...prev, sortKey: "bookmark" })), + }, + ], + }, + }, { type: "menu", label: "Options", @@ -56,88 +103,37 @@ export default function JournalScreen() { menu: { items: [ { - type: "submenu", - label: "", - inline: true, - items: [ - { - type: "submenu", - label: "Sort", - details: SORT_LABELS[sortKey], - icon: { - type: "sfSymbol", - name: "arrow.up.arrow.down", - }, - items: [ - { - type: "action", - label: "Newest First", - state: sortKey === "dateDesc" ? "on" : "off", - onPress: () => setSortKey("dateDesc"), - }, - { - type: "action", - label: "Oldest First", - state: sortKey === "dateAsc" ? "on" : "off", - onPress: () => setSortKey("dateAsc"), - }, - { - type: "action", - label: "Title (A→Z)", - state: sortKey === "titleAsc" ? "on" : "off", - onPress: () => setSortKey("titleAsc"), - }, - { - type: "action", - label: "Title (Z→A)", - state: sortKey === "titleDesc" ? "on" : "off", - onPress: () => setSortKey("titleDesc"), - }, - { - type: "action", - label: "Bookmarked", - state: sortKey === "bookmark" ? "on" : "off", - onPress: () => setSortKey("bookmark"), - }, - ], - }, - { - type: "action", - icon: { - type: "sfSymbol", - name: "ellipsis.circle", - }, - label: "Edit Journal", - onPress: () => { - // Do something - console.log("Edit Journal"); - }, - }, - { - type: "action", - icon: { - type: "sfSymbol", - name: "square.and.arrow.up.on.square", - }, - label: "Export", - onPress: () => { - // Do something - }, - }, - ], + type: "action", + icon: { + type: "sfSymbol", + name: "ellipsis.circle", + }, + label: "Edit", + onPress: () => { + router.push(`/(journal)/edit?journalId=${journalId}`); + }, }, { type: "action", icon: { type: "sfSymbol", - name: "trash", + name: "square.and.arrow.up.on.square", }, - destructive: true, - label: "Delete All", + label: "Export", onPress: () => { // Do something }, }, + { + type: "action", + label: "Delete All Entries", + icon: { + type: "sfSymbol", + name: "trash", + }, + destructive: true, + onPress: handleDeleteAll, + }, ], }, }, @@ -147,10 +143,10 @@ export default function JournalScreen() { {/* TODO: 空の場合の処理を追加する */} {/* エントリー一覧にはheaderTitleのデータは含まないので src/app/(journal)/entry/[id].tsx と混同しないように。 */} ); diff --git a/src/app/(journal)/create.tsx b/src/app/(journal)/create.tsx index 4f14fd0..4038bb7 100644 --- a/src/app/(journal)/create.tsx +++ b/src/app/(journal)/create.tsx @@ -1,7 +1,6 @@ -import { PlatformColor, Pressable } from "react-native"; +import { Keyboard, PlatformColor } from "react-native"; import { Stack, useRouter } from "expo-router"; -import { SymbolView } from "expo-symbols"; import { JournalCreateView } from "@/components/journal/journal-create-view"; import { useJournalField } from "@/utils/journal/use-journal-field"; @@ -25,6 +24,7 @@ export default function JournalCreateScreen() { } = useJournalField(); const handleJournalCreate = async () => { + Keyboard.dismiss(); const { id: newJournalId, name } = await createJournal(); // replace でスタックせずにジャーナル詳細画面からジャーナル一覧へ戻れるようにする @@ -36,18 +36,18 @@ export default function JournalCreateScreen() { ( - - - - ), + unstable_headerRightItems: () => [ + { + type: "button", + label: "Save", + icon: { type: "sfSymbol", name: "checkmark" }, + tintColor: formDisabled + ? PlatformColor("tertiaryLabel") + : PlatformColor("systemIndigo"), + disabled: formDisabled, + onPress: formDisabled ? () => {} : handleJournalCreate, + }, + ], }} /> > +>; + +type FormProps = { + journal: JournalDetail; +}; + +/** + * ジャーナル編集フォーム(journal ロード後にマウントして初期値を確定させる) + */ +function JournalEditForm({ journal }: FormProps) { + const router = useRouter(); + + const { name, icon, color } = journal; + + const initialMeta = { name, icon, color }; + + const initialFields: FieldDraftObj[] = [...journal.fields] + .sort((a: FieldObj, b: FieldObj) => a.sortOrder - b.sortOrder) + .map((f: FieldObj) => ({ id: f.id, type: f.type, label: f.label })); + + const { + fields, + addField, + renameField, + deleteField, + moveField, + meta, + setMeta, + updateJournal, + formDisabled, + } = useJournalField({ + meta: initialMeta, + fields: initialFields, + }); + + const handleSave = async () => { + Keyboard.dismiss(); + await updateJournal(journal.id); + router.back(); + }; + + return ( + <> + [ + { + type: "button" as const, + label: "Save", + icon: { type: "sfSymbol" as const, name: "checkmark" }, + tintColor: formDisabled + ? PlatformColor("tertiaryLabel") + : PlatformColor("systemIndigo"), + disabled: formDisabled, + onPress: formDisabled ? () => {} : handleSave, + }, + ], + }} + /> + + + ); +} + +/** + * ジャーナル編集 + */ +export default function JournalEditScreen() { + const { journalId } = useLocalSearchParams<{ journalId: string }>(); + const { data: journal } = useLiveQuery(getJournalDetailQuery(journalId)); + + return <>{journal && }; +} diff --git a/src/app/(journal)/entry/[id].tsx b/src/app/(journal)/entry/[id].tsx index 4955a62..bac0acd 100644 --- a/src/app/(journal)/entry/[id].tsx +++ b/src/app/(journal)/entry/[id].tsx @@ -1,71 +1,130 @@ +import { useState } from "react"; +import { Keyboard, PlatformColor } from "react-native"; + import { useLiveQuery } from "drizzle-orm/expo-sqlite"; -import { Stack, useLocalSearchParams } from "expo-router"; +import { Stack, useLocalSearchParams, useRouter } from "expo-router"; +import { EntryCreateView } from "@/components/entry/entry-create-view"; import { EntryDetailView } from "@/components/entry/entry-detail"; -import { getEntryDetailQuery } from "@/db/queries/entries"; +import { + bookmarkEntry, + deleteEntry, + getEntryDetailQuery, +} from "@/db/queries/entries"; +import { buildEntryFormData } from "@/utils/entry/entry-form"; +import { useEntry } from "@/utils/entry/use-entry"; /** * エントリー詳細 */ export default function EntryDetailScreen() { - const { id } = useLocalSearchParams<{ id: string }>(); + const router = useRouter(); + + const { + id: entryId, + journalName, + edit, + } = useLocalSearchParams<{ + id: string; + journalName: string; + edit: string; + }>(); + + const [editMode, setEditMode] = useState(Boolean(edit)); + + const { data: entry } = useLiveQuery(getEntryDetailQuery(entryId)); + + const { fields, initialValues } = entry + ? buildEntryFormData(entry) + : { fields: [], initialValues: null }; + + const { valuesRef, setValue, updateEntry } = useEntry(fields, initialValues); + + const handleSave = async () => { + Keyboard.dismiss(); + await updateEntry(entryId); + setEditMode(false); + }; + + const handleBookmark = async () => { + if (entry) await bookmarkEntry(entry.id, !entry.bookmark); + }; - const { data: entry } = useLiveQuery(getEntryDetailQuery(id)); + const handleDelete = async () => { + await deleteEntry(entryId); + router.back(); + }; return ( <> [ - { - type: "menu", - label: "Options", - icon: { - type: "sfSymbol", - name: "ellipsis", - }, - menu: { - items: [ + title: journalName, + headerLargeTitleEnabled: true, + unstable_headerRightItems: () => + editMode + ? [ { - type: "action", - icon: { - type: "sfSymbol", - name: entry?.bookmark ? "bookmark.fill" : "bookmark", - }, - label: "Bookmark", - onPress: () => { - // Do something - }, + type: "button", + label: "Cancel", + onPress: () => setEditMode(false), }, { - type: "action", - label: "Delete", - icon: { - type: "sfSymbol", - name: "trash", - }, - destructive: true, - onPress: () => { - // Do something + type: "button", + label: "Save", + icon: { type: "sfSymbol", name: "checkmark" }, + tintColor: PlatformColor("systemIndigo"), + 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: handleBookmark, + }, + { + type: "action", + label: "Delete", + icon: { type: "sfSymbol", name: "trash" }, + destructive: true, + onPress: handleDelete, + }, + ], }, }, + { + type: "button", + label: "Edit", + onPress: () => setEditMode(true), + }, ], - }, - }, - { - type: "button", - label: "Edit", - onPress: () => { - // Do something - }, - }, - ], }} /> - {/* TODO: 空の場合の処理を追加する */} - {entry && } + + {entry && + (editMode ? ( + + ) : ( + + ))} ); } diff --git a/src/app/(journal)/entry/create.tsx b/src/app/(journal)/entry/create.tsx index c6e303c..67a0064 100644 --- a/src/app/(journal)/entry/create.tsx +++ b/src/app/(journal)/entry/create.tsx @@ -1,14 +1,7 @@ -import { - PlatformColor, - Pressable, - Text as RNText, - StyleSheet, - View, -} from "react-native"; +import { Keyboard, PlatformColor } from "react-native"; import { useLiveQuery } from "drizzle-orm/expo-sqlite"; import { Stack, useLocalSearchParams, useRouter } from "expo-router"; -import { SymbolView } from "expo-symbols"; import { EntryCreateView } from "@/components/entry/entry-create-view"; import { getFieldsQuery } from "@/db/queries/fields"; @@ -20,60 +13,43 @@ import { useEntry } from "@/utils/entry/use-entry"; export default function EntryCreateScreen() { const router = useRouter(); - const { id: jounalId, name } = useLocalSearchParams<{ - id: string; - name: string; + const { journalId, journalName } = useLocalSearchParams<{ + journalId: string; + journalName: string; }>(); - const { data: fields } = useLiveQuery(getFieldsQuery(jounalId)); + const { data: fields } = useLiveQuery(getFieldsQuery(journalId)); const { valuesRef, setValue, createEntry } = useEntry(fields); const handleEntryCreate = async () => { - const { id: newEntryId } = await createEntry(jounalId); - router.replace(`/(journal)/entry/${newEntryId}`); + Keyboard.dismiss(); + const { id: newEntryId } = await createEntry(journalId); + router.replace(`/(journal)/entry/${newEntryId}?journalName=${journalName}`); }; return ( <> ( - - New Entry - {name} - - ), - headerRight: () => ( - // エントリー作成では disabled は設定しない - - - - ), + title: journalName, + headerLargeTitleEnabled: true, + // エントリー作成では disabled は設定しない + unstable_headerRightItems: () => [ + { + type: "button", + label: "Create New Entry", + icon: { type: "sfSymbol", name: "checkmark" }, + tintColor: PlatformColor("systemIndigo"), + onPress: handleEntryCreate, + }, + ], }} /> ); } - -const styles = StyleSheet.create({ - headerTitle: { - alignItems: "center", - }, - title: { - fontSize: 17, - fontWeight: "600", - color: PlatformColor("label"), - }, - subtitle: { - fontSize: 14, - color: PlatformColor("secondaryLabel"), - }, -}); diff --git a/src/app/(journal)/index.tsx b/src/app/(journal)/index.tsx index 4de32e6..74193d6 100644 --- a/src/app/(journal)/index.tsx +++ b/src/app/(journal)/index.tsx @@ -1,7 +1,4 @@ -import { PlatformColor, Pressable } from "react-native"; - import { Stack, useRouter } from "expo-router"; -import { SymbolView } from "expo-symbols"; import { JournalView } from "@/components/journal/journal-view"; @@ -17,14 +14,14 @@ export default function JournalListScreen() { options={{ title: "Journal", headerLargeTitleEnabled: true, - headerRight: () => ( - router.push("/(journal)/create")}> - - - ), + unstable_headerRightItems: () => [ + { + type: "button", + label: "Create New Journal", + icon: { type: "sfSymbol", name: "folder.badge.plus" }, + onPress: () => router.push("/(journal)/create"), + }, + ], }} /> {/* TODO: 空の場合の処理を追加する */} diff --git a/src/app/explore.tsx b/src/app/explore.tsx index 6cf1c52..dc8b8ad 100644 --- a/src/app/explore.tsx +++ b/src/app/explore.tsx @@ -1,8 +1,9 @@ import { PlatformColor, View } from "react-native"; -import { Host } from "@expo/ui/swift-ui"; +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 ( @@ -11,7 +12,10 @@ export default function ExploreScreen() { style={{ flex: 1, backgroundColor: PlatformColor("systemBackground") }} useViewportSizeMeasurement > - + + + + ); diff --git a/src/components/app-tabs.tsx b/src/components/app-tabs.tsx index e7a3559..c2d05d7 100644 --- a/src/components/app-tabs.tsx +++ b/src/components/app-tabs.tsx @@ -21,8 +21,8 @@ export default function AppTabs() { - Report - + Insite + diff --git a/src/components/chart-demo.tsx b/src/components/chart-demo.tsx new file mode 100644 index 0000000..28bfe9e --- /dev/null +++ b/src/components/chart-demo.tsx @@ -0,0 +1,74 @@ +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-create-view.tsx b/src/components/entry/entry-create-view.tsx index 46babc5..8e0ab7f 100644 --- a/src/components/entry/entry-create-view.tsx +++ b/src/components/entry/entry-create-view.tsx @@ -17,12 +17,19 @@ type Props = { values: Record; /** フィールドに値を格納する関数 */ setValue: (id: string, value: FieldValue) => void; + /** エントリー作成日(編集画面に使用する) */ + createdAt?: number; }; /** * エントリー作成画面 */ -export function EntryCreateView({ id, values, setValue }: Props) { +export function EntryCreateView({ + id, + values, + setValue, + createdAt = undefined, +}: Props) { const now = Date.now(); const { data: fields } = useLiveQuery(getFieldsQuery(id)); @@ -39,7 +46,7 @@ export function EntryCreateView({ id, values, setValue }: Props) { listStyle("plain"), ]} > -
+
{fields.map((field) => ( - - {grouped.map(({ month, previewEntries }) => ( -
( +
+ + indices.forEach( + async (i) => await deleteEntry(previewEntries[i].id), + ) + } > - {previewEntries.map((previewEntry) => ( - + {previewEntries.map((entry) => ( + ))} -
- ))} - + +
+ ))}
- router.push(`/(journal)/entry/create?id=${id}&name=${journalName}`) + router.push( + `/(journal)/entry/create?journalId=${journalId}&journalName=${journalName}`, + ) } style={styles.fab} > diff --git a/src/components/entry/entry-row.tsx b/src/components/entry/entry-row.tsx index 1aa3ea1..3f34225 100644 --- a/src/components/entry/entry-row.tsx +++ b/src/components/entry/entry-row.tsx @@ -1,15 +1,26 @@ import { PlatformColor } from "react-native"; -import { Button, HStack, Image, Spacer, Text, VStack } from "@expo/ui/swift-ui"; +import { + Button, + ContextMenu, + HStack, + Image, + Spacer, + Text, + VStack, +} from "@expo/ui/swift-ui"; import { font, foregroundStyle, lineLimit } from "@expo/ui/swift-ui/modifiers"; import { useRouter } from "expo-router"; +import { bookmarkEntry, deleteEntry } from "@/db/queries/entries"; import { formatDate } from "@/utils/date"; import { PreviewEntryObj } from "@/utils/entry/preview"; const secondary = foregroundStyle({ type: "hierarchical", style: "secondary" }); type Props = { + /** ジャーナル */ + journalName: string; /** エントリーデータ */ entry: PreviewEntryObj; }; @@ -17,34 +28,68 @@ type Props = { /** * エントリー行 */ -export function EntryRow({ entry }: Props) { +export function EntryRow({ journalName, entry }: Props) { const router = useRouter(); return ( - + + + + + +
{/* フィールド追加ボトムシート */} + setShowSheet((prev) => ({ ...prev, field: v })) + } onAdd={addField} /> {/* アイコン選択ボトムシート */} + setShowSheet((prev) => ({ ...prev, icon: v })) + } selectedIcon={meta.icon} selectedColor={meta.color} onSelectIcon={(icon) => setMeta((prev) => ({ ...prev, icon }))} diff --git a/src/db/queries/entries.ts b/src/db/queries/entries.ts index 191d4ba..7a9fb3d 100644 --- a/src/db/queries/entries.ts +++ b/src/db/queries/entries.ts @@ -1,3 +1,5 @@ +import { and, eq } from "drizzle-orm"; + import { db } from "@/db/client"; import { entries, EntryObj, EntryValueObj, entryValues } from "../schemas"; @@ -48,6 +50,63 @@ export const storeEntry = async ( }); }; +/** + * エントリーのフィールド値を更新する + * @param entryId エントリーID + * @param values 更新するフィールド値一覧 + */ +export const updateEntryValues = async ( + entryId: string, + values: { fieldId: string; value: string | null }[], +): Promise => { + await db.transaction(async (tx) => { + for (const { fieldId, value } of values) { + await tx + .update(entryValues) + .set({ value }) + .where( + and( + eq(entryValues.entryId, entryId), + eq(entryValues.fieldId, fieldId), + ), + ); + } + await tx + .update(entries) + .set({ updatedAt: Date.now() }) + .where(eq(entries.id, entryId)); + }); +}; + +/** + * エントリーをブックマーク登録・解除するクエリ + * @param entryId エントリーID + * @param bookmark ブックマークフラグ + */ +export const bookmarkEntry = async (entryId: string, bookmark: boolean) => { + await db.update(entries).set({ bookmark }).where(eq(entries.id, entryId)); +}; + +/** + * エントリー詳細を削除するクエリ + * @param entryId エントリーID + */ +export const deleteEntry = async (entryId: string) => { + await db.delete(entries).where(eq(entries.id, entryId)); +}; + +/** + * 全てのエントリーを削除するクエリ + * @param journalId ジャーナルID + */ +export const deleteAllEntries = async (journalId?: string) => { + if (journalId) { + await db.delete(entries).where(eq(entries.journalId, journalId)); + } else { + await db.delete(entries); + } +}; + /** エントリー詳細の型 */ export type EntryDetailObj = Awaited< ReturnType diff --git a/src/db/queries/fields.ts b/src/db/queries/fields.ts index af5e3ec..99b2450 100644 --- a/src/db/queries/fields.ts +++ b/src/db/queries/fields.ts @@ -7,4 +7,5 @@ import { db } from "../client"; export const getFieldsQuery = (journalId: string) => db.query.fields.findMany({ where: (fields, { eq }) => eq(fields.journalId, journalId), + orderBy: (fields, { asc }) => [asc(fields.sortOrder)], }); diff --git a/src/db/queries/journals.ts b/src/db/queries/journals.ts index 47348c4..a783aba 100644 --- a/src/db/queries/journals.ts +++ b/src/db/queries/journals.ts @@ -1,4 +1,4 @@ -import { sql } from "drizzle-orm"; +import { and, eq, notInArray, sql } from "drizzle-orm"; import { db } from "@/db/client"; import { entries, fields, JournalObj, journals } from "@/db/schemas"; @@ -10,12 +10,24 @@ import { FieldWithSortObj } from "@/utils/journal/use-journal-field"; export const getJournalsQuery = db.query.journals.findMany({ extras: { entryCount: - sql`(select count(*) from ${entries} where ${entries.journalId} = ${journals.id})`.as( + sql`(select count(*) from ${entries} where ${entries.journalId} = journals.id)`.as( "entry_count", ), }, }); +/** + * ジャーナル詳細を取得するクエリ + * @param journalId エントリーID + */ +export const getJournalDetailQuery = (journalId: string) => + db.query.journals.findFirst({ + where: (journals, { eq }) => eq(journals.id, journalId), + with: { + fields: true, + }, + }); + /** * ジャーナルをフィールドと共に作成するクエリを実行 * @param newJournal ジャーナルのメタ情報 @@ -38,5 +50,79 @@ export const storeJournal = async ( }); }; +/** + * ジャーナルのメタ情報とフィールドラベル・順序を更新する + * @param journalId ジャーナルID + * @param meta 更新するメタ情報 + * @param fieldUpdates 更新するフィールド一覧 + */ +export const updateJournal = async ( + journalId: string, + meta: Pick, + fieldUpdates: FieldWithSortObj[], +): Promise => { + const { name, icon, color } = meta; + + await db.transaction(async (tx) => { + await tx + .update(journals) + .set({ + name, + icon, + color, + updatedAt: Date.now(), + }) + .where(eq(journals.id, journalId)); + + // 既存フィールドは更新、新規フィールドは挿入 + if (fieldUpdates.length > 0) { + await tx + .insert(fields) + .values(fieldUpdates.map((f) => ({ ...f, journalId }))) + .onConflictDoUpdate({ + target: fields.id, + set: { + label: sql`excluded.label`, + sortOrder: sql`excluded."sortOrder"`, + }, + }); + + // 新リストに存在しないフィールドを削除 + await tx.delete(fields).where( + and( + eq(fields.journalId, journalId), + notInArray( + fields.id, + fieldUpdates.map((f) => f.id), + ), + ), + ); + } + + // entries.updatedAt を touch して useLiveQuery に変更を伝播させる + await tx + .update(entries) + .set({ updatedAt: Date.now() }) + .where(eq(entries.journalId, journalId)); + }); +}; + +/** + * ジャーナルを削除するクエリ + * @param journalId ジャーナルID + */ +export const deleteJournal = async (journalId: string) => { + await db.delete(journals).where(eq(journals.id, journalId)); +}; + /** ジャーナル一覧の型 */ export type JournalWithCountObj = Awaited[number]; + +/** + * ジャーナルに紐づくフィールド一覧を取得するクエリ(変更検知用) + * @param journalId ジャーナルID + */ +export const getFieldsByJournalQuery = (journalId: string) => + db.query.fields.findMany({ + where: (f, { eq }) => eq(f.journalId, journalId), + }); diff --git a/src/db/schemas/fields.ts b/src/db/schemas/fields.ts index e4c2229..7e4a38b 100644 --- a/src/db/schemas/fields.ts +++ b/src/db/schemas/fields.ts @@ -29,4 +29,4 @@ export const fieldsRelations = relations(fields, ({ one, many }) => ({ entryValues: many(entryValues), })); -export type FieldlObj = typeof fields.$inferInsert; +export type FieldObj = typeof fields.$inferInsert; diff --git a/src/utils/entry/consts.ts b/src/utils/entry/consts.ts new file mode 100644 index 0000000..2039d7c --- /dev/null +++ b/src/utils/entry/consts.ts @@ -0,0 +1,20 @@ +/** + * ソートキー + */ +export type SortKey = + | "dateDesc" + | "dateAsc" + | "titleAsc" + | "titleDesc" + | "bookmark"; + +/** + * ソートラベル + */ +export const SORT_LABELS: Record = { + dateDesc: "Newest First", + dateAsc: "Oldest First", + titleAsc: "Title (A→Z)", + titleDesc: "Title (Z→A)", + bookmark: "Bookmarked", +}; diff --git a/src/utils/entry/entry-form.ts b/src/utils/entry/entry-form.ts new file mode 100644 index 0000000..9592fc8 --- /dev/null +++ b/src/utils/entry/entry-form.ts @@ -0,0 +1,28 @@ +import { EntryDetailObj } from "@/db/queries/entries"; +import { FieldObj } from "@/db/schemas"; + +import { deserializeValue, FieldValue } from "./use-entry"; + +/** + * エントリー詳細からフォーム用のフィールド一覧と初期値を導出する + * @param entry エントリー詳細 + */ +export const buildEntryFormData = ( + entry: EntryDetailObj, +): { + fields: FieldObj[]; + initialValues: Record; +} => { + const fields = [...entry.values] + .sort((a, b) => a.field.sortOrder - b.field.sortOrder) + .map((v) => v.field); + + const initialValues = Object.fromEntries( + entry.values.map((v) => [ + v.fieldId, + deserializeValue(v.value, v.field.type), + ]), + ); + + return { fields, initialValues }; +}; diff --git a/src/utils/entry/preview.ts b/src/utils/entry/preview.ts index c50b935..5e631f5 100644 --- a/src/utils/entry/preview.ts +++ b/src/utils/entry/preview.ts @@ -1,8 +1,8 @@ -import { SortKey } from "@/app/(journal)/[id]"; import { FieldType } from "@/core/constants"; import { EntryDetailObj } from "@/db/queries/entries"; import { formatDate, formatTime, formatYearMonth } from "../date"; +import { SortKey } from "./consts"; export type PreviewEntryObj = { /** エントリー id */ diff --git a/src/utils/entry/use-entry.ts b/src/utils/entry/use-entry.ts index f9dee2a..69bb8d6 100644 --- a/src/utils/entry/use-entry.ts +++ b/src/utils/entry/use-entry.ts @@ -3,8 +3,8 @@ import { useRef } from "react"; import * as Crypto from "expo-crypto"; import type { FieldType } from "@/core/constants"; -import { storeEntry } from "@/db/queries/entries"; -import { EntryObj, EntryValueObj, FieldlObj } from "@/db/schemas"; +import { storeEntry, updateEntryValues } from "@/db/queries/entries"; +import { EntryObj, EntryValueObj, FieldObj } from "@/db/schemas"; export type FieldValue = string | number | boolean | Date | null; @@ -17,16 +17,46 @@ const getDefaultValue = (type: FieldType): FieldValue => { case "text": case "longText": return ""; - case "number": - return null; 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; } }; @@ -34,21 +64,31 @@ const getDefaultValue = (type: FieldType): FieldValue => { * エントリーフォームの値を管理するフック * ref ベースのため、入力のたびに再レンダリングが発生しない * @param fields ジャーナルに紐づくフィールド一覧 + * @param initialValues 編集時の初期値。undefined=新規作成、null=ロード中、Record=編集準備完了 * @returns * - valuesRef 現在のフィールドの値 * - setValue フィールドに値を格納する関数 * - createEntry 新規エントリーをDBに保存する関数 + * - updateEntry 既存エントリーをDBに更新する関数 */ -export const useEntry = (fields: FieldlObj[] | undefined) => { +export const useEntry = ( + fields: FieldObj[], + initialValues?: Record | null, +) => { const valuesRef = useRef>({}); const initialized = useRef(false); - // fields が初めてロードされたタイミングで一度だけ初期化 if (!initialized.current && fields && fields.length > 0) { - initialized.current = true; - valuesRef.current = Object.fromEntries( - fields.map((f) => [f.id, getDefaultValue(f.type)]), - ); + const isCreateMode = initialValues === undefined; + const isEditModeReady = + initialValues !== null && initialValues !== undefined; + + if (isCreateMode || isEditModeReady) { + initialized.current = true; + valuesRef.current = + initialValues ?? + Object.fromEntries(fields.map((f) => [f.id, getDefaultValue(f.type)])); + } } /** @@ -60,17 +100,6 @@ export const useEntry = (fields: FieldlObj[] | undefined) => { valuesRef.current[fieldId] = value; }; - /** - * 新規エントリーをDBに保存する - * @param journalId ジャーナル id - */ - /** 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に保存する * @param journalId ジャーナル id @@ -100,5 +129,19 @@ export const useEntry = (fields: FieldlObj[] | undefined) => { return newEntry; }; - return { valuesRef, setValue, createEntry }; + /** + * 既存エントリーのフィールド値をDBに更新する + * @param entryId エントリー id + */ + const updateEntry = async (entryId: string): Promise => { + const values = Object.entries(valuesRef.current).map( + ([fieldId, value]) => ({ + fieldId, + value: serializeValue(value), + }), + ); + await updateEntryValues(entryId, values); + }; + + return { valuesRef, setValue, createEntry, updateEntry }; }; diff --git a/src/utils/journal/color.ts b/src/utils/journal/color.ts new file mode 100644 index 0000000..8c4c33e --- /dev/null +++ b/src/utils/journal/color.ts @@ -0,0 +1,25 @@ +/** + * 16進数カラーコードを明るくする + * @param hex 16進数カラーコード + * @param amount 明るさの加算量(デフォルト: 40) + */ +export const lightenColor = (hex: string, amount = 40): string => { + const num = parseInt(hex.replace("#", ""), 16); + const r = Math.min(255, (num >> 16) + amount); + const g = Math.min(255, ((num >> 8) & 0xff) + amount); + const b = Math.min(255, (num & 0xff) + amount); + return "#" + ((r << 16) | (g << 8) | b).toString(16).padStart(6, "0"); +}; + +/** + * 黒・白に近すぎる色を弾く(輝度が極端な色を除外) + * @param hex 16進数カラーコード + */ +export const isValidColor = (hex: string): boolean => { + const num = parseInt(hex.replace("#", ""), 16); + const r = ((num >> 16) & 0xff) / 255; + const g = ((num >> 8) & 0xff) / 255; + const b = (num & 0xff) / 255; + const luminance = 0.2126 * r + 0.7152 * g + 0.0722 * b; + return luminance > 0.05 && luminance < 0.9; +}; diff --git a/src/utils/journal/use-journal-field.ts b/src/utils/journal/use-journal-field.ts index 4faa1a1..8e34fa9 100644 --- a/src/utils/journal/use-journal-field.ts +++ b/src/utils/journal/use-journal-field.ts @@ -4,8 +4,11 @@ import * as Crypto from "expo-crypto"; import { SFSymbol } from "expo-symbols"; import { FIELD_ICONS, FieldType, JOURNAL_ICONS } from "@/core/constants"; -import { storeJournal } from "@/db/queries/journals"; -import { FieldlObj, JournalObj } from "@/db/schemas"; +import { + storeJournal, + updateJournal as updateJournalQuery, +} from "@/db/queries/journals"; +import { FieldObj, JournalObj } from "@/db/schemas"; /** * ジャーナルメタ情報の型 @@ -22,12 +25,12 @@ export type JournalMetaObj = { /** * ジャーナル作成フォームのフィールド下書き(journalId・sortOrder なし) */ -export type FieldDraftObj = Omit; +export type FieldDraftObj = Omit; /** * sortOrder 確定済み・journalId 未割当のフィールド(DB 保存直前) */ -export type FieldWithSortObj = Omit; +export type FieldWithSortObj = Omit; /** * 全 FieldType の配列 @@ -36,6 +39,8 @@ export const FIELD_TYPES = Object.keys(FIELD_ICONS) as FieldType[]; /** * ジャーナルフィールドに関するフック + * @param meta ジャーナルのメタ情報(編集用) + * @param fields 現在のフィールド一覧(編集用) * @returns * - fields 現在のフィールド一覧 * - addField 新規フィールドを追加する関数 @@ -45,18 +50,19 @@ export const FIELD_TYPES = Object.keys(FIELD_ICONS) as FieldType[]; * - meta ジャーナルのメタ情報 * - setMeta ジャーナルのメタ情報をセットする関数 * - createJournal 新規ジャーナルを作成する関数 + * - updateJournal ジャーナルを更新する関数 * - formDisabled フォームが送信可能かどうかのフラグ */ -export const useJournalField = () => { - const [fields, setFields] = useState([]); - - const initialState = { - name: "", - color: "#007AFF", - icon: JOURNAL_ICONS[0], - }; - - const [meta, setMeta] = useState(initialState); +export const useJournalField = (initialData?: { + meta: JournalMetaObj; + fields: FieldDraftObj[]; +}) => { + const [fields, setFields] = useState( + initialData?.fields ?? [], + ); + const [meta, setMeta] = useState( + initialData?.meta ?? { name: "", color: "#007AFF", icon: JOURNAL_ICONS[0] }, + ); /** * 新規フィールドを追加する @@ -130,18 +136,33 @@ export const useJournalField = () => { updatedAt: now, }; - const newFieldsFieldlObj: FieldWithSortObj[] = fields.map((field, i) => ({ + const newFieldsFieldObj: FieldWithSortObj[] = fields.map((field, i) => ({ id: field.id, type: field.type, label: field.label, sortOrder: i, })); - await storeJournal(newJournal, newFieldsFieldlObj); + await storeJournal(newJournal, newFieldsFieldObj); return newJournal; }; + /** + * 既存ジャーナルのメタ情報とフィールドをDBに更新する + * @param journalId ジャーナルID + */ + const updateJournal = async (journalId: string): Promise => { + const fieldUpdates: FieldWithSortObj[] = fields.map((field, i) => ({ + id: field.id, + type: field.type, + label: field.label, + sortOrder: i, + })); + + await updateJournalQuery(journalId, meta, fieldUpdates); + }; + return { fields, addField, @@ -152,6 +173,7 @@ export const useJournalField = () => { meta, setMeta, createJournal, + updateJournal, formDisabled, };