diff --git a/package.json b/package.json index e1e8624..ab5b00c 100644 --- a/package.json +++ b/package.json @@ -9,8 +9,8 @@ "scripts": { "dev": "vite", "build": "tsc -b && vite build", + "typecheck": "tsc -b --noEmit", "preview": "vite preview", - "typecheck": "tsc -b", "check": "biome check .", "check:fix": "biome check --write .", "test": "vitest run", diff --git a/src/components/layout/Header.tsx b/src/components/layout/Header.tsx index cfd7a44..0e7cf78 100644 --- a/src/components/layout/Header.tsx +++ b/src/components/layout/Header.tsx @@ -1,11 +1,9 @@ -import { ActionIcon, Anchor, SegmentedControl, Text } from '@mantine/core'; +import { ActionIcon, Anchor, Text } from '@mantine/core'; import { IconSearch, IconSettings } from '@tabler/icons-react'; import { useLocation, useNavigate } from 'react-router'; -import { useAppContext } from '../../context/AppContext.tsx'; import classes from './Header.module.css'; export function Header() { - const { mode, setMode } = useAppContext(); const navigate = useNavigate(); const { pathname } = useLocation(); @@ -59,26 +57,15 @@ export function Header() { ut.code(); {showControls && ( - <> - setMode(v as 'view' | 'edit')} - data={[ - { value: 'edit', label: 'Edit' }, - { value: 'view', label: 'View' }, - ]} - size="sm" - /> - - - - + + + )} void; + isDesktop: boolean; +} + +export function Calendar({ collapsed, onToggle, isDesktop }: Props) { + const { credits, togglePeriod, setPeriods, groupedByPeriod } = useAppContext(); const selectBlank = () => { const blank: string[] = []; @@ -26,84 +37,130 @@ export function Calendar() { setPeriods(blank); }; + if (collapsed && isDesktop) { + return ( + + + + + + 時間割 + + + ); + } + return ( - - - 時間割 - - - 現在 - - {credits} + + + + 時間割 - 単位 - + + {!collapsed && ( + + 現在 + + {credits} + + 単位 + + )} + + {isDesktop ? ( + + ) : collapsed ? ( + + ) : ( + + )} + + + -
- {/* Corner */} -
- {/* Day headers */} - {DAY_JP_LIST.map((dayJp, di) => { - const dayEn = DAY_EN_LIST[di]; - const periods = HEADER_ID_TO_PERIODS.get(`${dayEn}-all`) ?? []; - const isToday = dayEn === TODAY_EN; - return ( - togglePeriod(periods) : undefined} - > - {dayJp} - - ); - })} - {/* Time rows */} - {TIME_LIST.map((time) => ( - - togglePeriod(HEADER_ID_TO_PERIODS.get(`all-${time}`) ?? []) - : undefined - } - > - {time} - - {DAY_JP_LIST.map((dayJp) => ( -
- -
+ {!collapsed && ( + <> +
+ {/* Corner */} +
+ {/* Day headers */} + {DAY_JP_LIST.map((dayJp, di) => { + const dayEn = DAY_EN_LIST[di]; + const periods = HEADER_ID_TO_PERIODS.get(`${dayEn}-all`) ?? []; + const isToday = dayEn === TODAY_EN; + return ( + togglePeriod(periods)} + > + {dayJp} + + ); + })} + {/* Time rows */} + {TIME_LIST.map((time) => ( + + togglePeriod(HEADER_ID_TO_PERIODS.get(`all-${time}`) ?? [])} + > + {time} + + {DAY_JP_LIST.map((dayJp) => ( +
+ +
+ ))} +
))} - - ))} - {/* 集中 */} - togglePeriod(['集中']) : undefined} - > - 集 - -
- -
-
+ {/* 集中 */} + togglePeriod(['集中'])}> + 集 + +
+ +
+
- {isEdit && ( - - - - + + + + + )} ); diff --git a/src/components/main/ConditionFilter.module.css b/src/components/main/ConditionFilter.module.css index 9c9e7bd..16f30ac 100644 --- a/src/components/main/ConditionFilter.module.css +++ b/src/components/main/ConditionFilter.module.css @@ -1,44 +1,15 @@ -.semesterGrid { +.filterGrid { display: grid; - grid-template-columns: repeat(4, 2.5rem); - grid-template-rows: repeat(2, 2.5rem); - grid-auto-flow: column dense; - gap: 4px; + grid-template-columns: 1fr 1fr; + gap: 16px 32px; } -/* S_, A_ span 2 columns */ -.semesterGrid > :nth-child(3n + 1) { - grid-column: span 2; +.fullWidth { + grid-column: 1 / -1; } -.categoryGrid { - display: grid; - grid-template-columns: repeat(8, 2.5rem); - gap: 4px; -} - -/* foundation, requirement, thematic, intermediate span 2 */ -.categoryGrid > :nth-child(-n + 4) { - grid-column: span 2; -} - -@media (max-width: 470px) { - .categoryGrid { - grid-template-columns: repeat(4, 2.5rem); +@media (max-width: 480px) { + .filterGrid { + grid-template-columns: 1fr; } } - -/* Ternary (evaluation) */ -.ternaryGroup { - display: grid; - grid-template-columns: 3rem 1.5rem 1.5rem; - align-items: center; - gap: 4px; -} - -.evalHeader { - display: grid; - grid-template-columns: 3rem 1.5rem 1.5rem; - gap: 4px; - margin-bottom: 2px; -} diff --git a/src/components/main/ConditionFilter.tsx b/src/components/main/ConditionFilter.tsx index 65f6d89..1600943 100644 --- a/src/components/main/ConditionFilter.tsx +++ b/src/components/main/ConditionFilter.tsx @@ -1,203 +1,307 @@ -import { ActionIcon, Box, Button, Group, Stack, Text } from '@mantine/core'; -import { IconCheck, IconX } from '@tabler/icons-react'; +import { + Box, + Button, + Checkbox, + Collapse, + Group, + Stack, + Switch, + Text, + TextInput, +} from '@mantine/core'; +import { useDisclosure } from '@mantine/hooks'; import { useCallback } from 'react'; import { useAppContext } from '../../context/AppContext.tsx'; -import { - CATEGORY_OPTIONS, - EVALUATION_OPTIONS, - NAME_TABLE, - REGISTRATION_OPTIONS, - SEMESTER_OPTIONS, -} from '../../lib/constants.ts'; -import type { TernaryState } from '../../types.ts'; +import { useSearchContext } from '../../context/SearchContext.tsx'; import classes from './ConditionFilter.module.css'; -function BinaryBtn({ - label, - active, - onClick, -}: { +type TreeNode = { + key: string; label: string; - active: boolean; - onClick: () => void; -}) { - return ( - - ); + virtual?: boolean; + children?: TreeNode[]; +}; + +function getAllFilterKeys(node: TreeNode): string[] { + const self = node.virtual ? [] : [node.key]; + return [...self, ...(node.children?.flatMap(getAllFilterKeys) ?? [])]; } -function TernaryGroup({ - label, - value, - onChange, +type NodeState = 'checked' | 'unchecked' | 'indeterminate'; + +function getNodeState(node: TreeNode, values: Record): NodeState { + const keys = getAllFilterKeys(node); + if (!keys.length) return 'unchecked'; + const checkedCount = keys.filter((k) => values[k]).length; + if (checkedCount === 0) return 'unchecked'; + if (checkedCount === keys.length) return 'checked'; + return 'indeterminate'; +} + +function applyToggle(node: TreeNode, values: Record): Record { + const keys = getAllFilterKeys(node); + const newVal = getNodeState(node, values) !== 'checked'; + return { ...values, ...Object.fromEntries(keys.map((k) => [k, newVal])) }; +} + +type FlatNode = { node: TreeNode; depth: number }; + +function flattenTree(nodes: TreeNode[], depth = 0): FlatNode[] { + return nodes.flatMap((node) => [{ node, depth }, ...flattenTree(node.children ?? [], depth + 1)]); +} + +function CheckboxTree({ + nodes, + values, + onToggle, }: { - label: string; - value: TernaryState; - onChange: (v: TernaryState) => void; + nodes: TreeNode[]; + values: Record; + onToggle: (node: TreeNode) => void; }) { - const handleMust = () => onChange(value === true ? null : true); - const handleReject = () => onChange(value === false ? null : false); + const flat = flattenTree(nodes); return ( -
- - {label} - - - {value === true && } - - - {value === false && } - -
+ + {flat.map(({ node, depth }) => { + const state = getNodeState(node, values); + return ( + onToggle(node)} + ml={depth * 16} + size="xs" + /> + ); + })} + ); } -function FilterSection({ title, children }: { title: string; children: React.ReactNode }) { +const CATEGORY_TREE: TreeNode[] = [ + { + key: '__cat_all__', + label: 'すべて', + virtual: true, + children: [ + { key: 'foundation', label: '基礎' }, + { key: 'requirement', label: '要求' }, + { key: 'thematic', label: '主題' }, + { key: 'intermediate', label: '展開' }, + { + key: '__sogo__', + label: '総合', + virtual: true, + children: [ + { key: 'L', label: 'L' }, + { key: 'A', label: 'A' }, + { key: 'B', label: 'B' }, + { key: 'C', label: 'C' }, + { key: 'D', label: 'D' }, + { key: 'E', label: 'E' }, + { key: 'F', label: 'F' }, + ], + }, + ], + }, +]; + +const EVAL_TREE: TreeNode[] = [ + { + key: '__eval_all__', + label: 'すべて', + virtual: true, + children: [ + { key: 'exam', label: '試験' }, + { key: 'paper', label: 'レポ' }, + { key: 'attendance', label: '出席' }, + { key: 'participation', label: '平常' }, + { key: 'other', label: 'その他' }, + ], + }, +]; + +const SEMESTER_TREE: TreeNode[] = [ + { + key: 'S_', + label: 'S', + children: [ + { key: 'S1', label: 'S1' }, + { key: 'S2', label: 'S2' }, + ], + }, + { + key: 'A_', + label: 'A', + children: [ + { key: 'A1', label: 'A1' }, + { key: 'A2', label: 'A2' }, + ], + }, +]; + +function SectionTitle({ children }: { children: React.ReactNode }) { return ( - - - - {title} - - -
{children}
-
+ + {children} + ); } export function ConditionFilter() { const { condition, setCondition } = useAppContext(); + const { + freeword, + setFreeword, + searchAll, + setSearchAll, + availableOnly, + setAvailableOnly, + inputHistory, + setInputHistory, + } = useSearchContext(); + const [advancedOpened, { toggle: toggleAdvanced }] = useDisclosure(false); - const toggleSemester = useCallback( - (key: string) => { - setCondition((prev) => ({ - ...prev, - semester: { ...prev.semester, [key]: !prev.semester[key] }, - })); + const handleFreewordBlur = useCallback(() => { + if (!freeword) return; + setInputHistory([freeword, ...inputHistory.filter((h) => h !== freeword)].slice(0, 10)); + }, [freeword, inputHistory, setInputHistory]); + + const toggleCategoryNode = useCallback( + (node: TreeNode) => { + setCondition((prev) => ({ ...prev, category: applyToggle(node, prev.category) })); }, [setCondition], ); - const toggleCategory = useCallback( - (key: string) => { - setCondition((prev) => ({ - ...prev, - category: { ...prev.category, [key]: !prev.category[key] }, - })); + const toggleEvalNode = useCallback( + (node: TreeNode) => { + setCondition((prev) => ({ ...prev, evaluation: applyToggle(node, prev.evaluation) })); }, [setCondition], ); - const toggleRegistration = useCallback( - (key: string) => { - setCondition((prev) => ({ - ...prev, - registration: { ...prev.registration, [key]: !prev.registration[key] }, - })); + const toggleSemesterNode = useCallback( + (node: TreeNode) => { + setCondition((prev) => ({ ...prev, semester: applyToggle(node, prev.semester) })); }, [setCondition], ); - const setEvaluation = useCallback( - (key: string, v: TernaryState) => { + const toggleRegistration = useCallback( + (key: string) => { setCondition((prev) => ({ ...prev, - evaluation: { ...prev.evaluation, [key]: v }, + registration: { ...prev.registration, [key]: !prev.registration[key] }, })); }, [setCondition], ); return ( - - -
- {SEMESTER_OPTIONS.map((key) => ( - toggleSemester(key)} +
+ {/* フリーワード */} + + フリーワード + + setFreeword(e.target.value)} + onBlur={handleFreewordBlur} + placeholder="講義名・教員名など" + flex={1} + size="xs" + /> + + {inputHistory.map((h, i) => ( + // biome-ignore lint/suspicious/noArrayIndexKey: history list order is stable + + + + + + {/* 種別 */} + + 種別 + + + + {/* 評価 */} + + 評価 + + + + {/* 詳細な設定 */} + + + {advancedOpened ? '▲' : '▶'} 詳細な設定 + + + + setAvailableOnly(e.currentTarget.checked)} + color="syllabusGreen" + size="xs" /> - ))} -
- - - -
- - - 含む - - - 除外 - -
- - {EVALUATION_OPTIONS.map((key) => ( - setEvaluation(key, v)} + toggleRegistration('registered')} + color="syllabusGreen" + size="xs" /> - ))} - -
- - -
- {CATEGORY_OPTIONS.map((key) => ( - toggleCategory(key)} + toggleRegistration('unregistered')} + color="syllabusGreen" + size="xs" /> - ))} -
-
- - - - {REGISTRATION_OPTIONS.map((key) => ( - toggleRegistration(key)} + + + + + {/* 学期 */} + + 学期 + + {SEMESTER_TREE.map((root) => ( + ))} - - + +
); } diff --git a/src/components/main/LectureTable.tsx b/src/components/main/LectureTable.tsx index 4b3c2f0..4f18c53 100644 --- a/src/components/main/LectureTable.tsx +++ b/src/components/main/LectureTable.tsx @@ -1,9 +1,30 @@ -import { Box, Button, Paper, SegmentedControl, Table, Text } from '@mantine/core'; +import { + ActionIcon, + Box, + Button, + Group, + Paper, + SegmentedControl, + Table, + Text, +} from '@mantine/core'; +import { + IconChevronDown, + IconChevronLeft, + IconChevronRight, + IconChevronUp, +} from '@tabler/icons-react'; import { useAppContext } from '../../context/AppContext.tsx'; import { useSearchContext } from '../../context/SearchContext.tsx'; import type { Lecture } from '../../types.ts'; import classes from './LectureTable.module.css'; +interface Props { + collapsed: boolean; + onToggle: () => void; + isDesktop: boolean; +} + function RegisterButton({ lecture }: { lecture: Lecture }) { const { registeredCodes, toggleRegister } = useAppContext(); const isReg = registeredCodes.has(lecture.code); @@ -53,48 +74,89 @@ function LectureRow({ lecture }: { lecture: Lecture }) { ); } -export function LectureTable() { - const { mode } = useAppContext(); +export function LectureTable({ collapsed, onToggle, isDesktop }: Props) { const { displayTab, setDisplayTab, filteredLectures } = useSearchContext(); - const isEdit = mode === 'edit'; - if (!isEdit) return null; + if (collapsed && isDesktop) { + return ( + + + + + + 検索結果 + + + ); + } return ( - - setDisplayTab(v as 'searched' | 'registered')} - size="sm" - data={[ - { value: 'searched', label: `検索結果 (${filteredLectures.length}件)` }, - { value: 'registered', label: '登録中の講義' }, - ]} - /> - - - - {filteredLectures.map((lec) => ( - - ))} - {filteredLectures.length === 0 && ( - - - - 該当する講義がありません - - - + + {!collapsed && ( + setDisplayTab(v as 'searched' | 'registered')} + size="sm" + data={[ + { value: 'searched', label: `検索結果 (${filteredLectures.length}件)` }, + { value: 'registered', label: '登録中の講義' }, + ]} + /> + )} + {collapsed && ( + + 検索結果 + + )} + + {isDesktop ? ( + + ) : collapsed ? ( + + ) : ( + )} - -
+ +
+ {!collapsed && ( + + + + {filteredLectures.map((lec) => ( + + ))} + {filteredLectures.length === 0 && ( + + + + 該当する講義がありません + + + + )} + +
+
+ )} ); } diff --git a/src/components/main/MainContent.tsx b/src/components/main/MainContent.tsx index e4e6061..eb13a8d 100644 --- a/src/components/main/MainContent.tsx +++ b/src/components/main/MainContent.tsx @@ -1,18 +1,78 @@ import { Box } from '@mantine/core'; +import { useMediaQuery } from '@mantine/hooks'; +import { useState } from 'react'; import { Calendar } from './Calendar.tsx'; import { LectureTable } from './LectureTable.tsx'; import { SearchPanel } from './SearchPanel.tsx'; +type Panel = 'search' | 'results' | 'calendar'; +const FLEX: Record = { search: 3, results: 5, calendar: 4 }; +const COLLAPSED_WIDTH = 44; + export function MainContent() { + const [collapsed, setCollapsed] = useState>(new Set()); + const isDesktop = useMediaQuery('(min-width: 48em)', true); + + const toggle = (panel: Panel) => { + setCollapsed((prev) => { + const next = new Set(prev); + if (next.has(panel)) next.delete(panel); + else next.add(panel); + return next; + }); + }; + + const colStyle = (panel: Panel): React.CSSProperties => { + if (!isDesktop) return {}; + const isCollapsed = collapsed.has(panel); + return { + flex: isCollapsed ? `0 0 ${COLLAPSED_WIDTH}px` : FLEX[panel], + transition: 'flex 0.25s ease', + minWidth: 0, + overflow: 'hidden', + height: '100%', + }; + }; + return ( - - - + + toggle('search')} + isDesktop={isDesktop} + /> + + + toggle('results')} + isDesktop={isDesktop} + /> + + + toggle('calendar')} + isDesktop={isDesktop} + /> + ); } diff --git a/src/components/main/PeriodCell.module.css b/src/components/main/PeriodCell.module.css index 350a03d..f400b07 100644 --- a/src/components/main/PeriodCell.module.css +++ b/src/components/main/PeriodCell.module.css @@ -8,69 +8,55 @@ min-height: 3rem; width: 100%; height: 100%; -} - -.editMode { border-radius: 1.5rem; border: 1px solid #ccc; background-color: #f8f8f8; } -.editMode.checked { +.cell.checked { background-color: var(--mantine-color-syllabusGreen-3); color: #fff; } -.editMode:hover { +.cell:hover { background-color: #ccc; } -.editMode.checked:hover { +.cell.checked:hover { background-color: var(--mantine-color-syllabusGreen-5); } -.viewMode { - pointer-events: none; -} - -.viewMode .lectureBox { - pointer-events: all; -} - .intensive { border-radius: 1.5rem; } .lectureBox { display: flex; - justify-content: center; align-items: center; - padding: 2px 6px; + padding: 2px 4px 2px 6px; border-radius: 1rem; font-size: 0.8rem; color: #333; - background: transparent; - border: none; - cursor: pointer; - text-align: center; width: 100%; +} + +.lectureName { + flex: 1; word-break: break-all; + text-align: center; } -.editMode .lectureBox { - pointer-events: none; +.infoBtn { + flex-shrink: 0; + opacity: 0.6; } -.viewMode .lectureBox { - background: var(--mantine-color-blue-0); - border: 1px solid var(--mantine-color-blue-4); - box-shadow: 0 2px 6px rgba(0, 0, 0, 0.15); - transition: transform 0.2s; - border-radius: 8px; +.infoBtn:hover { + opacity: 1; } -.viewMode .lectureBox:hover { - transform: translateY(-2px); +.cell.checked .infoBtn { + color: #fff; } .required::before { diff --git a/src/components/main/PeriodCell.tsx b/src/components/main/PeriodCell.tsx index a758fdb..7e8b195 100644 --- a/src/components/main/PeriodCell.tsx +++ b/src/components/main/PeriodCell.tsx @@ -1,3 +1,5 @@ +import { ActionIcon } from '@mantine/core'; +import { IconInfoCircle } from '@tabler/icons-react'; import { useAppContext } from '../../context/AppContext.tsx'; import { importanceFor } from '../../lib/lectures.ts'; import classes from './PeriodCell.module.css'; @@ -8,22 +10,16 @@ interface PeriodCellProps { } export function PeriodCell({ period, isIntensive = false }: PeriodCellProps) { - const { mode, selectedPeriods, groupedByPeriod, togglePeriod, setCode, personal } = - useAppContext(); + const { selectedPeriods, groupedByPeriod, togglePeriod, setCode, personal } = useAppContext(); const isChecked = selectedPeriods.has(period); const grouped = groupedByPeriod.get(period); const stream = personal?.stream ?? ''; - const isEdit = mode === 'edit'; - - const handleCellClick = () => { - if (isEdit) togglePeriod([period]); - }; return ( - // biome-ignore lint/a11y/noLabelWithoutControl lint/a11y/useKeyWithClickEvents: cell acts as toggle, not a form label -
); })} - +
); } diff --git a/src/components/main/SearchPanel.tsx b/src/components/main/SearchPanel.tsx index 38382e2..bc4f4c2 100644 --- a/src/components/main/SearchPanel.tsx +++ b/src/components/main/SearchPanel.tsx @@ -1,88 +1,84 @@ -import { Box, Button, Group, Paper, Switch, Text, TextInput } from '@mantine/core'; +import { ActionIcon, Button, Group, Paper, Text } from '@mantine/core'; +import { + IconChevronDown, + IconChevronLeft, + IconChevronRight, + IconChevronUp, +} from '@tabler/icons-react'; import { useCallback } from 'react'; import { defaultCondition, useAppContext } from '../../context/AppContext.tsx'; -import { useSearchContext } from '../../context/SearchContext.tsx'; import { ConditionFilter } from './ConditionFilter.tsx'; -export function SearchPanel() { - const { setCondition, personal, mode } = useAppContext(); - const { - freeword, - setFreeword, - searchAll, - setSearchAll, - availableOnly, - setAvailableOnly, - inputHistory, - setInputHistory, - } = useSearchContext(); - - const isEdit = mode === 'edit'; +interface Props { + collapsed: boolean; + onToggle: () => void; + isDesktop: boolean; +} - const handleFreewordBlur = useCallback(() => { - if (!freeword) return; - setInputHistory([freeword, ...inputHistory.filter((h) => h !== freeword)].slice(0, 10)); - }, [freeword, inputHistory, setInputHistory]); +export function SearchPanel({ collapsed, onToggle, isDesktop }: Props) { + const { setCondition, personal } = useAppContext(); const resetCondition = useCallback(() => { setCondition(defaultCondition(personal?.stream ?? 'default')); }, [setCondition, personal]); - if (!isEdit) return null; + if (collapsed && isDesktop) { + return ( + + + + + + 検索条件 + + + ); + } return ( - - - 検索条件 - - - setAvailableOnly(e.currentTarget.checked)} - color="syllabusGreen" - mb="sm" - /> - - - - フリーワード + + + + 検索条件 - - setFreeword(e.target.value)} - onBlur={handleFreewordBlur} - placeholder="講義名・教員名など" - w="auto" - /> - - {inputHistory.map((h, i) => ( - // biome-ignore lint/suspicious/noArrayIndexKey: history list order is stable - - - - - - - - - + + {isDesktop ? ( + + ) : collapsed ? ( + + ) : ( + + )} + + {!collapsed && ( + <> + + + + + + )} ); } diff --git a/src/context/AppContext.tsx b/src/context/AppContext.tsx index f0ac228..0a34e5d 100644 --- a/src/context/AppContext.tsx +++ b/src/context/AppContext.tsx @@ -22,13 +22,13 @@ import { groupRegisteredByPeriod, } from '../lib/lectures.ts'; import { storage } from '../lib/storage.ts'; -import type { Condition, Lecture, Mode, PersonalStatus, RequiredDB } from '../types.ts'; +import type { Condition, Lecture, PersonalStatus, RequiredDB } from '../types.ts'; export function defaultCondition(stream: string): Condition { const suffix = LAST_UPDATED.slice(-1); return { semester: Object.fromEntries(SEMESTER_OPTIONS.map((o) => [o, o.includes(suffix)])), - evaluation: Object.fromEntries(EVALUATION_OPTIONS.map((o) => [o, null])), + evaluation: Object.fromEntries(EVALUATION_OPTIONS.map((o) => [o, false])), category: Object.fromEntries( CATEGORY_OPTIONS.map((o) => [ o, @@ -46,7 +46,6 @@ interface AppContextValue { registeredCodes: Set; selectedPeriods: Set; condition: Condition; - mode: Mode; code: string | null; credits: number; groupedByPeriod: Map>; @@ -57,7 +56,6 @@ interface AppContextValue { toggleRegister: (lecture: Lecture) => void; clearRegistered: () => void; setRegisteredCodes: (codes: Set) => void; - setMode: (m: Mode) => void; setCode: (c: string | null) => void; clearCode: () => void; goToMain: (skipCompulsory?: boolean, statusOverride?: PersonalStatus) => Promise; @@ -98,7 +96,6 @@ export function AppProvider({ return saved ?? defaultCondition('default'); }); - const mode = (searchParams.get('mode') as Mode) ?? 'edit'; const code = searchParams.get('code'); useEffect(() => { @@ -126,19 +123,6 @@ export function AppProvider({ setRegisteredCodesState(codes); }, []); - const setMode = useCallback( - (m: Mode) => { - setSearchParams( - (prev) => { - prev.set('mode', m); - return prev; - }, - { replace: true }, - ); - }, - [setSearchParams], - ); - const setCode = useCallback( (c: string | null) => { setSearchParams( @@ -247,7 +231,6 @@ export function AppProvider({ registeredCodes, selectedPeriods, condition, - mode, code, credits, groupedByPeriod, @@ -258,7 +241,6 @@ export function AppProvider({ toggleRegister, clearRegistered, setRegisteredCodes, - setMode, setCode, clearCode, goToMain, @@ -271,7 +253,6 @@ export function AppProvider({ registeredCodes, selectedPeriods, condition, - mode, code, credits, groupedByPeriod, @@ -282,7 +263,6 @@ export function AppProvider({ toggleRegister, clearRegistered, setRegisteredCodes, - setMode, setCode, clearCode, goToMain, diff --git a/src/lib/constants.ts b/src/lib/constants.ts index f3f8168..f2f356a 100644 --- a/src/lib/constants.ts +++ b/src/lib/constants.ts @@ -69,10 +69,17 @@ export const NAME_TABLE: Record = { registration: '登録', unregistered: '未登録', registered: '登録済', + other: 'その他', }; export const SEMESTER_OPTIONS = ['S_', 'S1', 'S2', 'A_', 'A1', 'A2'] as const; -export const EVALUATION_OPTIONS = ['exam', 'paper', 'attendance', 'participation'] as const; +export const EVALUATION_OPTIONS = [ + 'exam', + 'paper', + 'attendance', + 'participation', + 'other', +] as const; export const CATEGORY_OPTIONS = [ 'foundation', 'requirement', diff --git a/src/lib/filters.ts b/src/lib/filters.ts index e534d14..d4edbdb 100644 --- a/src/lib/filters.ts +++ b/src/lib/filters.ts @@ -54,8 +54,7 @@ export function buildLectureFilter(args: { const skipSemester = semesterOpts.every(([, v]) => !v) || semesterOpts.every(([, v]) => v); const skipCategory = categoryOpts.every(([, v]) => !v) || categoryOpts.every(([, v]) => v); - const skipEvalMust = evalOpts.every(([, v]) => !v); - const skipEvalReject = evalOpts.every(([, v]) => v ?? true); + const skipEval = !evalOpts.some(([, v]) => v) || evalOpts.every(([, v]) => v); const skipRegistration = regOpts.every(([, v]) => !v) || regOpts.every(([, v]) => v); const skipPeriodsFilter = selectedPeriods.size === 0; @@ -92,18 +91,18 @@ export function buildLectureFilter(args: { if (!match) return false; } - if (!skipEvalMust) { - const match = evalOpts.some( - ([k, v]) => v === true && lecture.shortenedEvaluation.includes(NAME_TABLE[k]), - ); + if (!skipEval) { + const KNOWN_EVAL_NAMES = ['試験', 'レポ', '出席', '平常']; + const match = evalOpts + .filter(([, v]) => v) + .some(([k]) => { + if (k === 'other') { + return !KNOWN_EVAL_NAMES.some((n) => lecture.shortenedEvaluation.includes(n)); + } + return lecture.shortenedEvaluation.includes(NAME_TABLE[k]); + }); if (!match) return false; } - if (!skipEvalReject) { - const bad = evalOpts.some( - ([k, v]) => v === false && lecture.shortenedEvaluation.includes(NAME_TABLE[k]), - ); - if (bad) return false; - } if (!skipRegistration) { const match = regOpts.some( diff --git a/src/lib/text.test.ts b/src/lib/text.test.ts new file mode 100644 index 0000000..6cd2322 --- /dev/null +++ b/src/lib/text.test.ts @@ -0,0 +1,65 @@ +import { describe, expect, it } from 'vitest'; +import { normalize, parseKeywords, toSearch } from './text.ts'; + +describe('normalize', () => { + it('全角英数字を半角に変換する', () => { + expect(normalize('A1')).toBe('A1'); + }); + + it('全角スペース・連続スペースを半角1つに正規化する', () => { + expect(normalize('foo bar baz')).toBe('foo bar baz'); + }); + + it('各種ダッシュ・ハイフンを統一する', () => { + expect(normalize('A‐B―C−D')).toBe('A-B-C-D'); + }); + + it('null / undefined を空文字として扱う', () => { + expect(normalize(null)).toBe(''); + expect(normalize(undefined)).toBe(''); + }); + + it('前後の空白を除去する', () => { + expect(normalize(' hello ')).toBe('hello'); + }); +}); + +describe('toSearch', () => { + it('大文字を小文字に変換する', () => { + expect(toSearch('HELLO')).toBe('hello'); + }); + + it('長音符をハイフンに変換する', () => { + expect(toSearch('コーヒー')).toBe('コ-ヒ-'); + }); + + it('読点・句点を変換する', () => { + expect(toSearch('りんご、みかん。')).toBe('りんご,みかん.'); + }); +}); + +describe('parseKeywords', () => { + it('スペース区切りでキーワードを分割する', () => { + const [pos, neg] = parseKeywords('数学 物理'); + expect(pos).toEqual(['数学', '物理']); + expect(neg).toEqual([]); + }); + + it('- 始まりのキーワードを除外語として扱う', () => { + const [pos, neg] = parseKeywords('数学 -物理'); + expect(pos).toEqual(['数学']); + expect(neg).toEqual(['物理']); + }); + + it('空文字列は無視する', () => { + const [pos, neg] = parseKeywords(''); + expect(pos).toEqual([]); + expect(neg).toEqual([]); + }); + + it('- 単体は正のキーワードとして扱う(除外語は -foo 形式のみ)', () => { + const [pos, neg] = parseKeywords('-'); + expect(pos).toEqual(['-']); + expect(neg).toEqual([]); + }); +}); diff --git a/src/types.ts b/src/types.ts index 83a2096..a1416cf 100644 --- a/src/types.ts +++ b/src/types.ts @@ -41,17 +41,13 @@ export interface PersonalStatus { autofillCompulsory: boolean; } -export type TernaryState = true | false | null; - export interface Condition { semester: Record; - evaluation: Record; + evaluation: Record; category: Record; registration: Record; } -export type Mode = 'view' | 'edit'; - export type RequiredDB = [ Record, Record,