Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
51 changes: 28 additions & 23 deletions SNUTT/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,16 +34,18 @@ tuist build "SNUTT Widget" # Build app + widget extension
# Wait for the build to complete before proceeding with other tasks.
# This ensures proper error detection and prevents build state corruption.

# Module-Specific Builds (Preview Schemes)
# Module-Specific Builds
# Use these for faster builds when working on a single module:
tuist build "Timetable Preview" # Build only Timetable module
tuist build "Auth Preview" # Build only Auth module
tuist build "Vacancy Preview" # Build only Vacancy module
tuist build "Themes Preview" # Build only Themes module
# ... and more Preview schemes for other modules
tuist build Timetable # Build only Timetable module
tuist build Auth # Build only Auth module
tuist build Vacancy # Build only Vacancy module
tuist build Themes # Build only Themes module
# ... and more schemes for other modules

# Testing
tuist build "ModuleTests" # Build all module tests
tuist test Timetable # Run tests for Timetable module
tuist test Auth # Run tests for Auth module
tuist test "ModuleTests" # Run all module tests

# Code Quality
just format # Format Swift code using swift-format
Expand All @@ -63,44 +65,47 @@ This project automatically generates the following schemes:
- **SNUTT Prod**: Production build with prod API endpoint (`snutt-api.wafflestudio.com`)
- **SNUTT Widget**: Build app with widget extension for testing widgets

#### Module Preview Schemes
#### Module Schemes

Each feature and shared UI module has its own Preview scheme for faster, isolated builds:
Each feature and shared UI module has its own scheme for faster, isolated builds and testing:

**Feature Module Previews:**
- `Timetable Preview`, `Auth Preview`, `Notifications Preview`
- `Vacancy Preview`, `Themes Preview`, `Settings Preview`
- `Reviews Preview`, `Friends Preview`, `Popup Preview`
**Feature Modules:**
- `Timetable`, `Auth`, `Notifications`
- `Vacancy`, `Themes`, `Settings`
- `Reviews`, `Friends`, `Popup`

**Shared UI Module Previews:**
- `TimetableUIComponents Preview`
- `SharedUIComponents Preview`, `SharedUIWebKit Preview`, `SharedUIMapKit Preview`
**Shared UI Modules:**
- `TimetableUIComponents`
- `SharedUIComponents`, `SharedUIWebKit`, `SharedUIMapKit`

**Test Scheme:**
- `ModuleTests`: Runs all module tests across the project

#### When to Use Preview Schemes
#### When to Use Module Schemes

Preview schemes are ideal for **localized development** when working on a single module:
Module schemes are ideal for **localized development** when working on a single module:

```bash
# Example: Working on Timetable feature only
tuist build "Timetable Preview" # Much faster than full app build
tuist build Timetable # Much faster than full app build
tuist test Timetable # Run only Timetable tests

# Example: Making changes to shared UI components
tuist build "SharedUIComponents Preview"
tuist build SharedUIComponents
tuist test SharedUIComponents
```

**Benefits:**
- **Faster build times**: Only builds the target module and its dependencies
- **Quick validation**: Immediately verify if your changes compile without full app rebuild
- **Focused development**: Isolate work to specific modules
- **Focused testing**: Run tests for specific modules during development
- **Isolated development**: Work on specific modules without building the entire app

**Note:** Some modules don't have Preview schemes:
**Note:** Some modules don't have schemes:
- `Analytics` (marked as `previewable: false`)
- `Configs` (marked as `previewable: false`)
- `APIClient` (marked as `previewable: false`)
- All `*Interface` modules (FeatureInterface modules don't have preview schemes)
- All `*Interface` modules (FeatureInterface modules don't have schemes)

### Build Configurations
- **Dev**: Development configuration, API: `snutt-api-dev.wafflestudio.com`
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,16 +10,15 @@ final class TimetableSettingsViewModel {
@Dependency(\.timetableLocalRepository) private var timetableLocalRepository

private(set) var timetable: Timetable?

var configuration: TimetableConfiguration = .init() {
didSet {
timetableLocalRepository.storeTimetableConfiguration(configuration)
}
}

init() {
self.timetable = try? timetableLocalRepository.loadSelectedTimetable()
self.configuration = timetableLocalRepository.loadTimetableConfiguration()
func loadInitialTimetable() {
timetable = try? timetableLocalRepository.loadSelectedTimetable()
configuration = timetableLocalRepository.loadTimetableConfiguration()
}

func toggleWeekday(weekday: Weekday) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,7 @@ struct TimetableSettingView: View {
}
.navigationTitle(SettingsStrings.displayTable)
.navigationBarTitleDisplayMode(.inline)
.onAppear { viewModel.loadInitialTimetable() }
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,14 @@
"search.predicate.credit.suffix" = "학점";
"search.predicate.etc.english" = "영강";
"search.predicate.etc.army" = "군휴학";
"search.predicate.time.emptySlots" = "빈 시간대로 검색";
"search.predicate.time.directSelection" = "시간대 직접 선택";
"search.time.selection.title" = "시간대 선택";
"search.time.selection.placeholder" = "시간표에서 시간대를 선택해주세요";
"search.time.selection.done" = "완료";
"search.time.selection.cancel" = "취소";
"search.time.selection.reset" = "초기화";
"search.time.selection.selectEmptySlots" = "빈 시간대 선택";

"toast.bookmark.message" = "검색탭 우측 상단에서 관심강좌 목록을 확인해보세요.";
"toast.vacancy.message" = "더보기탭에서 빈자리 알림 목록을 확인해보세요.";
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,14 @@
"search.predicate.credit.suffix" = "credits";
"search.predicate.etc.english" = "English";
"search.predicate.etc.army" = "Military";
"search.predicate.time.emptySlots" = "Search Empty Time Slots";
"search.predicate.time.directSelection" = "Select Time Manually";
"search.time.selection.title" = "Select Time Slots";
"search.time.selection.placeholder" = "Select time slots from the timetable";
"search.time.selection.done" = "Done";
"search.time.selection.cancel" = "Cancel";
"search.time.selection.reset" = "Reset";
"search.time.selection.selectEmptySlots" = "Select Empty Slots";

"toast.bookmark.message" = "Check your bookmarked courses at the top right of the Search tab.";
"toast.vacancy.message" = "Check your vacancy notifications in the More tab.";
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -127,43 +127,6 @@ struct LectureSearchAPIRepository: LectureSearchRepository {
}
}

extension Components.Schemas.LectureDto {
fileprivate func toLecture() throws -> Lecture {
let timePlaces = try class_time_json.enumerated().map { index, json in
try json.toTimePlace(index: index, isCustom: false)
}
let evLecture = snuttEvLecture.flatMap {
EvLecture(
evLectureID: Int($0.evLectureId),
avgRating: $0.avgRating,
evaluationCount: Int($0.evaluationCount)
)
}
return try Lecture(
id: require(_id),
lectureID: nil,
courseTitle: course_title,
timePlaces: timePlaces,
lectureNumber: lecture_number,
instructor: instructor,
credit: credit,
courseNumber: course_number,
department: department,
academicYear: academic_year,
remark: remark,
evLecture: evLecture,
colorIndex: 0,
customColor: .temporary,
classification: classification,
category: category,
wasFull: wasFull,
registrationCount: registrationCount,
quota: quota,
freshmenQuota: freshmanQuota
)
}
}

extension Array {
fileprivate func nilIfEmpty() -> Self? {
isEmpty ? nil : self
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@

import APIClientInterface
import Dependencies
import FoundationUtility
import Observation
import SharedUIComponents
import SwiftUI
Expand All @@ -28,7 +29,7 @@ class LectureSearchViewModel {
@ObservationIgnored
@Dependency(\.notificationCenter) var notificationCenter

private let timetableViewModel: TimetableViewModel
let timetableViewModel: TimetableViewModel

init(timetableViewModel: TimetableViewModel) {
self.timetableViewModel = timetableViewModel
Expand Down Expand Up @@ -76,12 +77,38 @@ class LectureSearchViewModel {

private(set) var availablePredicates: [SearchFilterCategory: [SearchPredicate]] = [:]
var selectedCategory: SearchFilterCategory = .sortCriteria
private(set) var selectedPredicates: [SearchPredicate] = []
var selectedPredicates: [SearchPredicate] = []
var isTimeSelectionSheetOpen = false

var displayPredicates: [SearchPredicate] {
var displayPredicates = selectedPredicates.filter { $0.category != .time }
if isTimeIncludeSelected {
displayPredicates.append(.timeInclude(.init(day: 0, startMinute: 0, endMinute: 0)))
}
if isTimeExcludeSelected {
displayPredicates.append(.timeExclude(.init(day: 0, startMinute: 0, endMinute: 0)))
}
return displayPredicates
}

var isTimeIncludeSelected: Bool {
selectedPredicates.contains { if case .timeInclude = $0 { return true } else { return false } }
}

var isTimeExcludeSelected: Bool {
selectedPredicates.contains { if case .timeExclude = $0 { return true } else { return false } }
}

var selectedTimeIncludeRanges: [SearchTimeRange] {
selectedPredicates.compactMap {
if case .timeInclude(let range) = $0 { return range } else { return nil }
}
}

var supportedCategories: [SearchFilterCategory] {
SearchFilterCategory.allCases
.filter { $0 != .instructor }
.filter { availablePredicates.keys.contains($0) }
.filter { $0 == .time || availablePredicates.keys.contains($0) }
}

func fetchAvailablePredicates(quarter: Quarter) async throws {
Expand All @@ -101,7 +128,11 @@ class LectureSearchViewModel {
}

func deselectPredicate(predicate: SearchPredicate) {
selectedPredicates.removeAll(where: { $0 == predicate })
if predicate.category == .time {
selectedPredicates.removeAll(where: { $0.category == .time })
} else {
selectedPredicates.removeAll(where: { $0 == predicate })
}
}
Comment on lines 130 to 136
Copy link

Copilot AI Nov 19, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Missing closing brace. The function body is incomplete - add the closing brace after line 135.

Copilot uses AI. Check for mistakes.

func fetchInitialSearchResult() async throws {
Expand All @@ -113,6 +144,12 @@ class LectureSearchViewModel {
)
)
}

// If time exclude filter is selected, update it with current timetable state
if isTimeExcludeSelected {
setTimeExcludeRangesFromCurrentTimetable()
}

try await dataSource.fetchInitialSearchResult(
query: searchQuery,
quarter: searchingQuarter,
Expand All @@ -134,6 +171,34 @@ class LectureSearchViewModel {
guard let searchingQuarter else { return }
bookmarkedLectures = try await lectureRepository.fetchBookmarks(quarter: searchingQuarter)
}

func setTimeIncludeRanges(_ ranges: [SearchTimeRange]) {
// Remove all time predicates first
selectedPredicates.removeAll { $0.category == .time }
// Add timeInclude for each range
selectedPredicates.append(contentsOf: ranges.map { .timeInclude($0) })
}

func setTimeExcludeRangesFromCurrentTimetable() {
// Remove all time predicates first
selectedPredicates.removeAll { $0.category == .time }

guard let currentTimetable = timetableViewModel.currentTimetable else { return }

let ranges: [SearchPredicate] = currentTimetable.occupiedTimeRanges.map { .timeExclude($0) }

// If timetable is empty, add a dummy timeExclude predicate to indicate filter is active
// The backend will handle empty timetable case appropriately
if ranges.isEmpty {
selectedPredicates.append(.timeExclude(.init(day: 0, startMinute: 0, endMinute: 0)))
} else {
selectedPredicates.append(contentsOf: ranges)
}
}

func clearTimeFilter() {
selectedPredicates.removeAll { $0.category == .time }
}
}

extension LectureSearchViewModel: ExpandableLectureListViewModel {
Expand Down Expand Up @@ -344,9 +409,9 @@ extension SearchPredicate {
case let .credit(value):
"\(value)\(TimetableStrings.searchPredicateCreditSuffix)"
case .timeInclude:
""
TimetableStrings.searchPredicateTimeDirectSelection
case .timeExclude:
""
TimetableStrings.searchPredicateTimeEmptySlots
case let .etc(value):
switch value {
case .english:
Expand All @@ -357,3 +422,27 @@ extension SearchPredicate {
}
}
}

extension SearchTimeRange {
func formatted() -> String {
// Convert day Int (0-6) to Weekday enum
guard let weekday = Weekday(rawValue: day) else { return "" }

let calendar = Calendar.current
let baseDate = calendar.startOfDay(for: Date())

// Create dates for time formatting only
guard let startDate = calendar.date(byAdding: .minute, value: startMinute, to: baseDate),
let endDate = calendar.date(byAdding: .minute, value: endMinute, to: baseDate)
else {
return ""
}

// Format times as HH:mm (24-hour format)
let timeFormat: Date.FormatStyle = .dateTime.hour().minute()
let startTime = startDate.formatted(timeFormat)
let endTime = endDate.formatted(timeFormat)

return "\(weekday.veryShortSymbol) \(startTime)-\(endTime)"
}
}
Loading
Loading