From 5ded8a5aad8de976925585872951d8c910e80368 Mon Sep 17 00:00:00 2001 From: aster <137767097+aster-void@users.noreply.github.com> Date: Fri, 25 Jul 2025 18:12:39 +0900 Subject: [PATCH 01/14] prepare for backend communication: User --- packages/web/src/App.tsx | 66 ++++++------------ packages/web/src/lib/tanstack/keys.ts | 5 ++ packages/web/src/pages/Home.tsx | 5 +- packages/web/src/services/user/UserContext.ts | 27 -------- packages/web/src/services/user/mock_data.ts | 4 +- packages/web/src/services/user/user.ts | 67 ++++++++----------- 6 files changed, 58 insertions(+), 116 deletions(-) delete mode 100644 packages/web/src/services/user/UserContext.ts diff --git a/packages/web/src/App.tsx b/packages/web/src/App.tsx index 4a80a8a..e30cbdc 100644 --- a/packages/web/src/App.tsx +++ b/packages/web/src/App.tsx @@ -1,12 +1,5 @@ -import type { User } from "@packages/models"; import { QueryClientProvider } from "@tanstack/react-query"; -import { useState } from "react"; import { BrowserRouter, Route, Routes } from "react-router-dom"; -import { - UserContext, - type UserContextValue, -} from "@/services/user/UserContext.ts"; -import { UserService } from "@/services/user/user.ts"; import Footer from "./components/Footer/index.tsx"; import Header from "./components/Header/index.tsx"; import { queryClient } from "./lib/tanstack/client.ts"; @@ -33,47 +26,28 @@ export default function App() { */ const themeService = useThemeProvider(); - const userInstance = new UserService(); - const [user, setUserState] = useState( - userInstance.getUser(), - ); - - const setUser = (newUser: User) => { - userInstance.setUser(newUser); - setUserState(newUser); - }; - - const userContextValue: UserContextValue = { - user, - setUser, - }; - return ( - - -
- -
-
- - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - -
-
-
-
-
-
+ +
+ +
+
+ + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + +
+
+
+
+
); } diff --git a/packages/web/src/lib/tanstack/keys.ts b/packages/web/src/lib/tanstack/keys.ts index dec2146..241f3cc 100644 --- a/packages/web/src/lib/tanstack/keys.ts +++ b/packages/web/src/lib/tanstack/keys.ts @@ -3,4 +3,9 @@ export const keys = { _: ["users_sample"], list: ["users_sample", "list"], }, + + users: { + _: ["users"], + currentUser: ["users", "current"], + }, } as const; diff --git a/packages/web/src/pages/Home.tsx b/packages/web/src/pages/Home.tsx index b0384b4..cbfc62b 100644 --- a/packages/web/src/pages/Home.tsx +++ b/packages/web/src/pages/Home.tsx @@ -1,14 +1,15 @@ import type React from "react"; import { Link } from "react-router-dom"; import Logo from "/syllabus_icon.svg"; -// import { useUser } from "@/app/UserContext"; +// import { useCurrentUserQuery, useUpdateUserMutation } from "@/services/user/user.ts"; /** * Home コンポーネントは、ホームページの内容を表示します。 * @returns HTMLを生成するReactコンポーネント。 */ export default function Home(): React.ReactElement { - // const { user, setUser } = useUser(); + // const queryUser = useCurrentUserQuery(); + // const updateUser = useUpdateUserMutation(); return ( <> {/* バックグラウンド画像 */} diff --git a/packages/web/src/services/user/UserContext.ts b/packages/web/src/services/user/UserContext.ts deleted file mode 100644 index 0afc584..0000000 --- a/packages/web/src/services/user/UserContext.ts +++ /dev/null @@ -1,27 +0,0 @@ -import type { User } from "@packages/models"; -import { createContext, useContext } from "react"; - -/** - * UserContext で管理するデータの型定義 - */ -export type UserContextValue = { - user: User | undefined; - setUser: (newUser: User) => void; -}; - -/** - * ユーザー情報を提供するContext - */ -export const UserContext = createContext(null); - -/** - * カスタムフック: useUser - * @returns UserContextValue - ユーザー情報とその更新関数を提供するオブジェクト - */ -export const useUser = () => { - const context = useContext(UserContext); - if (!context) { - throw new Error("useUser must be used within a UserProvider"); - } - return context; -}; diff --git a/packages/web/src/services/user/mock_data.ts b/packages/web/src/services/user/mock_data.ts index 7eb2a8e..799e441 100644 --- a/packages/web/src/services/user/mock_data.ts +++ b/packages/web/src/services/user/mock_data.ts @@ -3,7 +3,7 @@ import type { ClassDataType, User } from "@packages/models"; /** * 講義詳細モーダルの動作確認に利用するサンプルデータ */ -export const SampleClasses: ClassDataType[] = [ +export const sampleClasses: ClassDataType[] = [ { code: "30003", type: "基礎", @@ -184,7 +184,7 @@ export const SampleClasses: ClassDataType[] = [ }, ]; -export const SampleUser: User = { +export const sampleUser: User = { stream: "l1", grade: 1, classNumber: 1, diff --git a/packages/web/src/services/user/user.ts b/packages/web/src/services/user/user.ts index 1b5f6fc..fdb6214 100644 --- a/packages/web/src/services/user/user.ts +++ b/packages/web/src/services/user/user.ts @@ -1,43 +1,32 @@ import type { User } from "@packages/models"; -import { SampleUser } from "@/services/user/mock_data.ts"; -import { env } from "../../lib/env.ts"; +import { useMutation, useQuery } from "@tanstack/react-query"; +import { queryClient } from "@/lib/tanstack/client.ts"; +import { sampleUser } from "@/services/user/mock_data.ts"; +import { keys } from "@/lib/tanstack/keys"; -// TODO: サーバーとデータを送受信する -export class UserService { - private user: User | undefined; +// TODO: use backend to persist data +let currentUser = sampleUser; - constructor() { - if (typeof window !== "undefined") { - if (env.mockData) { - this.user = SampleUser; - } else { - const storedUser = localStorage.getItem("user"); - this.user = storedUser ? JSON.parse(storedUser) : undefined; - } - } else { - this.user = undefined; - } - } +export const useCurrentUserQuery = () => { + return useQuery({ + queryKey: keys.users.currentUser, + queryFn: async () => { + // TODO: fetch actual user + await new Promise((resolve) => setTimeout(resolve, 500)); + return currentUser; + }, + }); +}; - /** - * ユーザー情報を取得します。 - * undefinedの場合は、未登録とみなします。 - * @returns 現在のユーザー情報 - */ - getUser(): User | undefined { - return this.user; - } - - /** - * ユーザー情報を更新します。 - * クライアントサイドのみで localStorage を更新します。 - * @param newUser 新しいユーザー情報 - */ - setUser(newUser: User): void { - this.user = newUser; - - if (typeof window !== "undefined" && !env.mockData) { - localStorage.setItem("user", JSON.stringify(newUser)); - } - } -} +export const useUpdateUserMutation = () => { + return useMutation({ + mutationFn: async (data: User) => { + currentUser = data; + }, + onSuccess: () => { + queryClient.invalidateQueries({ + queryKey: keys.users.currentUser, + }); + }, + }); +}; From 84f457c85dd55fa744ff427a37767820f46df6e9 Mon Sep 17 00:00:00 2001 From: aster <137767097+aster-void@users.noreply.github.com> Date: Sun, 27 Jul 2025 17:00:51 +0900 Subject: [PATCH 02/14] fix many flaws: class_data is no longer imported in web, add Course schema, class_data is validated on test --- .github/workflows/test.yaml | 18 +++ bun.lock | 17 +- packages/models/atoms.ts | 41 +++++ packages/models/mappings.ts | 10 ++ packages/models/models.ts | 145 ++++++++---------- packages/tests/.gitignore | 34 ++++ packages/tests/class_data_parsing.test.ts | 10 ++ packages/tests/index.ts | 1 + packages/tests/package.json | 17 ++ packages/tests/tsconfig.json | 29 ++++ packages/web/package.json | 1 - .../web/src/components/ClassModal/index.tsx | 8 +- .../Sample/ClassModal/SampleClassData.ts | 9 +- .../components/Sample/LoadClasses/page.tsx | 24 --- .../src/components/Sample/Timetable/page.tsx | 6 +- .../components/Timetable/slots/classSlot.tsx | 6 +- .../components/Timetable/timetableFrame.tsx | 10 +- packages/web/src/services/user/mock_data.ts | 4 +- packages/web/src/services/user/user.ts | 2 +- .../web/src/stories/Timetable.stories.tsx | 4 +- 20 files changed, 262 insertions(+), 134 deletions(-) create mode 100644 .github/workflows/test.yaml create mode 100644 packages/models/atoms.ts create mode 100644 packages/models/mappings.ts create mode 100644 packages/tests/.gitignore create mode 100644 packages/tests/class_data_parsing.test.ts create mode 100644 packages/tests/index.ts create mode 100644 packages/tests/package.json create mode 100644 packages/tests/tsconfig.json delete mode 100644 packages/web/src/components/Sample/LoadClasses/page.tsx diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml new file mode 100644 index 0000000..deee0ff --- /dev/null +++ b/.github/workflows/test.yaml @@ -0,0 +1,18 @@ +name: Tests + +on: + push: + branches: + - main + pull_request: + +jobs: + bun: + name: Bun Unit Tests + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@v4 + - uses: oven-sh/setup-bun@v2 + - run: bun install --frozen-lockfile + - run: bun test \ No newline at end of file diff --git a/bun.lock b/bun.lock index 6234008..db0a5be 100644 --- a/bun.lock +++ b/bun.lock @@ -43,13 +43,26 @@ "typescript": "^5.8.3", }, }, + "packages/tests": { + "name": "tests", + "dependencies": { + "@packages/class_data": "workspace:*", + "@packages/models": "workspace:*", + "@sinclair/typebox": "^0.34.38", + }, + "devDependencies": { + "@types/bun": "^1.2.19", + }, + "peerDependencies": { + "typescript": "^5", + }, + }, "packages/web": { "name": "@packages/web", "version": "0.1.0", "dependencies": { "@elysiajs/eden": "^1.3.2", "@headlessui/react": "^2.2.4", - "@packages/class_data": "workspace:*", "@packages/server": "workspace:*", "@tanstack/react-query": "^5.83.0", "react": "^19.1.0", @@ -921,6 +934,8 @@ "tar": ["tar@7.4.3", "", { "dependencies": { "@isaacs/fs-minipass": "^4.0.0", "chownr": "^3.0.0", "minipass": "^7.1.2", "minizlib": "^3.0.1", "mkdirp": "^3.0.1", "yallist": "^5.0.0" } }, "sha512-5S7Va8hKfV7W5U6g3aYxXmlPoZVAwUMy9AOKyF2fVuZa2UD3qZjg578OrLRt8PcNN1PleVaL/5/yYATNL0ICUw=="], + "tests": ["tests@workspace:packages/tests"], + "tiny-invariant": ["tiny-invariant@1.3.3", "", {}, "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg=="], "tinyglobby": ["tinyglobby@0.2.14", "", { "dependencies": { "fdir": "^6.4.4", "picomatch": "^4.0.2" } }, "sha512-tX5e7OM1HnYr2+a2C/4V0htOcSQcoSTH9KgJnVvNm5zm/cyEWKJ7j7YutsH9CxMdtOkkLFy2AHrMci9IM8IPZQ=="], diff --git a/packages/models/atoms.ts b/packages/models/atoms.ts new file mode 100644 index 0000000..fe25003 --- /dev/null +++ b/packages/models/atoms.ts @@ -0,0 +1,41 @@ +import { t } from "elysia"; + +// Stream って何? 教えて有識者 +// Course.importance に使われているよう? +export type Stream = typeof Stream.static; +export const Stream = t.UnionEnum(["s1", "s2", "s3", "l1", "l2", "l3"]); + +export type Day = typeof Day.static; +export const Day = t.UnionEnum(["mon", "tue", "wed", "thu", "fri", "sat"]); + +export type Period = typeof Period.static; +export const Period = t.UnionEnum([1, 2, 3, 4, 5, 6]); + +export type DayPeriod = typeof DayPeriod.static; +export const DayPeriod = t.Object({ + day: Day, + period: Period, +}); + +export type Semester = typeof Semester.static; +export const Semester = t.UnionEnum(["S", "S1", "S2", "A", "A1", "A2"]); + +// 使われていない。 +export type Evaluation = typeof Evaluation.static; +export const Evaluation = t.UnionEnum(["試験", "レポート", "出席", "平常"]); + +export type ClassType = typeof ClassType.static; +export const ClassType = t.UnionEnum([ + "基礎", + "総合", + "要求", + "主題", + "展開", + "L", + "A", + "B", + "C", + "D", + "E", + "F", +]); diff --git a/packages/models/mappings.ts b/packages/models/mappings.ts new file mode 100644 index 0000000..3054b25 --- /dev/null +++ b/packages/models/mappings.ts @@ -0,0 +1,10 @@ +import type { Day } from "./atoms.ts"; + +export const dayMapping: { [key in Day]: string } = { + mon: "月", + tue: "火", + wed: "水", + thu: "木", + fri: "金", + sat: "土", +}; diff --git a/packages/models/models.ts b/packages/models/models.ts index 7c2cd27..2ed546f 100644 --- a/packages/models/models.ts +++ b/packages/models/models.ts @@ -1,93 +1,80 @@ +export * from "./atoms.ts"; +export * from "./mappings.ts"; + import { t } from "elysia"; -// TODO: Elysia のスキーマにする -// サンプル変換: +import { ClassType, DayPeriod, Semester, Stream } from "./atoms.ts"; -export type Stream = typeof Stream.static; -export const Stream = t.Enum({ - s1: "s1", - s2: "s2", - s3: "s3", - l1: "l1", - l2: "l2", - l3: "l3", +export type User = typeof User.static; +export const User = t.Object({ + stream: Stream, + grade: t.Number(), + classNumber: t.Number(), }); -export type User = { - stream: Stream | undefined; - grade: number | undefined; - classNumber: number | undefined; -}; +/** + * 分かりにくいフィールドがたくさんあるので、 + * それぞれの意味の説明を追加する + */ +export type Course = typeof Course.static; +export const Course = t.Object({ + /// 基本情報 + /** 授業コード (例: 30003) */ + code: t.String(), + /** 共通時間割コード (例: CAS-FC1871L1) */ + ccCode: t.String(), + titleJp: t.String(), + titleEn: t.String(), + lecturer: t.String(), + lecturerEn: t.String(), -export type ClassDataType = { - code: string; - type: string; - category: string; - semester: string; - dayPeriod: DayPeriod[] | "集中"; - classroom: string; - titleJp: string; - lecturer: string; - titleEn: string; - lecturerEn: string; - ccCode: string; - credits: number; - detail: string; - schedule: string; - methods: string; - evaluation: string; - notes: string; - class: string; - guidance: string; - guidanceDate: string; - guidancePeriod: string; - time: number; - timeCompensation: string; - targetClass: string[][]; - importance: string[][]; - shortenedCategory: string; - shortenedEvaluation: string; - shortenedClassroom: string; -}; + /// 講義の内容に関する情報 + /** 授業詳細 */ + detail: t.String(), + /** 授業スケジュール */ + schedule: t.String(), + notes: t.String(), // メモ (なんでも良さそう) -export type Day = "mon" | "tue" | "wed" | "thu" | "fri" | "sat"; + /// 授業の開催に関する情報 + semester: Semester, + /** 開催教室 */ + classroom: t.String(), + shortenedClassroom: t.String(), // 短縮表示っぽい + /** 授業の方法? なぜ複数形になった? */ + methods: t.String(), + /** 授業の長さ (分) (例: 90) */ + time: t.Number(), -export type DayPeriod = { - day: Day; - period: 1 | 2 | 3 | 4 | 5 | 6; -}; + /// 講義のカテゴライジングに関する情報 + /** 授業の種別 (例: 基礎) */ + type: ClassType, + /** 授業のカテゴリー (例: 数理科学) */ + category: t.String(), + shortenedCategory: t.String(), + dayPeriod: t.Union([t.Array(DayPeriod), t.Literal("集中")]), -export const dayMapping: { [key in Day]: string } = { - mon: "月", - tue: "火", - wed: "水", - thu: "木", - fri: "金", - sat: "土", -}; + /// 単位に関する情報 + /** 単位数 (例: 2) */ + credits: t.Number(), + /** 評価方法 */ + /** 評価方法 (例: 出席状況、提出物などの状況、研究の達成度などをもとに評価します。) */ + evaluation: t.String(), + shortenedEvaluation: t.String(), -/** - * セメスターを表現する型 - */ -export type Semester = "S" | "S1" | "S2" | "A" | "A1" | "A2"; + /// ガイダンス情報 + guidance: t.String(), + guidanceDate: t.String(), + guidancePeriod: t.String(), -/** - * 評価方法を表現する型 - */ -export type Evaluation = "試験" | "レポート" | "出席" | "平常"; + /// なんなのかよくわからない。わかったら、対応する場所に移動して + timeCompensation: t.String(), + targetClass: t.Array(t.Array(t.String())), + class: t.String(), + importance: t.Array(t.Array(t.String())), +}); /** - * セメスターを表現する型 + * code -> Course */ -export type ClassType = - | "基礎" - | "要求" - | "主題" - | "展開" - | "L" - | "A" - | "B" - | "C" - | "D" - | "E" - | "F"; +export type CourseCollection = typeof CourseCollection.static; +export const CourseCollection = t.Record(t.String(), Course); diff --git a/packages/tests/.gitignore b/packages/tests/.gitignore new file mode 100644 index 0000000..a14702c --- /dev/null +++ b/packages/tests/.gitignore @@ -0,0 +1,34 @@ +# dependencies (bun install) +node_modules + +# output +out +dist +*.tgz + +# code coverage +coverage +*.lcov + +# logs +logs +_.log +report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json + +# dotenv environment variable files +.env +.env.development.local +.env.test.local +.env.production.local +.env.local + +# caches +.eslintcache +.cache +*.tsbuildinfo + +# IntelliJ based IDEs +.idea + +# Finder (MacOS) folder config +.DS_Store diff --git a/packages/tests/class_data_parsing.test.ts b/packages/tests/class_data_parsing.test.ts new file mode 100644 index 0000000..d5c6055 --- /dev/null +++ b/packages/tests/class_data_parsing.test.ts @@ -0,0 +1,10 @@ +import { test } from "bun:test"; +import classData2024A from "@packages/class_data/data/new/2024A.json"; +import { Course } from "@packages/models"; +import { Type } from "@sinclair/typebox"; +import { Value } from "@sinclair/typebox/value"; + +const CourseList = Type.Array(Course); +test("授業リストは Course の配列である", () => { + Value.Assert(CourseList, classData2024A); +}); diff --git a/packages/tests/index.ts b/packages/tests/index.ts new file mode 100644 index 0000000..f67b2c6 --- /dev/null +++ b/packages/tests/index.ts @@ -0,0 +1 @@ +console.log("Hello via Bun!"); \ No newline at end of file diff --git a/packages/tests/package.json b/packages/tests/package.json new file mode 100644 index 0000000..f6a24da --- /dev/null +++ b/packages/tests/package.json @@ -0,0 +1,17 @@ +{ + "name": "tests", + "module": "index.ts", + "type": "module", + "private": true, + "devDependencies": { + "@types/bun": "^1.2.19" + }, + "peerDependencies": { + "typescript": "^5" + }, + "dependencies": { + "@packages/class_data": "workspace:*", + "@packages/models": "workspace:*", + "@sinclair/typebox": "^0.34.38" + } +} diff --git a/packages/tests/tsconfig.json b/packages/tests/tsconfig.json new file mode 100644 index 0000000..bfa0fea --- /dev/null +++ b/packages/tests/tsconfig.json @@ -0,0 +1,29 @@ +{ + "compilerOptions": { + // Environment setup & latest features + "lib": ["ESNext"], + "target": "ESNext", + "module": "Preserve", + "moduleDetection": "force", + "jsx": "react-jsx", + "allowJs": true, + + // Bundler mode + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "noEmit": true, + + // Best practices + "strict": true, + "skipLibCheck": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedIndexedAccess": true, + "noImplicitOverride": true, + + // Some stricter flags (disabled by default) + "noUnusedLocals": false, + "noUnusedParameters": false, + "noPropertyAccessFromIndexSignature": false + } +} diff --git a/packages/web/package.json b/packages/web/package.json index 50cd152..eb331db 100644 --- a/packages/web/package.json +++ b/packages/web/package.json @@ -14,7 +14,6 @@ "dependencies": { "@elysiajs/eden": "^1.3.2", "@headlessui/react": "^2.2.4", - "@packages/class_data": "workspace:*", "@packages/server": "workspace:*", "@tanstack/react-query": "^5.83.0", "react": "^19.1.0", diff --git a/packages/web/src/components/ClassModal/index.tsx b/packages/web/src/components/ClassModal/index.tsx index 4036a48..210d1c0 100644 --- a/packages/web/src/components/ClassModal/index.tsx +++ b/packages/web/src/components/ClassModal/index.tsx @@ -1,9 +1,5 @@ import { Dialog, DialogPanel, DialogTitle } from "@headlessui/react"; -import { - type ClassDataType, - type DayPeriod, - dayMapping, -} from "@packages/models"; +import { type Course, type DayPeriod, dayMapping } from "@packages/models"; import { FiX } from "react-icons/fi"; import Item from "./ClassModalItem.tsx"; @@ -25,7 +21,7 @@ interface ModalProps { /** * 授業の情報 */ - classData: ClassDataType; + classData: Course; } /** diff --git a/packages/web/src/components/Sample/ClassModal/SampleClassData.ts b/packages/web/src/components/Sample/ClassModal/SampleClassData.ts index 4aac40c..9bb056c 100644 --- a/packages/web/src/components/Sample/ClassModal/SampleClassData.ts +++ b/packages/web/src/components/Sample/ClassModal/SampleClassData.ts @@ -1,14 +1,9 @@ -import type { ClassDataType } from "@packages/models"; +import type { Course } from "@packages/models"; /** * 講義詳細モーダルの動作確認に利用するサンプルデータ */ -export const SampleClasses: [ - ClassDataType, - ClassDataType, - ClassDataType, - ClassDataType, -] = [ +export const SampleClasses: [Course, Course, Course, Course] = [ { code: "30003", type: "基礎", diff --git a/packages/web/src/components/Sample/LoadClasses/page.tsx b/packages/web/src/components/Sample/LoadClasses/page.tsx deleted file mode 100644 index d58c390..0000000 --- a/packages/web/src/components/Sample/LoadClasses/page.tsx +++ /dev/null @@ -1,24 +0,0 @@ -/* - * /class_data に、現状のシ楽バスのスクレイピングデータを新シ楽バス用に変換したデータを置いている - * このページで変換後のデータを問題なく利用できることを確認する - */ -import * as classData from "@packages/class_data/data/new/2024A.json"; -import type { ClassDataType } from "@packages/models"; - -/** - * /class_data により生成された授業情報が機能することを確認するコンポーネント - * @returns コンポーネント - */ -const LoadClass: React.FC = () => { - // 読み取り - const classes = classData as ClassDataType[]; - - // 1つ授業を取り出し、再度Jsonに変換 - // ( classes[64] には、全ての授業情報プロパティが入力されている ) - const json = JSON.stringify(classes[64], null, 4); - - // 表示 - return

{json}

; -}; - -export default LoadClass; diff --git a/packages/web/src/components/Sample/Timetable/page.tsx b/packages/web/src/components/Sample/Timetable/page.tsx index c6b796a..fc77032 100644 --- a/packages/web/src/components/Sample/Timetable/page.tsx +++ b/packages/web/src/components/Sample/Timetable/page.tsx @@ -1,4 +1,4 @@ -import type { ClassDataType } from "@packages/models"; +import type { Course } from "@packages/models"; import { useState } from "react"; import ClassModalComponent from "../../ClassModal/index.tsx"; import TimetableFrame from "../../Timetable/timetableFrame.tsx"; @@ -26,8 +26,8 @@ const TimetableComponentSample: React.FC = () => { * @param classes このスロットに表示する関数 * @returns スロット内の要素 */ -function ClassSlotElement(classes: ClassDataType[]) { - const [classForModal, setClassForModal] = useState(); +function ClassSlotElement(classes: Course[]) { + const [classForModal, setClassForModal] = useState(); return ( <> diff --git a/packages/web/src/components/Timetable/slots/classSlot.tsx b/packages/web/src/components/Timetable/slots/classSlot.tsx index 80101cd..098dc9b 100644 --- a/packages/web/src/components/Timetable/slots/classSlot.tsx +++ b/packages/web/src/components/Timetable/slots/classSlot.tsx @@ -1,4 +1,4 @@ -import type { ClassDataType, DayPeriod } from "@packages/models"; +import type { Course, DayPeriod } from "@packages/models"; import type React from "react"; import { SlotDiv, type slotProps } from "./slot.tsx"; @@ -8,9 +8,9 @@ import { SlotDiv, type slotProps } from "./slot.tsx"; interface classProps extends slotProps { day_period: DayPeriod | "集中"; // 曜限 hasSaturday: boolean; // 土曜日表示か否か - classes: ClassDataType[]; // このスロットに表示したいクラス + classes: Course[]; // このスロットに表示したいクラス isIntensiveClass: boolean; // このスロットが集中講義か否か - classSlotElement: (classes: ClassDataType[]) => React.ReactNode; //講義スロット内に配置する要素 + classSlotElement: (classes: Course[]) => React.ReactNode; //講義スロット内に配置する要素 } /** diff --git a/packages/web/src/components/Timetable/timetableFrame.tsx b/packages/web/src/components/Timetable/timetableFrame.tsx index fa78b0e..9ad9066 100644 --- a/packages/web/src/components/Timetable/timetableFrame.tsx +++ b/packages/web/src/components/Timetable/timetableFrame.tsx @@ -9,7 +9,7 @@ * 使用例: components/Sample/Timetable/page */ -import type { ClassDataType, Day, DayPeriod } from "@packages/models"; +import type { Course, Day, DayPeriod } from "@packages/models"; import type React from "react"; import { type ReactElement, useEffect, useState } from "react"; import { SampleClasses } from "../Sample/ClassModal/SampleClassData.ts"; @@ -28,7 +28,7 @@ interface TimetableProps { // 講義スロット内のデザイン // このスロットに表示したい講義はこのコンポーネントで解決し // デザインだけ外部(classSlotElementの内容)に任せる - classSlotElement: (classes: ClassDataType[]) => React.ReactNode; + classSlotElement: (classes: Course[]) => React.ReactNode; // 時限ヘッダー内のデザイン // 詳細はclassSlotElementと同じ @@ -44,7 +44,7 @@ interface TimetableProps { * 【さしあたりサンプルの講義を用いる】 * @returns 講義のコレクション */ -async function loadClass(): Promise { +async function loadClass(): Promise { return SampleClasses; } @@ -54,7 +54,7 @@ async function loadClass(): Promise { * @param dayPeriod 検索対象の曜限 * @returns 指定の曜限に開講される授業全て */ -function findClasses(classes: ClassDataType[], dayPeriod: DayPeriod | "集中") { +function findClasses(classes: Course[], dayPeriod: DayPeriod | "集中") { // i番目の講義が、指定の曜限(dayPeriod)に開講されているか否かを判定する関数 const predicate = (i: number) => { // 集中講義が検索されているとき @@ -96,7 +96,7 @@ function findClasses(classes: ClassDataType[], dayPeriod: DayPeriod | "集中") */ const Timetable: React.FC = (props: TimetableProps) => { // 時間割に表示したい講義 - const [classes, setClasses] = useState([]); + const [classes, setClasses] = useState([]); // 時間割表示時、ユーザーが履修している講義を取得 useEffect(() => { diff --git a/packages/web/src/services/user/mock_data.ts b/packages/web/src/services/user/mock_data.ts index 799e441..e8821ce 100644 --- a/packages/web/src/services/user/mock_data.ts +++ b/packages/web/src/services/user/mock_data.ts @@ -1,9 +1,9 @@ -import type { ClassDataType, User } from "@packages/models"; +import type { Course, User } from "@packages/models"; /** * 講義詳細モーダルの動作確認に利用するサンプルデータ */ -export const sampleClasses: ClassDataType[] = [ +export const sampleClasses: Course[] = [ { code: "30003", type: "基礎", diff --git a/packages/web/src/services/user/user.ts b/packages/web/src/services/user/user.ts index fdb6214..7cc16e0 100644 --- a/packages/web/src/services/user/user.ts +++ b/packages/web/src/services/user/user.ts @@ -1,8 +1,8 @@ import type { User } from "@packages/models"; import { useMutation, useQuery } from "@tanstack/react-query"; import { queryClient } from "@/lib/tanstack/client.ts"; -import { sampleUser } from "@/services/user/mock_data.ts"; import { keys } from "@/lib/tanstack/keys"; +import { sampleUser } from "@/services/user/mock_data.ts"; // TODO: use backend to persist data let currentUser = sampleUser; diff --git a/packages/web/src/stories/Timetable.stories.tsx b/packages/web/src/stories/Timetable.stories.tsx index 73cd010..fdab3b9 100644 --- a/packages/web/src/stories/Timetable.stories.tsx +++ b/packages/web/src/stories/Timetable.stories.tsx @@ -1,4 +1,4 @@ -import type { ClassDataType } from "@packages/models"; +import type { Course } from "@packages/models"; import type { Meta, StoryObj } from "@storybook/react"; import Timetable from "@/components/Timetable/timetableFrame"; @@ -16,7 +16,7 @@ export default meta; type Story = StoryObj; // Helper component for class slots -const ClassSlotElement = (classes: ClassDataType[]) => { +const ClassSlotElement = (classes: Course[]) => { return (
{classes.map((c, i) => ( From da91ce63730ec5f9c8a7e63bc17971e47b8ec05b Mon Sep 17 00:00:00 2001 From: aster <137767097+aster-void@users.noreply.github.com> Date: Sun, 27 Jul 2025 18:44:34 +0900 Subject: [PATCH 03/14] implement course fetcher (without backend) --- TODO.md | 1 - bun.lock | 24 +++++++++- packages/models/atoms.ts | 35 +++++++++++++++ packages/models/models.ts | 16 ++++--- packages/web/package.json | 4 +- packages/web/src/lib/async-types.ts | 12 +++++ .../mock_data.ts => lib/mock/mock-data.ts} | 1 + packages/web/src/services/courses/data.ts | 30 +++++++++++++ packages/web/src/services/courses/loader.ts | 45 +++++++++++++++++++ packages/web/src/services/user/user.ts | 2 +- 10 files changed, 161 insertions(+), 9 deletions(-) create mode 100644 packages/web/src/lib/async-types.ts rename packages/web/src/{services/user/mock_data.ts => lib/mock/mock-data.ts} (99%) create mode 100644 packages/web/src/services/courses/data.ts create mode 100644 packages/web/src/services/courses/loader.ts diff --git a/TODO.md b/TODO.md index 11d654e..06ee05d 100644 --- a/TODO.md +++ b/TODO.md @@ -1,4 +1,3 @@ # TODOs -- setup elysia server and RPC - implement build script diff --git a/bun.lock b/bun.lock index db0a5be..9cb3558 100644 --- a/bun.lock +++ b/bun.lock @@ -64,12 +64,14 @@ "@elysiajs/eden": "^1.3.2", "@headlessui/react": "^2.2.4", "@packages/server": "workspace:*", + "@sinclair/typebox": "^0.34.38", "@tanstack/react-query": "^5.83.0", "react": "^19.1.0", "react-dom": "^19.1.0", "react-hook-form": "^7.60.0", "react-icons": "^5.5.0", "react-router-dom": "^7.1.1", + "svelte": "^5.37.0", }, "devDependencies": { "@storybook/addon-essentials": "^8.6.14", @@ -430,6 +432,8 @@ "@storybook/theming": ["@storybook/theming@8.6.14", "", { "peerDependencies": { "storybook": "^8.2.0 || ^8.3.0-0 || ^8.4.0-0 || ^8.5.0-0 || ^8.6.0-0" } }, "sha512-r4y+LsiB37V5hzpQo+BM10PaCsp7YlZ0YcZzQP1OCkPlYXmUAFy2VvDKaFRpD8IeNPKug2u4iFm/laDEbs03dg=="], + "@sveltejs/acorn-typescript": ["@sveltejs/acorn-typescript@1.0.5", "", { "peerDependencies": { "acorn": "^8.9.0" } }, "sha512-IwQk4yfwLdibDlrXVE04jTZYlLnwsTT2PIOQQGNLWfjavGifnk1JD1LcZjZaBTRcxZu2FfPfNLOE04DSu9lqtQ=="], + "@swc/helpers": ["@swc/helpers@0.5.17", "", { "dependencies": { "tslib": "^2.8.0" } }, "sha512-5IKx/Y13RsYd+sauPb2x+U/xZikHjolzfuDgTAl/Tdf3Q8rslRvC19NKDLgAJQ6wsqADk10ntlv08nPFw/gO/A=="], "@tailwindcss/node": ["@tailwindcss/node@4.1.11", "", { "dependencies": { "@ampproject/remapping": "^2.3.0", "enhanced-resolve": "^5.18.1", "jiti": "^2.4.2", "lightningcss": "1.30.1", "magic-string": "^0.30.17", "source-map-js": "^1.2.1", "tailwindcss": "4.1.11" } }, "sha512-yzhzuGRmv5QyU9qLNg4GTlYI6STedBWRE7NjxP45CsFYYq9taI0zJXZBMqIC/c8fViNLhmrbpSFS57EoxUmD6Q=="], @@ -526,7 +530,7 @@ "ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="], - "aria-query": ["aria-query@5.3.0", "", { "dependencies": { "dequal": "^2.0.3" } }, "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A=="], + "aria-query": ["aria-query@5.3.2", "", {}, "sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw=="], "asn1js": ["asn1js@3.0.6", "", { "dependencies": { "pvtsutils": "^1.3.6", "pvutils": "^1.1.3", "tslib": "^2.8.1" } }, "sha512-UOCGPYbl0tv8+006qks/dTgV9ajs97X2p0FAbyS2iyCRrmLSRolDaHdp+v/CLgnzHc3fVB+CwYiUmei7ndFcgA=="], @@ -536,6 +540,8 @@ "available-typed-arrays": ["available-typed-arrays@1.0.7", "", { "dependencies": { "possible-typed-array-names": "^1.0.0" } }, "sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ=="], + "axobject-query": ["axobject-query@4.1.0", "", {}, "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ=="], + "balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="], "better-auth": ["better-auth@1.3.3", "", { "dependencies": { "@better-auth/utils": "0.2.5", "@better-fetch/fetch": "^1.1.18", "@noble/ciphers": "^0.6.0", "@noble/hashes": "^1.8.0", "@simplewebauthn/browser": "^13.0.0", "@simplewebauthn/server": "^13.0.0", "better-call": "^1.0.12", "defu": "^6.1.4", "jose": "^5.9.6", "kysely": "^0.28.1", "nanostores": "^0.11.3", "zod": "^4.0.5" }, "peerDependencies": { "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["react", "react-dom"] }, "sha512-q1aD2nNpGfEI2ckYu+pBjN+23CIRctOpmREkWyJDJdoYW1q9EPs1Xdb+KhFztg2rMmsoUN8I9Xm5mUWMxiWuLw=="], @@ -640,8 +646,12 @@ "escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="], + "esm-env": ["esm-env@1.2.2", "", {}, "sha512-Epxrv+Nr/CaL4ZcFGPJIYLWFom+YeV1DqMLHJoEd9SYRxNbaFruBwfEX/kkHUJf55j2+TUbmDcmuilbP1TmXHA=="], + "esprima": ["esprima@4.0.1", "", { "bin": { "esparse": "./bin/esparse.js", "esvalidate": "./bin/esvalidate.js" } }, "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A=="], + "esrap": ["esrap@2.1.0", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.4.15" } }, "sha512-yzmPNpl7TBbMRC5Lj2JlJZNPml0tzqoqP5B1JXycNUwtqma9AKCO0M2wHrdgsHcy1WRW7S9rJknAMtByg3usgA=="], + "estree-walker": ["estree-walker@2.0.2", "", {}, "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w=="], "esutils": ["esutils@2.0.3", "", {}, "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g=="], @@ -714,6 +724,8 @@ "is-generator-function": ["is-generator-function@1.1.0", "", { "dependencies": { "call-bound": "^1.0.3", "get-proto": "^1.0.0", "has-tostringtag": "^1.0.2", "safe-regex-test": "^1.1.0" } }, "sha512-nPUB5km40q9e8UfN/Zc24eLlzdSf9OfKByBw9CIdw4H1giPMeA0OIJvbchsCu4npfI2QcMVBsGEBHKZ7wLTWmQ=="], + "is-reference": ["is-reference@3.0.3", "", { "dependencies": { "@types/estree": "^1.0.6" } }, "sha512-ixkJoqQvAP88E6wLydLGGqCJsrFUnqoH6HnaczB8XmDH1oaWU+xxdptvikTgaEhtZ53Ky6YXiBuUI2WXLMCwjw=="], + "is-regex": ["is-regex@1.2.1", "", { "dependencies": { "call-bound": "^1.0.2", "gopd": "^1.2.0", "has-tostringtag": "^1.0.2", "hasown": "^2.0.2" } }, "sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g=="], "is-typed-array": ["is-typed-array@1.1.15", "", { "dependencies": { "which-typed-array": "^1.1.16" } }, "sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ=="], @@ -764,6 +776,8 @@ "lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.30.1", "", { "os": "win32", "cpu": "x64" }, "sha512-PVqXh48wh4T53F/1CCu8PIPCxLzWyCnn/9T5W1Jpmdy5h9Cwd+0YQS6/LwhHXSafuc61/xg9Lv5OrCby6a++jg=="], + "locate-character": ["locate-character@3.0.0", "", {}, "sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA=="], + "locate-path": ["locate-path@6.0.0", "", { "dependencies": { "p-locate": "^5.0.0" } }, "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw=="], "lodash": ["lodash@4.17.21", "", {}, "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg=="], @@ -926,6 +940,8 @@ "supports-preserve-symlinks-flag": ["supports-preserve-symlinks-flag@1.0.0", "", {}, "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w=="], + "svelte": ["svelte@5.37.0", "", { "dependencies": { "@ampproject/remapping": "^2.3.0", "@jridgewell/sourcemap-codec": "^1.5.0", "@sveltejs/acorn-typescript": "^1.0.5", "@types/estree": "^1.0.5", "acorn": "^8.12.1", "aria-query": "^5.3.1", "axobject-query": "^4.1.0", "clsx": "^2.1.1", "esm-env": "^1.2.1", "esrap": "^2.1.0", "is-reference": "^3.0.3", "locate-character": "^3.0.0", "magic-string": "^0.30.11", "zimmerframe": "^1.1.2" } }, "sha512-BAHgWdKncZ4F1DVBrkKAvelx2Nv3mR032ca8/yj9Gxf5s9zzK1uGXiZTjCFDvmO2e9KQfcR2lEkVjw+ZxExJow=="], + "tabbable": ["tabbable@6.2.0", "", {}, "sha512-Cat63mxsVJlzYvN51JmVXIgNoUokrIaT2zLclCXjRd8boZ0004U4KCs/sToJ75C6sdlByWxpYnb5Boif1VSFew=="], "tailwindcss": ["tailwindcss@4.1.11", "", {}, "sha512-2E9TBm6MDD/xKYe+dvJZAmg3yxIEDNRc0jwlNyDg/4Fil2QcSLjFKGVff0lAf1jjeaArlG/M75Ey/EYr/OJtBA=="], @@ -1000,6 +1016,8 @@ "yocto-queue": ["yocto-queue@0.1.0", "", {}, "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="], + "zimmerframe": ["zimmerframe@1.1.2", "", {}, "sha512-rAbqEGa8ovJy4pyBxZM70hg4pE6gDgaQ0Sl9M3enG3I0d6H4XSAM3GeNGLKnsBpuijUow064sf7ww1nutC5/3w=="], + "zod": ["zod@4.0.8", "", {}, "sha512-+MSh9cZU9r3QKlHqrgHMTSr3QwMGv4PLfR0M4N/sYWV5/x67HgXEhIGObdBkpnX8G78pTgWnIrBL2lZcNJOtfg=="], "@esbuild-kit/core-utils/esbuild": ["esbuild@0.18.20", "", { "optionalDependencies": { "@esbuild/android-arm": "0.18.20", "@esbuild/android-arm64": "0.18.20", "@esbuild/android-x64": "0.18.20", "@esbuild/darwin-arm64": "0.18.20", "@esbuild/darwin-x64": "0.18.20", "@esbuild/freebsd-arm64": "0.18.20", "@esbuild/freebsd-x64": "0.18.20", "@esbuild/linux-arm": "0.18.20", "@esbuild/linux-arm64": "0.18.20", "@esbuild/linux-ia32": "0.18.20", "@esbuild/linux-loong64": "0.18.20", "@esbuild/linux-mips64el": "0.18.20", "@esbuild/linux-ppc64": "0.18.20", "@esbuild/linux-riscv64": "0.18.20", "@esbuild/linux-s390x": "0.18.20", "@esbuild/linux-x64": "0.18.20", "@esbuild/netbsd-x64": "0.18.20", "@esbuild/openbsd-x64": "0.18.20", "@esbuild/sunos-x64": "0.18.20", "@esbuild/win32-arm64": "0.18.20", "@esbuild/win32-ia32": "0.18.20", "@esbuild/win32-x64": "0.18.20" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-ceqxoedUrcayh7Y7ZX6NdbbDzGROiyVBgC4PriJThBKSVPWnnFHZAkfI1lJT8QFkOwH4qOS2SJkS4wvpGl8BpA=="], @@ -1028,6 +1046,10 @@ "@tailwindcss/oxide-wasm32-wasi/tslib": ["tslib@2.8.1", "", { "bundled": true }, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], + "@testing-library/dom/aria-query": ["aria-query@5.3.0", "", { "dependencies": { "dequal": "^2.0.3" } }, "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A=="], + + "@testing-library/jest-dom/aria-query": ["aria-query@5.3.0", "", { "dependencies": { "dequal": "^2.0.3" } }, "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A=="], + "@testing-library/jest-dom/chalk": ["chalk@3.0.0", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-4D3B6Wf41KOYRFdszmDqMCGq5VV/uMAB273JILmO+3jAlh8X4qDtdtgCR3fxtbLEMzSx22QdhnDcJvu2u1fVwg=="], "@testing-library/jest-dom/dom-accessibility-api": ["dom-accessibility-api@0.6.3", "", {}, "sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w=="], diff --git a/packages/models/atoms.ts b/packages/models/atoms.ts index fe25003..cd6235d 100644 --- a/packages/models/atoms.ts +++ b/packages/models/atoms.ts @@ -5,25 +5,60 @@ import { t } from "elysia"; export type Stream = typeof Stream.static; export const Stream = t.UnionEnum(["s1", "s2", "s3", "l1", "l2", "l3"]); +/** + * 授業コード + * 例: 30003 + * フォーマットが不明なので、 String にしておく。後の調査でフォーマットを探し、 Regex に変更したい。 + */ +export type CourseCode = typeof CourseCode.static; +export const CourseCode = t.String(); + +/** + * 共通科目コード + * 例: + * - CAS-FC1871L1 + * - XAB-CD1001L2 + * 参考: https://www.u-tokyo.ac.jp/ja/students/classes/course-numbering.html + */ +export type CommonSubjectCode = typeof CommonSubjectCode.static; +export const CommonSubjectCode = t.RegExp( + "^[A-Z]{3}-[A-Z]{2}[0-9]{3}[A-Z]{1}$", +); + +/** + * 曜日。 + */ export type Day = typeof Day.static; export const Day = t.UnionEnum(["mon", "tue", "wed", "thu", "fri", "sat"]); export type Period = typeof Period.static; export const Period = t.UnionEnum([1, 2, 3, 4, 5, 6]); +/** + * 曜限。 + */ export type DayPeriod = typeof DayPeriod.static; export const DayPeriod = t.Object({ day: Day, period: Period, }); +/** + * セメスターまたはターム。 + */ export type Semester = typeof Semester.static; export const Semester = t.UnionEnum(["S", "S1", "S2", "A", "A1", "A2"]); // 使われていない。 +/** + * 評価方法。 + */ export type Evaluation = typeof Evaluation.static; export const Evaluation = t.UnionEnum(["試験", "レポート", "出席", "平常"]); +/** + * 単位の種類。 + */ export type ClassType = typeof ClassType.static; export const ClassType = t.UnionEnum([ "基礎", diff --git a/packages/models/models.ts b/packages/models/models.ts index 2ed546f..d768617 100644 --- a/packages/models/models.ts +++ b/packages/models/models.ts @@ -3,13 +3,21 @@ export * from "./mappings.ts"; import { t } from "elysia"; -import { ClassType, DayPeriod, Semester, Stream } from "./atoms.ts"; +import { + ClassType, + CommonSubjectCode, + CourseCode, + DayPeriod, + Semester, + Stream, +} from "./atoms.ts"; export type User = typeof User.static; export const User = t.Object({ stream: Stream, grade: t.Number(), classNumber: t.Number(), + courses: t.Array(CourseCode), }); /** @@ -19,10 +27,8 @@ export const User = t.Object({ export type Course = typeof Course.static; export const Course = t.Object({ /// 基本情報 - /** 授業コード (例: 30003) */ - code: t.String(), - /** 共通時間割コード (例: CAS-FC1871L1) */ - ccCode: t.String(), + code: CourseCode, + ccCode: CommonSubjectCode, titleJp: t.String(), titleEn: t.String(), lecturer: t.String(), diff --git a/packages/web/package.json b/packages/web/package.json index eb331db..c3a1ff9 100644 --- a/packages/web/package.json +++ b/packages/web/package.json @@ -15,12 +15,14 @@ "@elysiajs/eden": "^1.3.2", "@headlessui/react": "^2.2.4", "@packages/server": "workspace:*", + "@sinclair/typebox": "^0.34.38", "@tanstack/react-query": "^5.83.0", "react": "^19.1.0", "react-dom": "^19.1.0", "react-hook-form": "^7.60.0", "react-icons": "^5.5.0", - "react-router-dom": "^7.1.1" + "react-router-dom": "^7.1.1", + "svelte": "^5.37.0" }, "devDependencies": { "@storybook/addon-essentials": "^8.6.14", diff --git a/packages/web/src/lib/async-types.ts b/packages/web/src/lib/async-types.ts new file mode 100644 index 0000000..85ef882 --- /dev/null +++ b/packages/web/src/lib/async-types.ts @@ -0,0 +1,12 @@ +export type AsyncState = + | { + status: "loading"; + } + | { + status: "success"; + data: T; + } + | { + status: "error"; + error: Error; + }; diff --git a/packages/web/src/services/user/mock_data.ts b/packages/web/src/lib/mock/mock-data.ts similarity index 99% rename from packages/web/src/services/user/mock_data.ts rename to packages/web/src/lib/mock/mock-data.ts index e8821ce..926d05a 100644 --- a/packages/web/src/services/user/mock_data.ts +++ b/packages/web/src/lib/mock/mock-data.ts @@ -188,4 +188,5 @@ export const sampleUser: User = { stream: "l1", grade: 1, classNumber: 1, + courses: ["30003", "51320"], }; diff --git a/packages/web/src/services/courses/data.ts b/packages/web/src/services/courses/data.ts new file mode 100644 index 0000000..98ed949 --- /dev/null +++ b/packages/web/src/services/courses/data.ts @@ -0,0 +1,30 @@ +import type { CourseCollection } from "@packages/models"; +import { useEffect, useState } from "react"; +import type { AsyncState } from "@/lib/async-types.ts"; +import { courseStore } from "./loader.ts"; + +export function getCourseData(): Promise { + const { resolve, promise } = Promise.withResolvers(); + const unsubscribe = courseStore.subscribe((result) => { + if (result.status === "success") { + resolve(result.data); + unsubscribe(); + } + }); + return promise; +} + +export function useCourseDataStatus(): AsyncState { + const [state, setState] = useState>({ + status: "loading", + }); + + useEffect(() => { + const unsubscribe = courseStore.subscribe((result) => { + setState(result); + }); + return () => unsubscribe(); + }, []); + + return state; +} diff --git a/packages/web/src/services/courses/loader.ts b/packages/web/src/services/courses/loader.ts new file mode 100644 index 0000000..5dc2def --- /dev/null +++ b/packages/web/src/services/courses/loader.ts @@ -0,0 +1,45 @@ +import { CourseCollection } from "@packages/models"; +import { Value } from "@sinclair/typebox/value"; +import { writable } from "svelte/store"; +import type { AsyncState } from "@/lib/async-types"; +import { sampleClasses } from "@/lib/mock/mock-data.ts"; + +// TODO: implement retrying +// (or use Tanstack Query? like this? +// https://tanstack.com/query/v4/docs/framework/react/guides/prefetching) + +export const courseStore = writable>({ + status: "loading", +}); + +(async () => { + try { + const data = await load(); + const courseCollection = Value.Parse(CourseCollection, data); + courseStore.set({ + status: "success", + data: courseCollection, + }); + } catch (error) { + courseStore.set({ + status: "error", + error: normalizeError(error), + }); + } +})(); + +async function load() { + if (Math.random() > 0.9) { + throw new Error("Failed to load courses: Math.random returned > 0.9"); + } + return new Promise((resolve) => + setTimeout(() => resolve(sampleClasses), 500), + ); +} + +function normalizeError(error: unknown): Error { + if (error instanceof Error) { + return error; + } + return new Error("Unknown error", { cause: error }); +} diff --git a/packages/web/src/services/user/user.ts b/packages/web/src/services/user/user.ts index 7cc16e0..31a962a 100644 --- a/packages/web/src/services/user/user.ts +++ b/packages/web/src/services/user/user.ts @@ -1,8 +1,8 @@ import type { User } from "@packages/models"; import { useMutation, useQuery } from "@tanstack/react-query"; +import { sampleUser } from "@/lib/mock/mock-data"; import { queryClient } from "@/lib/tanstack/client.ts"; import { keys } from "@/lib/tanstack/keys"; -import { sampleUser } from "@/services/user/mock_data.ts"; // TODO: use backend to persist data let currentUser = sampleUser; From 23fb7aef0b3308c9bf450afc661a264a117b39bd Mon Sep 17 00:00:00 2001 From: aster <137767097+aster-void@users.noreply.github.com> Date: Sun, 27 Jul 2025 23:02:05 +0900 Subject: [PATCH 04/14] fetch course from backend --- bun.lock | 3 +- packages/models/atoms.ts | 45 ++++++++++------ packages/models/models.ts | 32 ++++++++---- packages/models/package.json | 3 +- packages/models/transformer/transformer.ts | 9 ++++ packages/server/app.ts | 4 +- packages/server/domain/transform.ts | 15 ++++++ packages/server/lib/utils/dateconv.test.ts | 16 ++++++ packages/server/lib/utils/dateconv.ts | 7 +++ packages/server/package.json | 3 +- packages/server/router/courses.ts | 33 ++++++++++++ packages/tests/class_data_parsing.test.ts | 7 ++- .../src/components/DataLoadingIndicator.tsx | 18 +++++++ packages/web/src/lib/mock/mock-data.ts | 8 +-- packages/web/src/lib/utils/normalize-error.ts | 6 +++ packages/web/src/lib/utils/sleep.ts | 3 ++ packages/web/src/pages/Home.tsx | 5 +- packages/web/src/services/courses/loader.ts | 52 ++++++++++++------- 18 files changed, 213 insertions(+), 56 deletions(-) create mode 100644 packages/models/transformer/transformer.ts create mode 100644 packages/server/domain/transform.ts create mode 100644 packages/server/lib/utils/dateconv.test.ts create mode 100644 packages/server/lib/utils/dateconv.ts create mode 100644 packages/server/router/courses.ts create mode 100644 packages/web/src/components/DataLoadingIndicator.tsx create mode 100644 packages/web/src/lib/utils/normalize-error.ts create mode 100644 packages/web/src/lib/utils/sleep.ts diff --git a/bun.lock b/bun.lock index 9cb3558..1121547 100644 --- a/bun.lock +++ b/bun.lock @@ -29,7 +29,8 @@ "dependencies": { "@elysiajs/cors": "^1.3.3", "@libsql/client": "^0.15.10", - "@sinclair/typebox": "^0.34.37", + "@packages/class_data": "workspace:*", + "@sinclair/typebox": "^0.34.38", "better-auth": "^1.3.1", "drizzle-orm": "^0.44.3", "elysia": "^1.3.5", diff --git a/packages/models/atoms.ts b/packages/models/atoms.ts index cd6235d..6c2a573 100644 --- a/packages/models/atoms.ts +++ b/packages/models/atoms.ts @@ -8,21 +8,32 @@ export const Stream = t.UnionEnum(["s1", "s2", "s3", "l1", "l2", "l3"]); /** * 授業コード * 例: 30003 - * フォーマットが不明なので、 String にしておく。後の調査でフォーマットを探し、 Regex に変更したい。 */ export type CourseCode = typeof CourseCode.static; -export const CourseCode = t.String(); +export const CourseCode = t.RegExp("^\\d{5}$"); /** * 共通科目コード * 例: - * - CAS-FC1871L1 * - XAB-CD1001L2 - * 参考: https://www.u-tokyo.ac.jp/ja/students/classes/course-numbering.html + * - CAS-FC1871L1 + * - CAS-GC1L37S4 + * - CASPG1F40L3 // 絶対入力ミスだが、データにあるので対応しなければならない + * 仕様: https://www.u-tokyo.ac.jp/ja/students/classes/course-numbering.html + * 本当は CommonSubjectCode になるはずだが、公式が勝手に CommonCourseCode と読んでいる */ -export type CommonSubjectCode = typeof CommonSubjectCode.static; -export const CommonSubjectCode = t.RegExp( - "^[A-Z]{3}-[A-Z]{2}[0-9]{3}[A-Z]{1}$", +export type CommonCourseCode = typeof CommonCourseCode.static; +export const CommonCourseCode = t.RegExp( + ` + ^[CFG] ${/* [1] 課程コード */ ""} + (?:LA|ME|EN|LE|SC|AG|EC|AS|ED|PH|GL) ${/* [2] 開講学部・研究科(教育部)コード */ ""} + -? + [A-Z]{2} ${/* [3] 開講学科・専攻等コード */ ""} + [1-7] ${/* [4] レベルコード */ ""} + [0-9a-zA-Z]{3} ${/* [5] 整理番号 */ ""} + [LSEPTZ] ${/* [6] 授業形態コード */ ""} + [123459] ${/* [7] 使用言語コード */ ""} + $`.replaceAll(/\s/g, ""), ); /** @@ -60,17 +71,19 @@ export const Evaluation = t.UnionEnum(["試験", "レポート", "出席", "平 * 単位の種類。 */ export type ClassType = typeof ClassType.static; -export const ClassType = t.UnionEnum([ +export const ClassType = t.UnionEnum(["基礎", "要求", "主題", "総合", "展開"]); + +export type ClassSeries = typeof ClassSeries.static; +export const ClassSeries = t.UnionEnum([ "基礎", - "総合", "要求", "主題", "展開", - "L", - "A", - "B", - "C", - "D", - "E", - "F", + "総合L", + "総合A", + "総合B", + "総合C", + "総合D", + "総合E", + "総合F", ]); diff --git a/packages/models/models.ts b/packages/models/models.ts index d768617..bd12c47 100644 --- a/packages/models/models.ts +++ b/packages/models/models.ts @@ -4,8 +4,9 @@ export * from "./mappings.ts"; import { t } from "elysia"; import { + ClassSeries, ClassType, - CommonSubjectCode, + CommonCourseCode, CourseCode, DayPeriod, Semester, @@ -28,8 +29,10 @@ export type Course = typeof Course.static; export const Course = t.Object({ /// 基本情報 code: CourseCode, - ccCode: CommonSubjectCode, + ccCode: CommonCourseCode, + /** 講義名 (例: 大規模計算) */ titleJp: t.String(), + /** 講義名 (例: Large-Scale Computing) */ titleEn: t.String(), lecturer: t.String(), lecturerEn: t.String(), @@ -37,32 +40,39 @@ export const Course = t.Object({ /// 講義の内容に関する情報 /** 授業詳細 */ detail: t.String(), - /** 授業スケジュール */ + /** 授業スケジュール (内容的な方) */ schedule: t.String(), - notes: t.String(), // メモ (なんでも良さそう) + notes: t.String(), // 備考 /// 授業の開催に関する情報 semester: Semester, - /** 開催教室 */ + /** 開催教室 (例: 駒場5号館 523教室) */ classroom: t.String(), - shortenedClassroom: t.String(), // 短縮表示っぽい - /** 授業の方法? なぜ複数形になった? */ + /** 開催教室の短縮表示 (例: 523) */ + shortenedClassroom: t.String(), + /** + * 授業の方法 + * 例: 講義形式であるが, 担当教員によっては適宜小テストやレポートを課すことがある. 成績評価は主として期末試験によって行なう. + */ methods: t.String(), /** 授業の長さ (分) (例: 90) */ time: t.Number(), + dayPeriod: t.Union([t.Array(DayPeriod), t.Literal("集中")]), /// 講義のカテゴライジングに関する情報 /** 授業の種別 (例: 基礎) */ type: ClassType, /** 授業のカテゴリー (例: 数理科学) */ category: t.String(), - shortenedCategory: t.String(), - dayPeriod: t.Union([t.Array(DayPeriod), t.Literal("集中")]), + /** + * 科目区分+系列 (例: 基礎、総合A) + * 授業カテゴリの短縮表示**じゃない**!!!! + */ + shortenedCategory: ClassSeries, /// 単位に関する情報 /** 単位数 (例: 2) */ credits: t.Number(), - /** 評価方法 */ /** 評価方法 (例: 出席状況、提出物などの状況、研究の達成度などをもとに評価します。) */ evaluation: t.String(), shortenedEvaluation: t.String(), @@ -79,6 +89,8 @@ export const Course = t.Object({ importance: t.Array(t.Array(t.String())), }); +export type CourseList = typeof CourseList.static; +export const CourseList = t.Array(Course); /** * code -> Course */ diff --git a/packages/models/package.json b/packages/models/package.json index 9858896..51b9284 100644 --- a/packages/models/package.json +++ b/packages/models/package.json @@ -3,7 +3,8 @@ "type": "module", "private": true, "exports": { - ".": "./models.ts" + ".": "./models.ts", + "./transformer": "./transformer/transformer.ts" }, "devDependencies": { "@types/bun": "^1.2.18" diff --git a/packages/models/transformer/transformer.ts b/packages/models/transformer/transformer.ts new file mode 100644 index 0000000..19b0593 --- /dev/null +++ b/packages/models/transformer/transformer.ts @@ -0,0 +1,9 @@ +import type { CourseCollection, CourseList } from "@packages/models"; + +export function courseListToCourseCollection(courseList: CourseList) { + const collection: CourseCollection = {}; + for (const course of courseList) { + collection[course.code] = course; + } + return collection; +} diff --git a/packages/server/app.ts b/packages/server/app.ts index 0f1b4bf..0abc504 100644 --- a/packages/server/app.ts +++ b/packages/server/app.ts @@ -1,11 +1,13 @@ import { cors } from "@elysiajs/cors"; import { Elysia } from "elysia"; import { betterAuth } from "./lib/auth.ts"; +import coursesRouter from "./router/courses.ts"; export const app = new Elysia({ prefix: "/api", }) .use(betterAuth) - .use(cors()); + .use(cors()) + .group("/courses", (route) => route.use(coursesRouter)); export type App = typeof app; diff --git a/packages/server/domain/transform.ts b/packages/server/domain/transform.ts new file mode 100644 index 0000000..aac020d --- /dev/null +++ b/packages/server/domain/transform.ts @@ -0,0 +1,15 @@ +import { Course, type CourseCollection } from "@packages/models"; +import { Value } from "@sinclair/typebox/value"; + +export function transformJSONToCourseCollection(coursesJSON: unknown) { + if (!Array.isArray(coursesJSON)) { + throw new Error("Invalid courses JSON: not array"); + } + + const collection: CourseCollection = {}; + for (const rawCourse of coursesJSON) { + const course = Value.Parse(Course, rawCourse); + collection[course.code] = course; + } + return collection; +} diff --git a/packages/server/lib/utils/dateconv.test.ts b/packages/server/lib/utils/dateconv.test.ts new file mode 100644 index 0000000..dbfb3ce --- /dev/null +++ b/packages/server/lib/utils/dateconv.test.ts @@ -0,0 +1,16 @@ +import { describe, expect, it } from "bun:test"; +import { toHttpDate, toUnixTime } from "./dateconv.ts"; + +describe("toHttpDate", () => { + it("should format a date as an HTTP date string", () => { + const date = new Date(Date.UTC(2006, 1, 2, 15, 4, 5)); + expect(toHttpDate(date)).toBe("Mon, 02 Jan 2006 15:04:05 GMT"); + }); +}); + +describe("toUnixTime", () => { + it("should convert a date to a Unix timestamp", () => { + const date = new Date(Date.UTC(1971, 0, 1, 0, 0, 0)); + expect(toUnixTime(date)).toBe(31536000); + }); +}); diff --git a/packages/server/lib/utils/dateconv.ts b/packages/server/lib/utils/dateconv.ts new file mode 100644 index 0000000..c067dd6 --- /dev/null +++ b/packages/server/lib/utils/dateconv.ts @@ -0,0 +1,7 @@ +export function toUnixTime(date: Date) { + return Math.floor(date.getTime() / 1000); +} + +export function toHttpDate(date: Date) { + return date.toUTCString(); +} diff --git a/packages/server/package.json b/packages/server/package.json index 9b156d9..8c42fce 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -20,7 +20,8 @@ "dependencies": { "@elysiajs/cors": "^1.3.3", "@libsql/client": "^0.15.10", - "@sinclair/typebox": "^0.34.37", + "@packages/class_data": "workspace:*", + "@sinclair/typebox": "^0.34.38", "better-auth": "^1.3.1", "drizzle-orm": "^0.44.3", "elysia": "^1.3.5" diff --git a/packages/server/router/courses.ts b/packages/server/router/courses.ts new file mode 100644 index 0000000..26def69 --- /dev/null +++ b/packages/server/router/courses.ts @@ -0,0 +1,33 @@ +import courses2024A from "@packages/class_data/data/new/2024A.json"; +import { CourseList } from "@packages/models"; +import { Value } from "@sinclair/typebox/value"; +import Elysia, { status, t } from "elysia"; +import { toUnixTime } from "../lib/utils/dateconv.ts"; + +const semesterCoursesMap = new Map([ + ["2024A", Value.Parse(CourseList, courses2024A)], +]); + +const router = new Elysia().get( + "/", + ({ query, set }) => { + const courses = semesterCoursesMap.get(query.name); + if (!courses) { + return status(404, `Entry not found: ${query.name}`); + } + + // this never expires? + const validUntil = new Date(); + validUntil.setFullYear(validUntil.getFullYear() + 1); + + set.headers["cache-control"] = `public, expires=${toUnixTime(validUntil)}`; + return courses; + }, + { + query: t.Object({ + name: t.String(), + }), + }, +); + +export default router; diff --git a/packages/tests/class_data_parsing.test.ts b/packages/tests/class_data_parsing.test.ts index d5c6055..1dd62b0 100644 --- a/packages/tests/class_data_parsing.test.ts +++ b/packages/tests/class_data_parsing.test.ts @@ -6,5 +6,10 @@ import { Value } from "@sinclair/typebox/value"; const CourseList = Type.Array(Course); test("授業リストは Course の配列である", () => { - Value.Assert(CourseList, classData2024A); + try { + Value.Parse(CourseList, classData2024A); + } catch (e) { + console.error(e); + throw e; + } }); diff --git a/packages/web/src/components/DataLoadingIndicator.tsx b/packages/web/src/components/DataLoadingIndicator.tsx new file mode 100644 index 0000000..d34d23f --- /dev/null +++ b/packages/web/src/components/DataLoadingIndicator.tsx @@ -0,0 +1,18 @@ +import { useCourseDataStatus } from "@/services/courses/data"; + +export default function DataLoadingIndicator({ + className, +}: { + className?: string; +}) { + const courseData = useCourseDataStatus(); + return ( +
+ {courseData.status === "loading" &&

Loading courses...

} + {courseData.status === "error" &&

Error loading courses

} + {courseData.status === "success" && ( +

Fetched {Object.keys(courseData.data).length} courses

+ )} +
+ ); +} diff --git a/packages/web/src/lib/mock/mock-data.ts b/packages/web/src/lib/mock/mock-data.ts index 926d05a..6ac5635 100644 --- a/packages/web/src/lib/mock/mock-data.ts +++ b/packages/web/src/lib/mock/mock-data.ts @@ -8,6 +8,7 @@ export const sampleClasses: Course[] = [ code: "30003", type: "基礎", category: "数理科学", + shortenedCategory: "基礎", semester: "S1", dayPeriod: [ { day: "mon", period: 1 }, @@ -57,7 +58,6 @@ export const sampleClasses: Course[] = [ [], ], importance: [["l1", "l2", "l3", "s1", "s2", "s3"], []], - shortenedCategory: "基礎", shortenedEvaluation: "試験レポ平常", shortenedClassroom: "523", }, @@ -65,6 +65,7 @@ export const sampleClasses: Course[] = [ code: "40303", type: "基礎", category: "基礎実験", + shortenedCategory: "基礎", semester: "S2", dayPeriod: "集中", classroom: "その他(学内等) シラバス参照", @@ -92,7 +93,6 @@ export const sampleClasses: Course[] = [ timeCompensation: "", targetClass: [[], ["s1_all", "s2_all", "s3_all"]], importance: [["l1", "l2", "l3", "s1", "s2", "s3"], []], - shortenedCategory: "基礎", shortenedEvaluation: "レポ出席", shortenedClassroom: "他(学内等)", }, @@ -100,6 +100,7 @@ export const sampleClasses: Course[] = [ code: "50033", type: "基礎", category: "既修外国語", + shortenedCategory: "基礎", semester: "A", dayPeriod: [ { @@ -134,7 +135,6 @@ export const sampleClasses: Course[] = [ [], ], importance: [["l1", "l2", "l3", "s1", "s2", "s3"], []], - shortenedCategory: "基礎", shortenedEvaluation: "レポ出席平常", shortenedClassroom: "516", }, @@ -142,6 +142,7 @@ export const sampleClasses: Course[] = [ code: "51320", type: "総合", category: "A(思想・芸術)", + shortenedCategory: "総合A", semester: "A", dayPeriod: [ { @@ -178,7 +179,6 @@ export const sampleClasses: Course[] = [ ["l1_all", "l2_all", "l3_all", "s1_all", "s2_all", "s3_all"], ], importance: [[], []], - shortenedCategory: "総合A", shortenedEvaluation: "レポ", shortenedClassroom: "1232", }, diff --git a/packages/web/src/lib/utils/normalize-error.ts b/packages/web/src/lib/utils/normalize-error.ts new file mode 100644 index 0000000..257a55c --- /dev/null +++ b/packages/web/src/lib/utils/normalize-error.ts @@ -0,0 +1,6 @@ +export function normalizeError(error: unknown): Error { + if (error instanceof Error) { + return error; + } + return new Error("Unknown error", { cause: error }); +} diff --git a/packages/web/src/lib/utils/sleep.ts b/packages/web/src/lib/utils/sleep.ts new file mode 100644 index 0000000..421bda0 --- /dev/null +++ b/packages/web/src/lib/utils/sleep.ts @@ -0,0 +1,3 @@ +export function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} diff --git a/packages/web/src/pages/Home.tsx b/packages/web/src/pages/Home.tsx index cbfc62b..2b463ec 100644 --- a/packages/web/src/pages/Home.tsx +++ b/packages/web/src/pages/Home.tsx @@ -1,5 +1,7 @@ import type React from "react"; import { Link } from "react-router-dom"; +import DataLoadingIndicator from "@/components/DataLoadingIndicator"; +import { useCourseDataStatus } from "@/services/courses/data"; import Logo from "/syllabus_icon.svg"; // import { useCurrentUserQuery, useUpdateUserMutation } from "@/services/user/user.ts"; @@ -56,10 +58,11 @@ export default function Home(): React.ReactElement {
{/* はじめるボタン */} -
+
サインインページへ +
diff --git a/packages/web/src/services/courses/loader.ts b/packages/web/src/services/courses/loader.ts index 5dc2def..629a8f4 100644 --- a/packages/web/src/services/courses/loader.ts +++ b/packages/web/src/services/courses/loader.ts @@ -1,13 +1,40 @@ -import { CourseCollection } from "@packages/models"; -import { Value } from "@sinclair/typebox/value"; +import type { CourseCollection, CourseList } from "@packages/models"; +import { courseListToCourseCollection } from "@packages/models/transformer"; import { writable } from "svelte/store"; -import type { AsyncState } from "@/lib/async-types"; +import { api } from "@/lib/api.ts"; +import type { AsyncState } from "@/lib/async-types.ts"; +import { env } from "@/lib/env.ts"; import { sampleClasses } from "@/lib/mock/mock-data.ts"; +import { normalizeError } from "@/lib/utils/normalize-error.ts"; +import { sleep } from "@/lib/utils/sleep.ts"; // TODO: implement retrying // (or use Tanstack Query? like this? // https://tanstack.com/query/v4/docs/framework/react/guides/prefetching) +async function load(): Promise { + if (env.mockData) { + await sleep(500); + if (Math.random() > 0.5) { + throw new Error("Failed to load courses: Math.random returned > 0.5"); + } + return sampleClasses; + } + + // real data + const { data, error } = await api.courses.get({ + $query: { + name: "2024A", + }, + }); + + if (error) { + throw error; + } + + return data; +} + export const courseStore = writable>({ status: "loading", }); @@ -15,7 +42,8 @@ export const courseStore = writable>({ (async () => { try { const data = await load(); - const courseCollection = Value.Parse(CourseCollection, data); + console.log("fetched", data.length, "data"); + const courseCollection = courseListToCourseCollection(data); courseStore.set({ status: "success", data: courseCollection, @@ -27,19 +55,3 @@ export const courseStore = writable>({ }); } })(); - -async function load() { - if (Math.random() > 0.9) { - throw new Error("Failed to load courses: Math.random returned > 0.9"); - } - return new Promise((resolve) => - setTimeout(() => resolve(sampleClasses), 500), - ); -} - -function normalizeError(error: unknown): Error { - if (error instanceof Error) { - return error; - } - return new Error("Unknown error", { cause: error }); -} From 7ca581da12b3f3b24e963574d47793e24218fe62 Mon Sep 17 00:00:00 2001 From: aster <137767097+aster-void@users.noreply.github.com> Date: Sun, 27 Jul 2025 23:11:17 +0900 Subject: [PATCH 05/14] fix dateconv test: correct JavaScript Date month parameter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit JavaScript Date constructor uses 0-based months, so month 1 is February, not January. Changed from month 1 to month 0 to properly test January 2nd, 2006. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- packages/server/lib/utils/dateconv.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/server/lib/utils/dateconv.test.ts b/packages/server/lib/utils/dateconv.test.ts index dbfb3ce..cf81401 100644 --- a/packages/server/lib/utils/dateconv.test.ts +++ b/packages/server/lib/utils/dateconv.test.ts @@ -3,7 +3,7 @@ import { toHttpDate, toUnixTime } from "./dateconv.ts"; describe("toHttpDate", () => { it("should format a date as an HTTP date string", () => { - const date = new Date(Date.UTC(2006, 1, 2, 15, 4, 5)); + const date = new Date(Date.UTC(2006, 0, 2, 15, 4, 5)); expect(toHttpDate(date)).toBe("Mon, 02 Jan 2006 15:04:05 GMT"); }); }); From cb2a300011a76185067cc77d6eb6e63342badc46 Mon Sep 17 00:00:00 2001 From: aster <137767097+aster-void@users.noreply.github.com> Date: Sun, 27 Jul 2025 23:22:13 +0900 Subject: [PATCH 06/14] fix --- packages/tests/index.ts | 1 - packages/tests/package.json | 7 --- .../FilterUI/FilterComponents/ClassType.tsx | 52 +++++++------------ .../web/src/components/FilterUI/FilterUI.tsx | 19 ++++--- packages/web/src/pages/Home.tsx | 1 - 5 files changed, 32 insertions(+), 48 deletions(-) delete mode 100644 packages/tests/index.ts diff --git a/packages/tests/index.ts b/packages/tests/index.ts deleted file mode 100644 index f67b2c6..0000000 --- a/packages/tests/index.ts +++ /dev/null @@ -1 +0,0 @@ -console.log("Hello via Bun!"); \ No newline at end of file diff --git a/packages/tests/package.json b/packages/tests/package.json index f6a24da..fee44df 100644 --- a/packages/tests/package.json +++ b/packages/tests/package.json @@ -1,14 +1,7 @@ { "name": "tests", - "module": "index.ts", "type": "module", "private": true, - "devDependencies": { - "@types/bun": "^1.2.19" - }, - "peerDependencies": { - "typescript": "^5" - }, "dependencies": { "@packages/class_data": "workspace:*", "@packages/models": "workspace:*", diff --git a/packages/web/src/components/FilterUI/FilterComponents/ClassType.tsx b/packages/web/src/components/FilterUI/FilterComponents/ClassType.tsx index 2f6a5cd..dd16970 100644 --- a/packages/web/src/components/FilterUI/FilterComponents/ClassType.tsx +++ b/packages/web/src/components/FilterUI/FilterComponents/ClassType.tsx @@ -1,64 +1,52 @@ -/* - * セメスターフィルターのコンポーネント - */ - -import type { ClassType } from "@packages/models"; -import type React from "react"; +import type { ClassSeries } from "@packages/models"; import { FlagButton } from "../UI/FlagButton.tsx"; -/** - * クラス種別フィルターのプロパティ - */ -interface SemesterProp { - selectedClassTypes?: ClassType[]; - setSelectedClassTypes: (classType: ClassType[]) => void; -} - -const ClassType1: ClassType[] = ["基礎", "要求", "主題", "展開"]; -const ClassType2: ClassType[] = ["L", "A", "B", "C", "D", "E", "F"]; +const ClassType: ClassSeries[] = ["基礎", "要求", "主題", "展開"]; +const GeneralSeries = ["L", "A", "B", "C", "D", "E", "F"] as const; /** - * 種別フィルターのコンポーネント - * @param prop 種別フィルターのプロパティ - * @returns コンポーネント + * 成績種別・系列でフィルターする */ -export const ClassTypeFilter: React.FC = (prop: SemesterProp) => { - const selectedClassTypes = prop.selectedClassTypes ?? []; +export function ClassSeriesFilter(props: { + selectedClassSeries?: ClassSeries[]; + setSelectedClassSeries: (classSeries: ClassSeries[]) => void; +}) { + const selectedClassSeries = props.selectedClassSeries ?? []; // ボタンがクリックされたときの関数 - const onClick = (classType: ClassType) => { - if (selectedClassTypes.includes(classType)) { + const onClick = (classSeries: ClassSeries) => { + if (selectedClassSeries.includes(classSeries)) { // 既に含まれている場合、除外 - prop.setSelectedClassTypes( - selectedClassTypes.filter((c) => c !== classType), + props.setSelectedClassSeries( + selectedClassSeries.filter((c) => c !== classSeries), ); } else { // 含まれていた場合、追加 - prop.setSelectedClassTypes([...selectedClassTypes, classType]); + props.setSelectedClassSeries([...selectedClassSeries, classSeries]); } }; return (
- {ClassType1.map((c) => ( + {ClassType.map((c) => ( onClick(c)} className="col-span-2" /> ))} - {ClassType2.map((c) => ( + {GeneralSeries.map((c) => ( onClick(c)} + isSelected={selectedClassSeries.includes(`総合${c}`)} + onClick={() => onClick(`総合${c}`)} className="aspect-square col-span-1" /> ))}
); -}; +} diff --git a/packages/web/src/components/FilterUI/FilterUI.tsx b/packages/web/src/components/FilterUI/FilterUI.tsx index 5080fc1..c201124 100644 --- a/packages/web/src/components/FilterUI/FilterUI.tsx +++ b/packages/web/src/components/FilterUI/FilterUI.tsx @@ -2,9 +2,14 @@ * 全てのフィルターを表示するコンポーネント */ -import type { ClassType, Evaluation, Semester } from "@packages/models"; +import type { + ClassSeries, + ClassType, + Evaluation, + Semester, +} from "@packages/models"; import { useState } from "react"; -import { ClassTypeFilter } from "./FilterComponents/ClassType.tsx"; +import { ClassSeriesFilter } from "./FilterComponents/ClassType.tsx"; import { EvaluationFilter } from "./FilterComponents/Evaluation.tsx"; import { Freeword } from "./FilterComponents/Freeword.tsx"; import { RegistrationFilter } from "./FilterComponents/RegistrationFilter.tsx"; @@ -19,7 +24,7 @@ type Filter = { semesters?: Semester[]; // セメスター evaluation_included?: Evaluation[]; // 含めたい評価方法 evaluation_excluded?: Evaluation[]; // 除外したい評価方法 - classTypes?: ClassType[]; // 種別 + series?: ClassSeries[]; // 種別・系列 showRegistered?: boolean; // 履修登録済みの授業を表示する showNotRegistered?: boolean; // 未履修の授業を表示する }; @@ -66,10 +71,10 @@ export const FilterUI: React.FC = () => { - - setFilter({ ...filter, classTypes }) + + setFilter({ ...filter, series }) } /> diff --git a/packages/web/src/pages/Home.tsx b/packages/web/src/pages/Home.tsx index 2b463ec..b9992d8 100644 --- a/packages/web/src/pages/Home.tsx +++ b/packages/web/src/pages/Home.tsx @@ -1,7 +1,6 @@ import type React from "react"; import { Link } from "react-router-dom"; import DataLoadingIndicator from "@/components/DataLoadingIndicator"; -import { useCourseDataStatus } from "@/services/courses/data"; import Logo from "/syllabus_icon.svg"; // import { useCurrentUserQuery, useUpdateUserMutation } from "@/services/user/user.ts"; From b0ec6ca0123342319185306b6a474b221e258ea0 Mon Sep 17 00:00:00 2001 From: aster <137767097+aster-void@users.noreply.github.com> Date: Mon, 28 Jul 2025 16:29:46 +0900 Subject: [PATCH 07/14] delete unused file --- packages/server/domain/transform.ts | 15 --------------- 1 file changed, 15 deletions(-) delete mode 100644 packages/server/domain/transform.ts diff --git a/packages/server/domain/transform.ts b/packages/server/domain/transform.ts deleted file mode 100644 index aac020d..0000000 --- a/packages/server/domain/transform.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { Course, type CourseCollection } from "@packages/models"; -import { Value } from "@sinclair/typebox/value"; - -export function transformJSONToCourseCollection(coursesJSON: unknown) { - if (!Array.isArray(coursesJSON)) { - throw new Error("Invalid courses JSON: not array"); - } - - const collection: CourseCollection = {}; - for (const rawCourse of coursesJSON) { - const course = Value.Parse(Course, rawCourse); - collection[course.code] = course; - } - return collection; -} From 14ec2a64550b00efa3bf71d4638e96052e891ab4 Mon Sep 17 00:00:00 2001 From: aster <137767097+aster-void@users.noreply.github.com> Date: Mon, 28 Jul 2025 16:57:27 +0900 Subject: [PATCH 08/14] create story for FilterUI --- .../web/src/components/FilterUI/FilterUI.tsx | 7 ---- .../src/components/FilterUI/Sample/page.tsx | 11 ------- packages/web/src/stories/FilterUI.stories.tsx | 33 +++++++++++++++++++ 3 files changed, 33 insertions(+), 18 deletions(-) delete mode 100644 packages/web/src/components/FilterUI/Sample/page.tsx create mode 100644 packages/web/src/stories/FilterUI.stories.tsx diff --git a/packages/web/src/components/FilterUI/FilterUI.tsx b/packages/web/src/components/FilterUI/FilterUI.tsx index c201124..ed3e741 100644 --- a/packages/web/src/components/FilterUI/FilterUI.tsx +++ b/packages/web/src/components/FilterUI/FilterUI.tsx @@ -16,9 +16,6 @@ import { RegistrationFilter } from "./FilterComponents/RegistrationFilter.tsx"; import { SemestersCheckbox } from "./FilterComponents/Semester.tsx"; import { FilterCard } from "./UI/FilterCard.tsx"; -/** - * フィルタの型定義 - */ type Filter = { isFreewordForSyllabusDetail?: boolean; // フリーワード検索 semesters?: Semester[]; // セメスター @@ -29,10 +26,6 @@ type Filter = { showNotRegistered?: boolean; // 未履修の授業を表示する }; -/** - * フィルタUI - * @returns フィルタUI - */ export const FilterUI: React.FC = () => { // 現在のフィルター const [filter, setFilter] = useState({}); diff --git a/packages/web/src/components/FilterUI/Sample/page.tsx b/packages/web/src/components/FilterUI/Sample/page.tsx deleted file mode 100644 index 9e7a2ad..0000000 --- a/packages/web/src/components/FilterUI/Sample/page.tsx +++ /dev/null @@ -1,11 +0,0 @@ -/* - * FilterUIのサンプルページ - */ - -import { FilterUI } from "../FilterUI.tsx"; - -const FilterUISample: React.FC = () => { - return ; -}; - -export default FilterUISample; diff --git a/packages/web/src/stories/FilterUI.stories.tsx b/packages/web/src/stories/FilterUI.stories.tsx new file mode 100644 index 0000000..3fb29e7 --- /dev/null +++ b/packages/web/src/stories/FilterUI.stories.tsx @@ -0,0 +1,33 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import { FilterUI } from "@/components/FilterUI/FilterUI.tsx"; + +const meta = { + title: "Components/FilterUI", + component: FilterUI, + tags: ["autodocs"], + parameters: { + layout: "centered", + docs: { + description: { + component: `A filter UI component that allows users to filter courses based on various criteria. + Includes filters for free text search, semesters, evaluation methods, class types, and registration status.`, + }, + }, + }, + argTypes: { + // No direct props since FilterUI manages its own state internally + }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +// Default story showing the FilterUI with no initial filters +const Template: Story = { + render: () => , +}; + +export const Default = { + ...Template, + args: {}, +}; From bcd7d0939de651e1428af59a43318e73346d92d0 Mon Sep 17 00:00:00 2001 From: aster <137767097+aster-void@users.noreply.github.com> Date: Mon, 28 Jul 2025 17:02:04 +0900 Subject: [PATCH 09/14] fix db client initialization --- packages/server/db/{index.ts => client.ts} | 3 ++- packages/server/lib/auth.ts | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) rename packages/server/db/{index.ts => client.ts} (50%) diff --git a/packages/server/db/index.ts b/packages/server/db/client.ts similarity index 50% rename from packages/server/db/index.ts rename to packages/server/db/client.ts index 750967d..c30b0d9 100644 --- a/packages/server/db/index.ts +++ b/packages/server/db/client.ts @@ -1,5 +1,6 @@ import { drizzle } from "drizzle-orm/libsql"; +import { env } from "../lib/env.ts"; export const db = drizzle({ - connection: { url: process.env.DATABASE_URL ?? "file:local.db" }, + connection: { url: env.DATABASE_URL }, }); diff --git a/packages/server/lib/auth.ts b/packages/server/lib/auth.ts index d6bcbe8..e2a1426 100644 --- a/packages/server/lib/auth.ts +++ b/packages/server/lib/auth.ts @@ -1,7 +1,7 @@ import { betterAuth } from "better-auth"; import { drizzleAdapter } from "better-auth/adapters/drizzle"; import Elysia from "elysia"; -import { db } from "../db/index.ts"; +import { db } from "../db/client.ts"; import * as schema from "../db/schema.ts"; import { env } from "../lib/env.ts"; From e5d47ae560e9fc4b97684093ddd8ff3fb61cdad0 Mon Sep 17 00:00:00 2001 From: aster <137767097+aster-void@users.noreply.github.com> Date: Mon, 28 Jul 2025 19:09:22 +0900 Subject: [PATCH 10/14] set up actual pages --- bun.lock | 13 +++-- package.json | 4 ++ packages/server/app.ts | 8 ++-- packages/server/db/client.ts | 2 + packages/server/db/schema.ts | 29 ++++++++++- packages/server/lib/auth.ts | 3 ++ packages/server/package.json | 3 +- packages/server/router/courses.ts | 4 +- packages/server/router/users.ts | 27 +++++++++++ packages/server/tsconfig.json | 6 ++- packages/server/types.ts | 7 +++ packages/web/.storybook/preview.tsx | 4 +- packages/web/package.json | 1 + packages/web/src/App.tsx | 32 +++++-------- .../web/src/components/GoogleLoginButton.tsx | 48 +++++++++++++++++++ packages/web/src/lib/api.ts | 2 +- packages/web/src/lib/auth-client.ts | 17 ++++++- packages/web/src/pages/Profile.tsx | 25 ++++++++-- packages/web/src/pages/Setup.tsx | 8 ++++ packages/web/src/pages/SignIn.tsx | 19 ++------ .../web/src/pages/{ => static}/AboutUs.tsx | 10 ++-- .../web/src/pages/{ => static}/Disclaimer.tsx | 0 .../web/src/pages/{Home.tsx => static/LP.tsx} | 9 +--- .../web/src/pages/{ => static}/NotFound.tsx | 0 .../web/src/pages/{ => static}/Notion.tsx | 0 .../{ => static}/how-to-use/HowToUseItem.tsx | 0 .../{ => static}/how-to-use/HowToUseItem2.tsx | 0 .../pages/{ => static}/how-to-use/page.tsx | 0 packages/web/src/services/theme/index.ts | 6 +-- packages/web/src/services/user/user.ts | 29 +++++++---- 30 files changed, 233 insertions(+), 83 deletions(-) create mode 100644 packages/server/router/users.ts create mode 100644 packages/server/types.ts create mode 100644 packages/web/src/components/GoogleLoginButton.tsx create mode 100644 packages/web/src/pages/Setup.tsx rename packages/web/src/pages/{ => static}/AboutUs.tsx (75%) rename packages/web/src/pages/{ => static}/Disclaimer.tsx (100%) rename packages/web/src/pages/{Home.tsx => static/LP.tsx} (84%) rename packages/web/src/pages/{ => static}/NotFound.tsx (100%) rename packages/web/src/pages/{ => static}/Notion.tsx (100%) rename packages/web/src/pages/{ => static}/how-to-use/HowToUseItem.tsx (100%) rename packages/web/src/pages/{ => static}/how-to-use/HowToUseItem2.tsx (100%) rename packages/web/src/pages/{ => static}/how-to-use/page.tsx (100%) diff --git a/bun.lock b/bun.lock index 1121547..871f3f4 100644 --- a/bun.lock +++ b/bun.lock @@ -3,6 +3,10 @@ "workspaces": { "": { "name": "syllabus", + "dependencies": { + "@elysiajs/eden": "^1.3.2", + "elysia": "^1.3.6", + }, "devDependencies": { "@biomejs/biome": "^2.1.1", "concurrently": "^9.2.0", @@ -51,12 +55,6 @@ "@packages/models": "workspace:*", "@sinclair/typebox": "^0.34.38", }, - "devDependencies": { - "@types/bun": "^1.2.19", - }, - "peerDependencies": { - "typescript": "^5", - }, }, "packages/web": { "name": "@packages/web", @@ -64,6 +62,7 @@ "dependencies": { "@elysiajs/eden": "^1.3.2", "@headlessui/react": "^2.2.4", + "@packages/models": "workspace:models", "@packages/server": "workspace:*", "@sinclair/typebox": "^0.34.38", "@tanstack/react-query": "^5.83.0", @@ -629,7 +628,7 @@ "electron-to-chromium": ["electron-to-chromium@1.5.190", "", {}, "sha512-k4McmnB2091YIsdCgkS0fMVMPOJgxl93ltFzaryXqwip1AaxeDqKCGLxkXODDA5Ab/D+tV5EL5+aTx76RvLRxw=="], - "elysia": ["elysia@1.3.5", "", { "dependencies": { "cookie": "^1.0.2", "exact-mirror": "0.1.2", "fast-decode-uri-component": "^1.0.1" }, "optionalDependencies": { "@sinclair/typebox": "^0.34.33", "openapi-types": "^12.1.3" }, "peerDependencies": { "file-type": ">= 20.0.0", "typescript": ">= 5.0.0" } }, "sha512-XVIKXlKFwUT7Sta8GY+wO5reD9I0rqAEtaz1Z71UgJb61csYt8Q3W9al8rtL5RgumuRR8e3DNdzlUN9GkC4KDw=="], + "elysia": ["elysia@1.3.6", "", { "dependencies": { "cookie": "^1.0.2", "exact-mirror": "0.1.2", "fast-decode-uri-component": "^1.0.1" }, "optionalDependencies": { "@sinclair/typebox": "^0.34.33", "openapi-types": "^12.1.3" }, "peerDependencies": { "file-type": ">= 20.0.0", "typescript": ">= 5.0.0" } }, "sha512-NPjbt42KlLzf98JN/3Uvzg3oVFCzEle9gJnRtwUxYGQd61Bm7sE1h8FMqQVdXRCoxmz1T+jhhoB+p1YjDdwEzQ=="], "emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="], diff --git a/package.json b/package.json index 940ce6b..93ed83c 100644 --- a/package.json +++ b/package.json @@ -17,5 +17,9 @@ "devDependencies": { "@biomejs/biome": "^2.1.1", "concurrently": "^9.2.0" + }, + "dependencies": { + "@elysiajs/eden": "^1.3.2", + "elysia": "^1.3.6" } } diff --git a/packages/server/app.ts b/packages/server/app.ts index 0abc504..6a7e3a0 100644 --- a/packages/server/app.ts +++ b/packages/server/app.ts @@ -1,13 +1,15 @@ import { cors } from "@elysiajs/cors"; import { Elysia } from "elysia"; -import { betterAuth } from "./lib/auth.ts"; +import { betterAuth } from "@/lib/auth.ts"; import coursesRouter from "./router/courses.ts"; +import usersRouter from "./router/users.ts"; export const app = new Elysia({ prefix: "/api", }) - .use(betterAuth) .use(cors()) - .group("/courses", (route) => route.use(coursesRouter)); + .use(betterAuth) + .use(coursesRouter) + .use(usersRouter); export type App = typeof app; diff --git a/packages/server/db/client.ts b/packages/server/db/client.ts index c30b0d9..df4328e 100644 --- a/packages/server/db/client.ts +++ b/packages/server/db/client.ts @@ -1,6 +1,8 @@ import { drizzle } from "drizzle-orm/libsql"; import { env } from "../lib/env.ts"; +import * as schema from "./schema.ts"; export const db = drizzle({ connection: { url: env.DATABASE_URL }, + schema, }); diff --git a/packages/server/db/schema.ts b/packages/server/db/schema.ts index 4c1e2d1..a44a06b 100644 --- a/packages/server/db/schema.ts +++ b/packages/server/db/schema.ts @@ -1,6 +1,6 @@ +import { relations } from "drizzle-orm"; import { integer, sqliteTable, text } from "drizzle-orm/sqlite-core"; -// auth export const user = sqliteTable("users", { id: text("id").primaryKey(), name: text("name").notNull(), @@ -16,7 +16,34 @@ export const user = sqliteTable("users", { .$defaultFn(() => /* @__PURE__ */ new Date()) .notNull(), }); +export const userData = sqliteTable("user_data", { + id: text("id").primaryKey(), + userId: text("user_id") + .notNull() + .references(() => user.id, { onDelete: "cascade" }), + stream: text("stream"), + grade: integer("grade"), + classNumber: integer("class_number"), +}); +export const userCourse = sqliteTable("user_courses", { + id: text("id").primaryKey(), + userId: text("user_id") + .notNull() + .references(() => user.id, { onDelete: "cascade" }), + code: text("code").notNull(), +}); +export const userRelations = relations(user, ({ one }) => ({ + courses: one(userCourse, { + fields: [user.id], + references: [userCourse.userId], + }), + data: one(userData, { + fields: [user.id], + references: [userData.userId], + }), +})); +// auth-related tables export const session = sqliteTable("sessions", { id: text("id").primaryKey(), expiresAt: integer("expires_at", { mode: "timestamp" }).notNull(), diff --git a/packages/server/lib/auth.ts b/packages/server/lib/auth.ts index e2a1426..3a59060 100644 --- a/packages/server/lib/auth.ts +++ b/packages/server/lib/auth.ts @@ -17,6 +17,9 @@ export const auth = betterAuth({ }, }, trustedOrigins: [env.PUBLIC_WEB_URL], + advanced: { + cookiePrefix: "x_utcode_syllabus_", + }, }); const betterAuthMacro = new Elysia({ name: "better-auth" }) diff --git a/packages/server/package.json b/packages/server/package.json index 8c42fce..651b8b4 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -4,7 +4,8 @@ "type": "module", "private": true, "exports": { - ".": "./app.ts" + ".": "./app.ts", + "./types": "./types.ts" }, "scripts": { "dev": "bun --env-file=../../.env run --watch ./serve.ts" diff --git a/packages/server/router/courses.ts b/packages/server/router/courses.ts index 26def69..07985c0 100644 --- a/packages/server/router/courses.ts +++ b/packages/server/router/courses.ts @@ -8,7 +8,9 @@ const semesterCoursesMap = new Map([ ["2024A", Value.Parse(CourseList, courses2024A)], ]); -const router = new Elysia().get( +const router = new Elysia({ + prefix: "/courses", +}).get( "/", ({ query, set }) => { const courses = semesterCoursesMap.get(query.name); diff --git a/packages/server/router/users.ts b/packages/server/router/users.ts new file mode 100644 index 0000000..0ea65c9 --- /dev/null +++ b/packages/server/router/users.ts @@ -0,0 +1,27 @@ +import { eq } from "drizzle-orm"; +import Elysia from "elysia"; +import { db } from "@/db/client"; +import { betterAuth } from "@/lib/auth.ts"; + +const router = new Elysia({ + prefix: "/users", +}) + .use(betterAuth) + .get( + "/me", + async ({ user: { id: currentUserId } }) => { + const fullUser = await db.query.user.findFirst({ + where: (user) => eq(user.id, currentUserId), + with: { + data: true, + courses: true, + }, + }); + return fullUser; + }, + { + auth: true, + }, + ); + +export default router; diff --git a/packages/server/tsconfig.json b/packages/server/tsconfig.json index 9be82aa..fba502a 100644 --- a/packages/server/tsconfig.json +++ b/packages/server/tsconfig.json @@ -12,6 +12,10 @@ "moduleResolution": "bundler", "allowImportingTsExtensions": true, "verbatimModuleSyntax": true, - "noEmit": true + "noEmit": true, + "baseUrl": ".", + "paths": { + "@/*": ["./*"] + } } } diff --git a/packages/server/types.ts b/packages/server/types.ts new file mode 100644 index 0000000..8194648 --- /dev/null +++ b/packages/server/types.ts @@ -0,0 +1,7 @@ +import type { InferSelectModel } from "drizzle-orm"; +import type { user, userCourse, userData } from "./db/schema.ts"; + +export type SelectUser = InferSelectModel & { + data: InferSelectModel; + courses: InferSelectModel[]; +}; diff --git a/packages/web/.storybook/preview.tsx b/packages/web/.storybook/preview.tsx index 1a3354e..8b5d855 100644 --- a/packages/web/.storybook/preview.tsx +++ b/packages/web/.storybook/preview.tsx @@ -1,6 +1,6 @@ import type { Preview } from "@storybook/react"; import "../src/app.css"; -import { ThemeContext, useThemeProvider } from "@/services/theme/index.ts"; +import { ThemeContext, useThemeService } from "@/services/theme/index.ts"; const preview: Preview = { parameters: { @@ -17,7 +17,7 @@ export default preview; export const decorators = [ (Story: () => React.ReactNode) => { - const provider = useThemeProvider(); + const provider = useThemeService(); return ( diff --git a/packages/web/package.json b/packages/web/package.json index c3a1ff9..04f95da 100644 --- a/packages/web/package.json +++ b/packages/web/package.json @@ -14,6 +14,7 @@ "dependencies": { "@elysiajs/eden": "^1.3.2", "@headlessui/react": "^2.2.4", + "@packages/models": "workspace:*", "@packages/server": "workspace:*", "@sinclair/typebox": "^0.34.38", "@tanstack/react-query": "^5.83.0", diff --git a/packages/web/src/App.tsx b/packages/web/src/App.tsx index e30cbdc..cf194d6 100644 --- a/packages/web/src/App.tsx +++ b/packages/web/src/App.tsx @@ -3,44 +3,36 @@ import { BrowserRouter, Route, Routes } from "react-router-dom"; import Footer from "./components/Footer/index.tsx"; import Header from "./components/Header/index.tsx"; import { queryClient } from "./lib/tanstack/client.ts"; -import AboutUs from "./pages/AboutUs.tsx"; -import Disclaimer from "./pages/Disclaimer.tsx"; -import Home from "./pages/Home.tsx"; -import HowToUse from "./pages/how-to-use/page.tsx"; -import NotFound from "./pages/NotFound.tsx"; -import Notion from "./pages/Notion.tsx"; import Profile from "./pages/Profile.tsx"; +import Setup from "./pages/Setup.tsx"; import SignIn from "./pages/SignIn.tsx"; -import { ThemeContext, useThemeProvider } from "./services/theme/index.ts"; +import AboutUs from "./pages/static/AboutUs.tsx"; +import Disclaimer from "./pages/static/Disclaimer.tsx"; +import HowToUse from "./pages/static/how-to-use/page.tsx"; +import Landing from "./pages/static/LP.tsx"; +import NotFound from "./pages/static/NotFound.tsx"; +import Notion from "./pages/static/Notion.tsx"; +import { ThemeContext, useThemeService } from "./services/theme/index.ts"; -/** - * App コンポーネントは、アプリケーション全体のレイアウトを定義します。 - * - * - テーマ(light/dark)の切り替え機能を提供します。 - * - ユーザー情報を管理し、アプリ全体で利用可能にします。 - * @returns アプリケーションのルートコンポーネント - */ export default function App() { - /** - * テーマ管理 - */ - const themeService = useThemeProvider(); + const themeService = useThemeService(); return ( -
+
- } /> + } /> } /> } /> } /> } /> } /> } /> + } /> } />