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,