diff --git a/src/app/(journal)/[id].tsx b/src/app/(journal)/[id].tsx index 4d2ecc6..f9a654d 100644 --- a/src/app/(journal)/[id].tsx +++ b/src/app/(journal)/[id].tsx @@ -144,7 +144,14 @@ 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 23c0e8a..4f14fd0 100644 --- a/src/app/(journal)/create.tsx +++ b/src/app/(journal)/create.tsx @@ -24,11 +24,11 @@ export default function JournalCreateScreen() { formDisabled, } = useJournalField(); - const handleCreate = async () => { - const { id, name } = await createJournal(); + const handleJournalCreate = async () => { + const { id: newJournalId, name } = await createJournal(); // replace でスタックせずにジャーナル詳細画面からジャーナル一覧へ戻れるようにする - router.replace(`/(journal)/${id}?name=${name}`); + router.replace(`/(journal)/${newJournalId}?name=${name}`); }; return ( @@ -37,7 +37,7 @@ export default function JournalCreateScreen() { options={{ title: "New Journal", headerRight: () => ( - + + {/* TODO: 空の場合の処理を追加する */} {entry && } ); diff --git a/src/app/(journal)/entry/create.tsx b/src/app/(journal)/entry/create.tsx new file mode 100644 index 0000000..c6e303c --- /dev/null +++ b/src/app/(journal)/entry/create.tsx @@ -0,0 +1,79 @@ +import { + PlatformColor, + Pressable, + Text as RNText, + StyleSheet, + View, +} 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"; +import { useEntry } from "@/utils/entry/use-entry"; + +/** + * エントリー作成 + */ +export default function EntryCreateScreen() { + const router = useRouter(); + + const { id: jounalId, name } = useLocalSearchParams<{ + id: string; + name: string; + }>(); + + const { data: fields } = useLiveQuery(getFieldsQuery(jounalId)); + const { valuesRef, setValue, createEntry } = useEntry(fields); + + const handleEntryCreate = async () => { + const { id: newEntryId } = await createEntry(jounalId); + router.replace(`/(journal)/entry/${newEntryId}`); + }; + + return ( + <> + ( + + New Entry + {name} + + ), + headerRight: () => ( + // エントリー作成では disabled は設定しない + + + + ), + }} + /> + + + ); +} + +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 3c08a97..4de32e6 100644 --- a/src/app/(journal)/index.tsx +++ b/src/app/(journal)/index.tsx @@ -27,6 +27,7 @@ export default function JournalListScreen() { ), }} /> + {/* TODO: 空の場合の処理を追加する */} ); diff --git a/src/app/explore.tsx b/src/app/explore.tsx index 9cae1f5..6cf1c52 100644 --- a/src/app/explore.tsx +++ b/src/app/explore.tsx @@ -1,6 +1,8 @@ import { PlatformColor, View } from "react-native"; -import { Host, Text } from "@expo/ui/swift-ui"; +import { Host } from "@expo/ui/swift-ui"; + +import { AllformList } from "@/components/all-form-list"; export default function ExploreScreen() { return ( @@ -9,7 +11,7 @@ export default function ExploreScreen() { style={{ flex: 1, backgroundColor: PlatformColor("systemBackground") }} useViewportSizeMeasurement > - Explore + ); diff --git a/src/components/all-form-list.tsx b/src/components/all-form-list.tsx new file mode 100644 index 0000000..5b94c78 --- /dev/null +++ b/src/components/all-form-list.tsx @@ -0,0 +1,45 @@ +import { List, Section } from "@expo/ui/swift-ui"; +import { frame, listStyle } from "@expo/ui/swift-ui/modifiers"; + +import { EntryNumber } from "./field/emtry-number"; +import { EntryCheck } from "./field/entry-check"; +import { EntryDate } from "./field/entry-date"; +import { EntryLocation } from "./field/entry-location"; +import { EntryLongText } from "./field/entry-long-text"; +import { EntryMedia } from "./field/entry-media"; +import { EntryText } from "./field/entry-text"; +import { EntryTime } from "./field/entry-time"; + +const edit = false; +/** + * 全フィールドタイプのフォームサンプル + */ +export function AllformList() { + return ( + +
+ + + + + + + + +
+
+ ); +} diff --git a/src/components/entry/entry-create-view.tsx b/src/components/entry/entry-create-view.tsx new file mode 100644 index 0000000..46babc5 --- /dev/null +++ b/src/components/entry/entry-create-view.tsx @@ -0,0 +1,57 @@ +import { PlatformColor, View } from "react-native"; + +import { Host, List, Section } from "@expo/ui/swift-ui"; +import { frame, listStyle } from "@expo/ui/swift-ui/modifiers"; +import { useLiveQuery } from "drizzle-orm/expo-sqlite"; + +import { getFieldsQuery } from "@/db/queries/fields"; +import { formatDate } from "@/utils/date"; +import { FieldValue } from "@/utils/entry/use-entry"; + +import { EntryFieldItem } from "./entry-field-item"; + +type Props = { + /** ジャーナル id */ + id: string; + /** 現在のフィールドの値 */ + values: Record; + /** フィールドに値を格納する関数 */ + setValue: (id: string, value: FieldValue) => void; +}; + +/** + * エントリー作成画面 + */ +export function EntryCreateView({ id, values, setValue }: Props) { + const now = Date.now(); + + const { data: fields } = useLiveQuery(getFieldsQuery(id)); + + return ( + + + +
+ {fields.map((field) => ( + + ))} +
+
+
+
+ ); +} diff --git a/src/components/entry/entry-detail.tsx b/src/components/entry/entry-detail.tsx index 4d002ce..f05d6f3 100644 --- a/src/components/entry/entry-detail.tsx +++ b/src/components/entry/entry-detail.tsx @@ -8,22 +8,19 @@ import { Section, Spacer, Text, - VStack, } from "@expo/ui/swift-ui"; -import { - font, - foregroundStyle, - frame, - listStyle, -} from "@expo/ui/swift-ui/modifiers"; +import { frame, listStyle } from "@expo/ui/swift-ui/modifiers"; import { EntryDetailObj } from "@/db/queries/entries"; import { formatDate } from "@/utils/date"; +import { EntryFieldItem } from "./entry-field-item"; + type Props = { /** エントリーデータ */ entry: EntryDetailObj; }; + /** * エントリー詳細画面 */ @@ -63,20 +60,7 @@ export function EntryDetailView({ entry }: Props) { } > {sorted.map((v) => ( - - - {v.field.label} - - {v.value ?? ""} - + ))} diff --git a/src/components/entry/entry-field-item.tsx b/src/components/entry/entry-field-item.tsx new file mode 100644 index 0000000..b7161df --- /dev/null +++ b/src/components/entry/entry-field-item.tsx @@ -0,0 +1,60 @@ +import { EntryNumber } from "@/components/field/emtry-number"; +import { EntryCheck } from "@/components/field/entry-check"; +import { EntryDate } from "@/components/field/entry-date"; +import { EntryLocation } from "@/components/field/entry-location"; +import { EntryLongText } from "@/components/field/entry-long-text"; +import { EntryMedia } from "@/components/field/entry-media"; +import { EntryText } from "@/components/field/entry-text"; +import { EntryTime } from "@/components/field/entry-time"; +import { FieldType } from "@/core/constants"; +import type { FieldlObj } from "@/db/schemas"; +import { type FieldValue } from "@/utils/entry/use-entry"; + +type Props = { + /** フィールド定義 */ + field: FieldlObj; + /** 現在の値 */ + value: FieldValue; + /** 値変更時のコールバック(edit=true のときのみ必要) */ + setValue?: (id: string, value: FieldValue) => void; + /** 入力かどうか */ + edit?: boolean; +}; + +/** + * フィールドタイプによってコンポーネントを切り替えるコンポーネント + * React Compiler がこの単位で再レンダリングを最適化する + */ +export function EntryFieldItem({ + field, + value, + setValue, + edit = false, +}: Props) { + const shared = { + label: field.label, + onValueChange: setValue + ? (v: T) => setValue(field.id, v) + : undefined, + edit, + }; + + switch (field.type as FieldType) { + case "text": + return ; + case "longText": + return ; + case "number": + return ; + case "check": + return ; + case "date": + return ; + case "time": + return ; + case "media": + return ; + case "location": + return ; + } +} diff --git a/src/components/entry/entry-list-view.tsx b/src/components/entry/entry-list-view.tsx index fe20435..8170b49 100644 --- a/src/components/entry/entry-list-view.tsx +++ b/src/components/entry/entry-list-view.tsx @@ -13,46 +13,19 @@ import { SymbolView } from "expo-symbols"; import type { SortKey } from "@/app/(journal)/[id]"; import { getEntriesQuery } from "@/db/queries/entries"; -import { formatYearMonth } from "@/utils/date"; -import { EntryObj } from "@/utils/journal/use-entry"; +import { + buildPreviewEntry, + groupByMonth, + sortEntries, +} from "@/utils/entry/preview"; import { EntryRow } from "./entry-row"; -function sortEntries(entries: EntryObj[], sortKey: SortKey): EntryObj[] { - switch (sortKey) { - case "dateDesc": - return [...entries].sort((a, b) => b.date.getTime() - a.date.getTime()); - case "dateAsc": - return [...entries].sort((a, b) => a.date.getTime() - b.date.getTime()); - case "titleAsc": - return [...entries].sort((a, b) => a.title.localeCompare(b.title)); - case "titleDesc": - return [...entries].sort((a, b) => b.title.localeCompare(a.title)); - case "bookmark": - return [...entries].sort( - (a, b) => (b.bookmark ? 1 : 0) - (a.bookmark ? 1 : 0), - ); - } -} - -function groupByMonth( - entries: EntryObj[], -): { month: string; entries: EntryObj[] }[] { - const map = new Map(); - for (const entry of entries) { - const key = `${entry.date.getFullYear()}-${entry.date.getMonth()}`; - if (!map.has(key)) map.set(key, []); - map.get(key)!.push(entry); - } - return Array.from(map.entries()).map(([, entries]) => ({ - month: formatYearMonth(entries[0].date), - entries, - })); -} - type Props = { /** ジャーナル id */ id: string; + /** ジャーナル */ + journalName: string; /** 検索テキスト */ searchText?: string; /** ソートキー */ @@ -64,6 +37,7 @@ type Props = { */ export function EntryListView({ id, + journalName, searchText = "", sortKey = "dateDesc", }: Props) { @@ -71,23 +45,20 @@ export function EntryListView({ const { data: dbEntries } = useLiveQuery(getEntriesQuery(id)); - const entries: EntryObj[] = (dbEntries ?? []).map((entry) => ({ - id: entry.id, - date: new Date(entry.createdAt), - title: entry.values[0]?.value ?? "", - preview: entry.values - .slice(1) - .map((v) => v.value) - .join(" "), - bookmark: entry.bookmark, - })); + // エントリープレビュー一覧に変換 + const previewEntries = dbEntries.map(buildPreviewEntry); + // 検索 const filtered = searchText - ? entries.filter( + ? previewEntries.filter( (e) => e.title.includes(searchText) || e.preview.includes(searchText), ) - : entries; + : previewEntries; + + // 並び替え const sorted = sortEntries(filtered, sortKey); + + // 月毎にグループ分け const grouped = groupByMonth(sorted); return ( @@ -98,14 +69,14 @@ export function EntryListView({ > - {grouped.map(({ month, entries }) => ( + {grouped.map(({ month, previewEntries }) => (
- {entries.map((entry) => ( - + {previewEntries.map((previewEntry) => ( + ))}
))} @@ -114,7 +85,9 @@ export function EntryListView({ router.push("/(journal)/create")} + onPress={() => + router.push(`/(journal)/entry/create?id=${id}&name=${journalName}`) + } style={styles.fab} > router.push(`/(journal)/entry/${entry.id}`)} > - - + + {entry.title} - + {entry.preview} - - {formatDate(entry.date)} + + {formatDate(entry.createdAt)} {entry.bookmark && ( diff --git a/src/components/field/emtry-number.tsx b/src/components/field/emtry-number.tsx new file mode 100644 index 0000000..d0988cc --- /dev/null +++ b/src/components/field/emtry-number.tsx @@ -0,0 +1,66 @@ +import { useRef, useState } from "react"; + +import { Text, TextField, type TextFieldRef, VStack } from "@expo/ui/swift-ui"; +import { font, foregroundStyle, frame } from "@expo/ui/swift-ui/modifiers"; + +import { FIELD_LABELS } from "@/core/constants"; + +type Props = { + /** フィールドラベル */ + label: string; + /** デフォルト値 */ + defaultValue?: number; + /** 値変更時のコールバック */ + onValueChange?: (value: number) => void | Promise; + /** 入力かどうか */ + edit?: boolean; +}; + +/** + * 数値フィールド + */ +export function EntryNumber({ + label, + defaultValue, + onValueChange, + edit = false, +}: Props) { + const [number, setNumber] = useState(defaultValue); + const numberFieldRef = useRef(null); + + return ( + + + {label} + + {edit ? ( + { + const cleaned = v + .replace(/[^0-9.]/g, "") + .replace(/^(\d*\.?\d*).*/, "$1"); + if (cleaned !== v) numberFieldRef.current?.setText(cleaned); + const parsed = + cleaned === "" || cleaned === "." ? 0 : parseFloat(cleaned); + setNumber(parsed); + onValueChange?.(parsed); + }} + modifiers={[frame({ maxWidth: 9999 })]} + /> + ) : ( + {String(number)} + )} + + ); +} diff --git a/src/components/field/entry-check.tsx b/src/components/field/entry-check.tsx new file mode 100644 index 0000000..27c0081 --- /dev/null +++ b/src/components/field/entry-check.tsx @@ -0,0 +1,75 @@ +import { useState } from "react"; +import { PlatformColor } from "react-native"; + +import { Button, HStack, Image, Text } from "@expo/ui/swift-ui"; +import { + buttonStyle, + contentShape, + foregroundStyle, + frame, + shapes, +} from "@expo/ui/swift-ui/modifiers"; + +type Props = { + /** フィールドラベル */ + label: string; + /** デフォルト値 */ + defaultValue?: boolean; + /** 値変更時のコールバック */ + onValueChange?: (value: boolean) => void | Promise; + /** 入力かどうか */ + edit?: boolean; +}; + +/** + * チェックフィールド + */ +export function EntryCheck({ + label, + defaultValue = false, + onValueChange, + edit = false, +}: Props) { + const [check, setCheck] = useState(defaultValue); + + const handlePress = () => { + if (!edit) return; + const next = !check; + setCheck(next); + onValueChange?.(next); + }; + + return ( + + ); +} diff --git a/src/components/field/entry-date.tsx b/src/components/field/entry-date.tsx new file mode 100644 index 0000000..a5a70a3 --- /dev/null +++ b/src/components/field/entry-date.tsx @@ -0,0 +1,58 @@ +import { useState } from "react"; + +import { DatePicker, Text, VStack } from "@expo/ui/swift-ui"; +import { font, foregroundStyle } from "@expo/ui/swift-ui/modifiers"; + +import { FIELD_LABELS } from "@/core/constants"; +import { formatDate } from "@/utils/date"; + +type Props = { + /** フィールドラベル */ + label: string; + /** デフォルト値 */ + defaultValue?: Date; + /** 値変更時のコールバック */ + onValueChange?: (value: Date) => void | Promise; + /** 入力かどうか */ + edit?: boolean; +}; + +/** + * 日付フィールド + */ +export function EntryDate({ + label, + defaultValue = new Date(), + onValueChange, + edit = false, +}: Props) { + const [date, setDate] = useState(defaultValue); + + const handleChange = (value: Date) => { + setDate(value); + onValueChange?.(value); + }; + + return ( + + + {label} + + {edit ? ( + + ) : ( + {formatDate(date)} + )} + + ); +} diff --git a/src/components/field/entry-location.tsx b/src/components/field/entry-location.tsx new file mode 100644 index 0000000..308b04b --- /dev/null +++ b/src/components/field/entry-location.tsx @@ -0,0 +1,70 @@ +import { PlatformColor } from "react-native"; + +import { Button, HStack, Image, Spacer, Text, VStack } from "@expo/ui/swift-ui"; +import { + buttonStyle, + font, + foregroundStyle, +} from "@expo/ui/swift-ui/modifiers"; + +type Props = { + /** フィールドラベル */ + label: string; + /** 入力かどうか */ + edit?: boolean; +}; + +/** + * 位置情報フィールド(ダミー実装) + */ +export function EntryLocation({ label, edit = false }: Props) { + return ( + + + {label} + + {edit ? ( + + Media + + + + ) : ( + + + + 未設定 + + + )} + + ); +} diff --git a/src/components/field/entry-long-text.tsx b/src/components/field/entry-long-text.tsx new file mode 100644 index 0000000..0b77094 --- /dev/null +++ b/src/components/field/entry-long-text.tsx @@ -0,0 +1,60 @@ +import { Text, TextField, VStack } from "@expo/ui/swift-ui"; +import { + font, + foregroundStyle, + frame, + lineLimit, +} from "@expo/ui/swift-ui/modifiers"; + +import { FIELD_LABELS } from "@/core/constants"; + +type Props = { + /** フィールドラベル */ + label: string; + /** デフォルト値 */ + defaultValue?: string; + /** 値変更時のコールバック */ + onValueChange?: (value: string) => void | Promise; + /** 入力かどうか */ + edit?: boolean; +}; + +/** + * ロングテキストフィールド + */ +export function EntryLongText({ + label, + defaultValue = "", + onValueChange, + edit = false, +}: Props) { + return ( + + + {label} + + {edit ? ( + + ) : ( + {defaultValue} + )} + + ); +} diff --git a/src/components/field/entry-media.tsx b/src/components/field/entry-media.tsx new file mode 100644 index 0000000..e15f41b --- /dev/null +++ b/src/components/field/entry-media.tsx @@ -0,0 +1,58 @@ +import { PlatformColor } from "react-native"; + +import { Button, HStack, Image, Spacer, Text, VStack } from "@expo/ui/swift-ui"; +import { + buttonStyle, + font, + foregroundStyle, +} from "@expo/ui/swift-ui/modifiers"; + +type Props = { + /** フィールドラベル */ + label: string; + /** 入力かどうか */ + edit?: boolean; +}; + +/** + * メディアフィールド(ダミー実装) + */ +export function EntryMedia({ label, edit = false }: Props) { + return ( + + + {label} + + {edit ? ( + + Media + + + + ) : ( + + )} + + ); +} diff --git a/src/components/field/entry-text.tsx b/src/components/field/entry-text.tsx new file mode 100644 index 0000000..1c2659d --- /dev/null +++ b/src/components/field/entry-text.tsx @@ -0,0 +1,51 @@ +import { Text, TextField, VStack } from "@expo/ui/swift-ui"; +import { font, foregroundStyle, frame } from "@expo/ui/swift-ui/modifiers"; + +import { FIELD_LABELS } from "@/core/constants"; + +type Props = { + /** フィールドラベル */ + label: string; + /** デフォルト値 */ + defaultValue?: string; + /** 値変更時のコールバック */ + onValueChange?: (value: string) => void | Promise; + /** 入力かどうか */ + edit?: boolean; +}; + +/** + * テキストフィールド + */ +export function EntryText({ + label, + defaultValue = "", + onValueChange, + edit = false, +}: Props) { + return ( + + + {label} + + {edit ? ( + + ) : ( + {defaultValue} + )} + + ); +} diff --git a/src/components/field/entry-time.tsx b/src/components/field/entry-time.tsx new file mode 100644 index 0000000..3b612aa --- /dev/null +++ b/src/components/field/entry-time.tsx @@ -0,0 +1,58 @@ +import { useState } from "react"; + +import { DatePicker, Text, VStack } from "@expo/ui/swift-ui"; +import { font, foregroundStyle } from "@expo/ui/swift-ui/modifiers"; + +import { FIELD_LABELS } from "@/core/constants"; +import { formatTime } from "@/utils/date"; + +type Props = { + /** フィールドラベル */ + label: string; + /** デフォルト値 */ + defaultValue?: Date; + /** 値変更時のコールバック */ + onValueChange?: (value: Date) => void | Promise; + /** 入力かどうか */ + edit?: boolean; +}; + +/** + * 時刻フィールド + */ +export function EntryTime({ + label, + defaultValue = new Date(), + onValueChange, + edit = false, +}: Props) { + const [time, setTime] = useState(defaultValue); + + const handleChange = (value: Date) => { + setTime(value); + onValueChange?.(value); + }; + + return ( + + + {label} + + {edit ? ( + + ) : ( + {formatTime(time)} + )} + + ); +} diff --git a/src/components/journal/journal-create-view.tsx b/src/components/journal/journal-create-view.tsx index 34b501b..4f25b50 100644 --- a/src/components/journal/journal-create-view.tsx +++ b/src/components/journal/journal-create-view.tsx @@ -25,7 +25,7 @@ import { import { FIELD_ICONS, FIELD_LABELS, FieldType } from "@/core/constants"; import { JournalMetaObj, - type FieldObj, + type FieldDraftObj, } from "@/utils/journal/use-journal-field"; import { FieldBottomSheet } from "./field-bottom-sheet"; @@ -46,7 +46,7 @@ function isValidColor(hex: string): boolean { type Props = { /** フィールド一覧 */ - fields: FieldObj[]; + fields: FieldDraftObj[]; /** フィールドを追加する関数 */ addField: (type: FieldType) => void; /** フィールドのラベルを更新する関数 */ diff --git a/src/db/queries/entries.ts b/src/db/queries/entries.ts index 3ed79d0..191d4ba 100644 --- a/src/db/queries/entries.ts +++ b/src/db/queries/entries.ts @@ -1,13 +1,19 @@ import { db } from "@/db/client"; +import { entries, EntryObj, EntryValueObj, entryValues } from "../schemas"; + /** - * ジャーナルに紐付いたエントリー一覧を取得するクエリ + * ジャーナルに紐付いたエントリー一覧をフィールドとともに取得するクエリ * @param journalId ジャーナルID */ export const getEntriesQuery = (journalId: string) => db.query.entries.findMany({ where: (entries, { eq }) => eq(entries.journalId, journalId), - with: { values: true }, + with: { + values: { + with: { field: true }, + }, + }, }); /** @@ -24,7 +30,25 @@ export const getEntryDetailQuery = (entryId: string) => }, }); +/** + * エントリーをフィールド値と共に保存する + * @param newEntry エントリー本体 + * @param newValues エントリー値一覧 + */ +export const storeEntry = async ( + newEntry: EntryObj, + newValues: EntryValueObj[], +): Promise => { + await db.transaction(async (tx) => { + await tx.insert(entries).values(newEntry); + + if (newValues.length > 0) { + await tx.insert(entryValues).values(newValues); + } + }); +}; + /** エントリー詳細の型 */ -export type EntryDetailObj = NonNullable< - Awaited> ->; +export type EntryDetailObj = Awaited< + ReturnType +>[number]; diff --git a/src/db/queries/fields.ts b/src/db/queries/fields.ts new file mode 100644 index 0000000..af5e3ec --- /dev/null +++ b/src/db/queries/fields.ts @@ -0,0 +1,10 @@ +import { db } from "../client"; + +/** + * ジャーナルに紐付いたフィールド一覧を取得するクエリ + * @param journalId ジャーナルID + */ +export const getFieldsQuery = (journalId: string) => + db.query.fields.findMany({ + where: (fields, { eq }) => eq(fields.journalId, journalId), + }); diff --git a/src/db/queries/journals.ts b/src/db/queries/journals.ts index 733bdf3..47348c4 100644 --- a/src/db/queries/journals.ts +++ b/src/db/queries/journals.ts @@ -1,7 +1,8 @@ import { sql } from "drizzle-orm"; import { db } from "@/db/client"; -import { entries, FieldlObj, fields, JournalObj, journals } from "@/db/schemas"; +import { entries, fields, JournalObj, journals } from "@/db/schemas"; +import { FieldWithSortObj } from "@/utils/journal/use-journal-field"; /** * ジャーナル一覧を取得するクエリ @@ -17,21 +18,21 @@ export const getJournalsQuery = db.query.journals.findMany({ /** * ジャーナルをフィールドと共に作成するクエリを実行 - * @param journal ジャーナルのメタ情報 + * @param newJournal ジャーナルのメタ情報 * @param newFields フィールド一覧 */ export const storeJournal = async ( - journal: JournalObj, - newFields: FieldlObj[], -) => { + newJournal: JournalObj, + newFields: FieldWithSortObj[], +): Promise => { await db.transaction(async (tx) => { - await tx.insert(journals).values(journal); + await tx.insert(journals).values(newJournal); if (newFields.length > 0) { await tx .insert(fields) .values( - newFields.map((field) => ({ ...field, journalId: journal.id })), + newFields.map((field) => ({ ...field, journalId: newJournal.id })), ); } }); diff --git a/src/db/schemas/entries.ts b/src/db/schemas/entries.ts index 198f776..65b0ec6 100644 --- a/src/db/schemas/entries.ts +++ b/src/db/schemas/entries.ts @@ -53,6 +53,7 @@ export const entriesRelations = relations(entries, ({ one, many }) => ({ fields: [entries.journalId], references: [journals.id], }), + // entry.enry_value ではなく entry.valueで取ると命名が綺麗 values: many(entryValues), })); @@ -66,3 +67,6 @@ export const entryValuesRelations = relations(entryValues, ({ one }) => ({ references: [fields.id], }), })); + +export type EntryObj = typeof entries.$inferSelect; +export type EntryValueObj = typeof entryValues.$inferInsert; diff --git a/src/db/schemas/fields.ts b/src/db/schemas/fields.ts index 77a5466..e4c2229 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 = Omit; +export type FieldlObj = typeof fields.$inferInsert; diff --git a/src/mocks/entries.ts b/src/mocks/entries.ts deleted file mode 100644 index 449e207..0000000 --- a/src/mocks/entries.ts +++ /dev/null @@ -1,79 +0,0 @@ -import { EntryObj } from "@/utils/journal/use-entry"; - -export const ENTRIES: EntryObj[] = [ - { - id: "1", - date: new Date(2026, 4, 5), - title: "朝の振り返り", - preview: - "今日は早起きできた。天気がよくて気分も上々。散歩しながらいろいろ考えた。やっぱり朝の時間は大切だと感じる。", - bookmark: true, - }, - { - id: "2", - date: new Date(2026, 4, 4), - title: "作業記録", - preview: - "新機能の実装を進めた。バグをいくつか修正して、全体的にスムーズに動くようになってきた。", - }, - { - id: "3", - date: new Date(2026, 4, 3), - title: "週末の過ごし方", - preview: - "久しぶりに友人と会った。話が弾んで気づいたら夕方になっていた。やっぱり人と話すのは大切。", - }, - { - id: "4", - date: new Date(2026, 4, 2), - title: "読書メモ", - preview: - "「習慣の力」を読んだ。習慣ループの話が面白かった。自分の習慣を見直すきっかけになりそう。", - bookmark: true, - }, - { - id: "5", - date: new Date(2026, 4, 1), - title: "月初めの目標", - preview: - "5月の目標を書き出した。毎朝30分読書、週3回運動、日記を毎日書くこと。無理のない範囲で続けたい。", - bookmark: true, - }, - { - id: "6", - date: new Date(2026, 3, 30), - title: "夜の散歩", - preview: - "仕事終わりに少し歩いた。夜風が気持ちよくて、頭が整理された気がする。こういう時間が必要だと思う。", - }, - { - id: "7", - date: new Date(2026, 3, 28), - title: "新しいレシピ", - preview: - "チキンカレーを初めて自分で作った。思ったより簡単で、味もよかった。また作ってみたい。", - bookmark: true, - }, - { - id: "8", - date: new Date(2026, 3, 26), - title: "気になったこと", - preview: - "最近、集中力が続かないと感じる。スマホの使い過ぎかもしれない。意識して使用時間を減らしてみよう。", - }, - { - id: "9", - date: new Date(2026, 3, 24), - title: "映画の感想", - preview: - "「PERFECT DAYS」を観た。台詞が少ないのに、主人公の日常がとても豊かに見えた。自分も丁寧に生きたいと思った。", - bookmark: true, - }, - { - id: "10", - date: new Date(2026, 3, 22), - title: "運動の記録", - preview: - "30分ジョギングした。最初はきつかったが、後半は気持ちよくなってきた。継続することが大事だと実感。", - }, -]; diff --git a/src/utils/date.ts b/src/utils/date.ts index c23ff59..217efe6 100644 --- a/src/utils/date.ts +++ b/src/utils/date.ts @@ -12,6 +12,18 @@ export const formatDate = (date: Date | number): string => { }).format(date); }; +/** + * 時刻を "13:05" などロケールに応じた形式でフォーマットする関数 + * @param date フォーマットする日付 + * @returns フォーマットされた時刻文字列 + */ +export const formatTime = (date: Date | number): string => { + return new Intl.DateTimeFormat(undefined, { + hour: "numeric", + minute: "2-digit", + }).format(date); +}; + /** * 年月を "2026年5月" / "May 2026" などロケールに応じた形式でフォーマットする関数 * @param date フォーマットする日付 diff --git a/src/utils/entry/preview.ts b/src/utils/entry/preview.ts new file mode 100644 index 0000000..c50b935 --- /dev/null +++ b/src/utils/entry/preview.ts @@ -0,0 +1,125 @@ +import { SortKey } from "@/app/(journal)/[id]"; +import { FieldType } from "@/core/constants"; +import { EntryDetailObj } from "@/db/queries/entries"; + +import { formatDate, formatTime, formatYearMonth } from "../date"; + +export type PreviewEntryObj = { + /** エントリー id */ + id: string; + /** タイトル */ + title: string; + /** プレビュー */ + preview: string; + /** ブックマーク */ + bookmark: boolean; + /** 日付 */ + createdAt: Date; +}; + +/** + * DB の value 文字列をフィールド型に応じた表示文字列に変換 + * @param value 値 + * @param type フィールドタイプ + */ +export const formatFieldValue = ( + value: string | null, + type: FieldType, +): string => { + if (!value) return ""; + switch (type) { + case "date": + return formatDate(new Date(Number(value))); + case "time": + return formatTime(new Date(Number(value))); + case "check": + return value === "true" ? "✓" : ""; + default: + return value; + } +}; + +/** + * DB のエントリーをプレビュー表示用に変換する + * @param entry エントリー詳細 + */ +export const buildPreviewEntry = (entry: EntryDetailObj): PreviewEntryObj => { + const { id, createdAt, bookmark, values } = entry; + + // フィールド順に並び替え + const sorted = [...values].sort( + (a, b) => a.field.sortOrder - b.field.sortOrder, + ); + + // 1つめのフィールドをタイトルに設定し、値がなければ作成日にフォールバック + const titleFromField = sorted[0] + ? formatFieldValue(sorted[0].value, sorted[0].field.type as FieldType) + : ""; + const title = titleFromField || formatDate(new Date(createdAt)); + + // プレビューは2つ目以降のフィールドを順に並べる + const preview = sorted + .slice(1) + .map((v) => formatFieldValue(v.value, v.field.type)) + .filter(Boolean) + .join(" "); // 全ての値を繋げることで検索できる + + return { + id, + createdAt: new Date(createdAt), + title, + preview, + bookmark, + }; +}; + +/** + * エントリーを並び替える + * @param previewEntries プレビューエントリー一覧 + * @param sortKey ソートキー + */ +export const sortEntries = ( + previewEntries: PreviewEntryObj[], + sortKey: SortKey, +): PreviewEntryObj[] => { + switch (sortKey) { + case "dateDesc": + return [...previewEntries].sort( + (a, b) => b.createdAt.getTime() - a.createdAt.getTime(), + ); + case "dateAsc": + return [...previewEntries].sort( + (a, b) => a.createdAt.getTime() - b.createdAt.getTime(), + ); + case "titleAsc": + return [...previewEntries].sort((a, b) => a.title.localeCompare(b.title)); + case "titleDesc": + return [...previewEntries].sort((a, b) => b.title.localeCompare(a.title)); + case "bookmark": + return [...previewEntries].sort( + (a, b) => (b.bookmark ? 1 : 0) - (a.bookmark ? 1 : 0), + ); + } +}; + +/** + * 月毎にエントリー一覧を分割する関数 + * @param previewEntries プレビューエントリー一覧 + */ +export const groupByMonth = ( + previewEntries: PreviewEntryObj[], +): { month: string; previewEntries: PreviewEntryObj[] }[] => { + const map = new Map(); + + for (const entry of previewEntries) { + const key = `${entry.createdAt.getFullYear()}-${entry.createdAt.getMonth()}`; + + if (!map.has(key)) map.set(key, []); + map.get(key)!.push(entry); + } + + return Array.from(map.entries()).map(([, previewEntries]) => ({ + month: formatYearMonth(previewEntries[0].createdAt), + previewEntries, + })); +}; diff --git a/src/utils/entry/use-entry.ts b/src/utils/entry/use-entry.ts new file mode 100644 index 0000000..f9dee2a --- /dev/null +++ b/src/utils/entry/use-entry.ts @@ -0,0 +1,104 @@ +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"; + +export type FieldValue = string | number | boolean | Date | null; + +/** + * 各エントリー入力のデフォルト値 + * @param type フィールドタイプ + */ +const getDefaultValue = (type: FieldType): FieldValue => { + switch (type) { + case "text": + case "longText": + return ""; + case "number": + return null; + case "check": + return false; + case "date": + case "time": + return new Date(); + case "media": + case "location": + return null; + } +}; + +/** + * エントリーフォームの値を管理するフック + * ref ベースのため、入力のたびに再レンダリングが発生しない + * @param fields ジャーナルに紐づくフィールド一覧 + * @returns + * - valuesRef 現在のフィールドの値 + * - setValue フィールドに値を格納する関数 + * - createEntry 新規エントリーをDBに保存する関数 + */ +export const useEntry = (fields: FieldlObj[] | undefined) => { + 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)]), + ); + } + + /** + * フィールドに値を格納する + * @param id フィールド id + * @param value フィールドの値 + */ + const setValue = (fieldId: string, value: FieldValue): void => { + 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 + */ + const createEntry = async (journalId: string): Promise => { + const now = Date.now(); + + const newEntry: EntryObj = { + id: Crypto.randomUUID(), + journalId, + bookmark: false, + createdAt: now, + updatedAt: now, + }; + + const newValues: EntryValueObj[] = Object.entries(valuesRef.current).map( + ([fieldId, value]) => ({ + id: Crypto.randomUUID(), + entryId: newEntry.id, + fieldId, + value: serializeValue(value), + }), + ); + + await storeEntry(newEntry, newValues); + + return newEntry; + }; + + return { valuesRef, setValue, createEntry }; +}; diff --git a/src/utils/journal/use-entry.ts b/src/utils/journal/use-entry.ts deleted file mode 100644 index d6bd3dc..0000000 --- a/src/utils/journal/use-entry.ts +++ /dev/null @@ -1,7 +0,0 @@ -export type EntryObj = { - id: string; - date: Date; - title: string; - preview: string; - bookmark?: boolean; -}; diff --git a/src/utils/journal/use-journal-field.ts b/src/utils/journal/use-journal-field.ts index a9eab28..4faa1a1 100644 --- a/src/utils/journal/use-journal-field.ts +++ b/src/utils/journal/use-journal-field.ts @@ -11,22 +11,23 @@ import { FieldlObj, JournalObj } from "@/db/schemas"; * ジャーナルメタ情報の型 */ export type JournalMetaObj = { + /** ジャーナル名 */ name: string; + /** ジャーナルカラー */ color: string; + /** ジャーナルアイコン */ icon: SFSymbol; }; /** - * ジャーナルフィールドの1項目 + * ジャーナル作成フォームのフィールド下書き(journalId・sortOrder なし) */ -export type FieldObj = { - /** フィールドID */ - id: string; - /** フィールド種別 */ - type: FieldType; - /** 表示ラベル */ - label: string; -}; +export type FieldDraftObj = Omit; + +/** + * sortOrder 確定済み・journalId 未割当のフィールド(DB 保存直前) + */ +export type FieldWithSortObj = Omit; /** * 全 FieldType の配列 @@ -34,7 +35,7 @@ export type FieldObj = { export const FIELD_TYPES = Object.keys(FIELD_ICONS) as FieldType[]; /** - * ジャーナルフィールドの管理フック + * ジャーナルフィールドに関するフック * @returns * - fields 現在のフィールド一覧 * - addField 新規フィールドを追加する関数 @@ -47,7 +48,7 @@ export const FIELD_TYPES = Object.keys(FIELD_ICONS) as FieldType[]; * - formDisabled フォームが送信可能かどうかのフラグ */ export const useJournalField = () => { - const [fields, setFields] = useState([]); + const [fields, setFields] = useState([]); const initialState = { name: "", @@ -62,7 +63,7 @@ export const useJournalField = () => { * @param type 追加するフィールドの種別 */ const addField = (type: FieldType): void => { - const newField: FieldObj = { + const newField: FieldDraftObj = { id: Crypto.randomUUID(), type, label: "", @@ -129,7 +130,7 @@ export const useJournalField = () => { updatedAt: now, }; - const newFieldsFieldlObj: FieldlObj[] = fields.map((field, i) => ({ + const newFieldsFieldlObj: FieldWithSortObj[] = fields.map((field, i) => ({ id: field.id, type: field.type, label: field.label,