From ab39d56d0ab7f321c3b016a5152fddcc9f206740 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 26 Jul 2026 19:27:33 +0900 Subject: [PATCH 1/4] feat: add two-person good-time calendars --- README.md | 16 +- app/compatibility.py | 203 ++++++++++++++++++++ app/events.py | 61 ++++-- app/main.py | 186 ++++++++++++++++++ app/static/app.js | 266 ++++++++++++++++++++++++-- app/static/index.html | 263 +++++++++++++++++++++++++- app/static/styles.css | 365 +++++++++++++++++++++++++++++++++++- app/store.py | 22 ++- docs/ARCHITECTURE.md | 11 +- docs/research/README.md | 25 +++ scripts/acceptance_smoke.py | 162 +++++++++++++--- tests/test_api.py | 110 ++++++++++- tests/test_compatibility.py | 144 ++++++++++++++ tests/test_events.py | 34 +++- tests/test_store.py | 2 + 15 files changed, 1787 insertions(+), 83 deletions(-) create mode 100644 app/compatibility.py create mode 100644 tests/test_compatibility.py diff --git a/README.md b/README.md index 2585b47..c20d45b 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,8 @@ # 사주 캘린더 · CalDAV -양력 또는 한국 음력 출생 시각에서 사주 네 기둥을 계산하고, 사용자가 만든 -조건과 맞는 현재 이후의 실제 날짜·시각을 개인 CalDAV 캘린더로 발행하는 -단일 운영자용 서비스입니다. +양력 또는 한국 음력 출생 시각에서 사주 네 기둥을 계산하고, 두 사람에게 +고르게 맞는 현재 이후의 실제 날짜·시각이나 사용자가 만든 조건의 일치 시간을 +개인 CalDAV 캘린더로 발행하는 단일 운영자용 서비스입니다. > 문화·역법 참고 도구입니다. 운세의 과학적 타당성이나 미래 사건과의 > 인과관계를 주장하지 않습니다. @@ -11,6 +11,10 @@ - 양력·한국 음력·윤달 출생 입력과 계산용 양력 시각의 분리 보존 - 출생 프로필별 년주·월주·일주·시주 계산과 한글 뜻 우선 설명 +- 두 사람의 출생 정보를 한 화면에 입력해 가까운 날짜별 추천 시간 확인 +- 한 사람에게만 유리한 후보를 줄이고, 두 사람의 일지와 날짜·시간 지지 관계를 + 함께 보여 주는 설명 가능한 문화적 점수 +- 추천 결과를 별도 CalDAV 캘린더로 저장하고 반복 동기화 - 출생 도시에서 IANA 시간대를 자동 선택하고 좌표 입력 없이 표준시 계산 - 선택한 도시의 서버 내부 경도를 사용하는 진태양시 보정 선택 - 일주·시주의 천간, 지지, 오행을 조합하는 허용 목록 기반 규칙 @@ -20,6 +24,12 @@ - HTTP Basic으로 보호되는 한국어 운영자 웹 콘솔 - SQLite 메타데이터와 소유자 전용 CalDAV 저장소 +첫 화면의 `두 사람의 좋은 시간 찾기`에서 저장된 프로필을 선택하거나 두 사람의 +생년월일과 탄생 시를 바로 입력하면 됩니다. 기본 검색은 프로필 시간대의 지금 +이후부터 1년 안에서 날짜마다 가장 균형이 높은 시간 하나를 골라 가까운 순으로 +보여 줍니다. 더 세밀한 천간·지지 조건은 아래의 `고급: 직접 조건 만들기`에서 +계속 사용할 수 있습니다. + ## 빠른 시작 Python 3.12 이상과 [uv](https://docs.astral.sh/uv/)를 사용합니다. diff --git a/app/compatibility.py b/app/compatibility.py new file mode 100644 index 0000000..0410869 --- /dev/null +++ b/app/compatibility.py @@ -0,0 +1,203 @@ +"""Transparent, culturally grounded two-person date ranking.""" + +from __future__ import annotations + +from dataclasses import dataclass +from datetime import date, datetime + +from app.events import MatchingWindow, iter_chart_windows +from app.saju import Chart + +SIX_HARMONIES = ( + frozenset(("子", "丑")), + frozenset(("寅", "亥")), + frozenset(("卯", "戌")), + frozenset(("辰", "酉")), + frozenset(("巳", "申")), + frozenset(("午", "未")), +) +THREE_HARMONIES = ( + frozenset(("申", "子", "辰")), + frozenset(("亥", "卯", "未")), + frozenset(("寅", "午", "戌")), + frozenset(("巳", "酉", "丑")), +) +SIX_CLASHES = ( + frozenset(("子", "午")), + frozenset(("丑", "未")), + frozenset(("寅", "申")), + frozenset(("卯", "酉")), + frozenset(("辰", "戌")), + frozenset(("巳", "亥")), +) + +DAY_POINTS = { + "six_harmony": 24, + "three_harmony": 16, + "same": 8, + "neutral": 0, + "clash": -32, +} +HOUR_POINTS = { + "six_harmony": 12, + "three_harmony": 8, + "same": 4, + "neutral": 0, + "clash": -16, +} +MINIMUM_SCORE = 60 + + +@dataclass(frozen=True, slots=True) +class CompatibilityCandidate: + window: MatchingWindow + score: int + primary_score: int + secondary_score: int + label: str + reasons: tuple[str, ...] + + +def _relation(left: str, right: str) -> str: + if left == right: + return "same" + pair = frozenset((left, right)) + if pair in SIX_HARMONIES: + return "six_harmony" + if any(pair <= group for group in THREE_HARMONIES): + return "three_harmony" + if pair in SIX_CLASHES: + return "clash" + return "neutral" + + +def _bounded_score(day_relation: str, hour_relation: str) -> int: + return max( + 0, + min(100, 50 + DAY_POINTS[day_relation] + HOUR_POINTS[hour_relation]), + ) + + +def _reason(person: str, period: str, relation: str) -> str | None: + relation_labels = { + "six_harmony": "서로 짝을 이루는 육합", + "three_harmony": "같은 흐름을 이루는 삼합 계열", + "same": "같은 지지", + } + if relation not in relation_labels: + return None + return f"{period}의 기운이 {person}의 일지와 {relation_labels[relation]}입니다." + + +def _label(score: int) -> str: + if score >= 80: + return "두 사람 모두에게 조화가 큰 시간" + if score >= 70: + return "두 사람에게 고르게 잘 맞는 시간" + return "두 사람에게 무난하게 어울리는 시간" + + +def score_window( + window: MatchingWindow, + primary: Chart, + secondary: Chart, + primary_name: str, + secondary_name: str, +) -> CompatibilityCandidate | None: + """Score one window without presenting the convention as a prediction.""" + + current_day = window.chart.day.branch + current_hour = window.chart.hour.branch + primary_day_relation = _relation(current_day, primary.day.branch) + secondary_day_relation = _relation(current_day, secondary.day.branch) + primary_hour_relation = _relation(current_hour, primary.day.branch) + secondary_hour_relation = _relation(current_hour, secondary.day.branch) + relations = ( + primary_day_relation, + secondary_day_relation, + primary_hour_relation, + secondary_hour_relation, + ) + if "clash" in relations: + return None + + primary_score = _bounded_score(primary_day_relation, primary_hour_relation) + secondary_score = _bounded_score(secondary_day_relation, secondary_hour_relation) + lower = min(primary_score, secondary_score) + average = (primary_score + secondary_score) / 2 + score = round(lower * 0.7 + average * 0.3) + if score < MINIMUM_SCORE or all(relation == "neutral" for relation in relations): + return None + + reasons = tuple( + reason + for reason in ( + _reason(primary_name, "이 날짜", primary_day_relation), + _reason(secondary_name, "이 날짜", secondary_day_relation), + _reason(primary_name, "이 시간", primary_hour_relation), + _reason(secondary_name, "이 시간", secondary_hour_relation), + ) + if reason is not None + ) + return CompatibilityCandidate( + window=window, + score=score, + primary_score=primary_score, + secondary_score=secondary_score, + label=_label(score), + reasons=reasons, + ) + + +def generate_compatibility_candidates( + primary: Chart, + secondary: Chart, + primary_name: str, + secondary_name: str, + start_date: date, + end_date: date, + timezone: str, + time_mode: str, + longitude: float | None, + limit: int = 24, + not_before: datetime | None = None, +) -> list[CompatibilityCandidate]: + """Return the earliest balanced candidate per eligible calendar day.""" + + if not 1 <= limit <= 96: + raise ValueError("limit must be between 1 and 96") + + best_by_date: dict[date, CompatibilityCandidate] = {} + for window in iter_chart_windows( + start_date, + end_date, + timezone, + time_mode, + longitude, + ): + if not_before is not None and window.end <= not_before: + continue + candidate = score_window( + window, + primary, + secondary, + primary_name, + secondary_name, + ) + if candidate is None: + continue + candidate_date = candidate.window.start.date() + previous = best_by_date.get(candidate_date) + if previous is None or ( + candidate.score, + -candidate.window.start.hour, + ) > ( + previous.score, + -previous.window.start.hour, + ): + best_by_date[candidate_date] = candidate + + return sorted( + best_by_date.values(), + key=lambda candidate: candidate.window.start, + )[:limit] diff --git a/app/events.py b/app/events.py index 9cb2270..c15dd5b 100644 --- a/app/events.py +++ b/app/events.py @@ -2,12 +2,13 @@ from __future__ import annotations +from collections.abc import Iterator from dataclasses import dataclass from datetime import date, datetime, time, timedelta from zoneinfo import ZoneInfo from app.rules import Rule, matches -from app.saju import Chart, _true_solar_time, calculate_chart +from app.saju import Chart, _hour_pillar, _true_solar_time, calculate_chart SEGMENTS = ((0, 1),) + tuple((hour, hour + 2) for hour in range(1, 22, 2)) + ((23, 24),) @@ -48,16 +49,45 @@ def generate_windows( ) -> list[MatchingWindow]: """Return matching local-time intervals, inclusive of both boundary dates.""" + return [ + window + for window in iter_chart_windows( + start_date, + end_date, + timezone, + time_mode, + longitude, + ) + if matches(rule, natal=natal, current=window.chart) + ] + + +def iter_chart_windows( + start_date: date, + end_date: date, + timezone: str, + time_mode: str, + longitude: float | None, +) -> Iterator[MatchingWindow]: + """Yield every local double-hour window with its calculated chart.""" + if end_date < start_date: raise ValueError("end_date must not be before start_date") if (end_date - start_date).days + 1 > 730: raise ValueError("generation range must not exceed 730 days") zone = ZoneInfo(timezone) - windows: list[MatchingWindow] = [] for day_offset in range((end_date - start_date).days + 1): calculation_date = start_date + timedelta(days=day_offset) midnight = datetime.combine(calculation_date, time.min) + calculation_noon = midnight + timedelta(hours=12) + civil_noon = _civil_from_calculation_time( + calculation_noon, + zone, + time_mode, + longitude, + ) + day_chart = calculate_chart(civil_noon, timezone, time_mode, longitude) for start_hour, end_hour in SEGMENTS: calculation_start = midnight + timedelta(hours=start_hour) calculation_end = midnight + timedelta(hours=end_hour) @@ -65,15 +95,18 @@ def generate_windows( calculation_start, zone, time_mode, longitude ) civil_end = _civil_from_calculation_time(calculation_end, zone, time_mode, longitude) - civil_midpoint = civil_start + (civil_end - civil_start) / 2 - current = calculate_chart(civil_midpoint, timezone, time_mode, longitude) - if matches(rule, natal=natal, current=current): - windows.append( - MatchingWindow( - start=civil_start.replace(tzinfo=zone), - end=civil_end.replace(tzinfo=zone), - chart=current, - ) - ) - return windows - + calculation_midpoint = calculation_start + ( + calculation_end - calculation_start + ) / 2 + current = Chart( + year=day_chart.year, + month=day_chart.month, + day=day_chart.day, + hour=_hour_pillar(day_chart.day.stem, calculation_midpoint.hour), + calculation_local=calculation_midpoint, + ) + yield MatchingWindow( + start=civil_start.replace(tzinfo=zone), + end=civil_end.replace(tzinfo=zone), + chart=current, + ) diff --git a/app/main.py b/app/main.py index 719dde8..292c10b 100644 --- a/app/main.py +++ b/app/main.py @@ -18,6 +18,10 @@ from app.birth import BirthInput, normalize_birth from app.caldav import CalDavPublisher, SyncResult +from app.compatibility import ( + CompatibilityCandidate, + generate_compatibility_candidates, +) from app.events import MatchingWindow, generate_windows from app.locations import list_birth_cities, resolve_birth_place from app.rules import Rule, validate_rule @@ -81,6 +85,25 @@ class DateRange(BaseModel): end_date: date | None = None +class CompatibilityRequest(DateRange): + primary_profile_id: str = Field(min_length=1, max_length=80) + secondary_profile_id: str = Field(min_length=1, max_length=80) + limit: int = Field(default=12, ge=1, le=96) + + +class CompatibilityCalendarCreate(BaseModel): + primary_profile_id: str = Field(min_length=1, max_length=80) + secondary_profile_id: str = Field(min_length=1, max_length=80) + name: str = Field(min_length=1, max_length=100) + slug: str = Field( + min_length=1, + max_length=80, + pattern=r"^[a-z0-9]+(?:-[a-z0-9]+)*$", + ) + visibility: Literal["private", "confidential", "public"] = "private" + limit: int = Field(default=36, ge=1, le=96) + + def _now(zone: ZoneInfo) -> datetime: return datetime.now(zone) @@ -151,11 +174,105 @@ def _calendar_context( return calendar, profile, rule, natal +def _compatibility_context( + store: Store, + primary_profile_id: str, + secondary_profile_id: str, +) -> tuple[dict[str, object], dict[str, object], Chart, Chart]: + if primary_profile_id == secondary_profile_id: + raise HTTPException( + status_code=422, + detail="서로 다른 두 사람의 프로필을 선택하세요", + ) + primary = store.get_profile(primary_profile_id) + secondary = store.get_profile(secondary_profile_id) + if primary is None or secondary is None: + raise HTTPException(status_code=404, detail="profile not found") + try: + return primary, secondary, _profile_chart(primary), _profile_chart(secondary) + except ZoneInfoNotFoundError as error: + raise HTTPException( + status_code=422, + detail="저장된 시간대 정보를 사용할 수 없습니다", + ) from error + except (TypeError, ValueError) as error: + raise HTTPException(status_code=422, detail=str(error)) from error + + +def _compatibility_candidates( + store: Store, + requested: CompatibilityRequest, +) -> tuple[dict[str, object], dict[str, object], list[CompatibilityCandidate]]: + primary, secondary, primary_chart, secondary_chart = _compatibility_context( + store, + requested.primary_profile_id, + requested.secondary_profile_id, + ) + try: + timezone = str(primary["timezone"]) + zone = ZoneInfo(timezone) + current = _now(zone) + start_date, end_date = _resolve_date_range(requested, zone, current) + candidates = generate_compatibility_candidates( + primary_chart, + secondary_chart, + str(primary["name"]), + str(secondary["name"]), + start_date, + end_date, + timezone, + str(primary["time_mode"]), + float(primary["longitude"]) if primary["longitude"] is not None else None, + requested.limit, + current if requested.start_date is None else None, + ) + except ZoneInfoNotFoundError as error: + raise HTTPException( + status_code=422, + detail="저장된 시간대 정보를 사용할 수 없습니다", + ) from error + except ValueError as error: + raise HTTPException(status_code=422, detail=str(error)) from error + return primary, secondary, candidates + + +def _candidate_json(candidate: CompatibilityCandidate) -> dict[str, object]: + window = candidate.window + return { + "start": window.start.isoformat(), + "end": window.end.isoformat(), + "score": candidate.score, + "primary_score": candidate.primary_score, + "secondary_score": candidate.secondary_score, + "label": candidate.label, + "reasons": list(candidate.reasons), + "day_pillar": window.chart.day.ganzhi, + "hour_pillar": window.chart.hour.ganzhi, + "day_branch_korean": window.chart.day.branch_korean, + "hour_branch_korean": window.chart.hour.branch_korean, + } + + def _windows( store: Store, calendar_id: str, requested: DateRange, ) -> tuple[dict[str, object], list[MatchingWindow]]: + stored_calendar = store.get_calendar(calendar_id) + if stored_calendar is None: + raise HTTPException(status_code=404, detail="calendar not found") + if stored_calendar.get("kind") == "compatibility": + settings = dict(stored_calendar["rule"]) + requested_pair = CompatibilityRequest( + primary_profile_id=str(stored_calendar["profile_id"]), + secondary_profile_id=str(stored_calendar["secondary_profile_id"]), + start_date=requested.start_date, + end_date=requested.end_date, + limit=int(settings.get("limit", 36)), + ) + _, _, candidates = _compatibility_candidates(store, requested_pair) + return stored_calendar, [candidate.window for candidate in candidates] + calendar, profile, rule, natal = _calendar_context(store, calendar_id) try: timezone = str(profile["timezone"]) @@ -319,6 +436,7 @@ def create_calendar(requested: CalendarCreate) -> dict[str, object]: slug=requested.slug, visibility=requested.visibility, rule=requested.rule, + kind="rule", ) except ValueError as error: raise HTTPException(status_code=422, detail=str(error)) from error @@ -332,6 +450,29 @@ def delete_calendar(calendar_id: str) -> None: @api.post("/calendars/{calendar_id}/preview") def preview_calendar(calendar_id: str, requested: DateRange) -> dict[str, object]: + calendar = metadata_store.get_calendar(calendar_id) + if calendar is None: + raise HTTPException(status_code=404, detail="calendar not found") + if calendar.get("kind") == "compatibility": + settings = dict(calendar["rule"]) + compatibility_request = CompatibilityRequest( + primary_profile_id=str(calendar["profile_id"]), + secondary_profile_id=str(calendar["secondary_profile_id"]), + start_date=requested.start_date, + end_date=requested.end_date, + limit=int(settings.get("limit", 36)), + ) + primary, secondary, candidates = _compatibility_candidates( + metadata_store, + compatibility_request, + ) + return { + "count": len(candidates), + "primary_name": primary["name"], + "secondary_name": secondary["name"], + "method": "balanced_branch_harmony", + "events": [_candidate_json(candidate) for candidate in candidates], + } _, windows = _windows(metadata_store, calendar_id, requested) return { "count": len(windows), @@ -348,6 +489,51 @@ def preview_calendar(calendar_id: str, requested: DateRange) -> dict[str, object ], } + @api.post("/compatibility/preview") + def preview_compatibility(requested: CompatibilityRequest) -> dict[str, object]: + primary, secondary, candidates = _compatibility_candidates( + metadata_store, + requested, + ) + return { + "count": len(candidates), + "primary_name": primary["name"], + "secondary_name": secondary["name"], + "method": "balanced_branch_harmony", + "events": [_candidate_json(candidate) for candidate in candidates], + } + + @api.post( + "/compatibility/calendars", + status_code=status.HTTP_201_CREATED, + ) + def create_compatibility_calendar( + requested: CompatibilityCalendarCreate, + ) -> dict[str, object]: + _compatibility_context( + metadata_store, + requested.primary_profile_id, + requested.secondary_profile_id, + ) + try: + return metadata_store.create_calendar( + profile_id=requested.primary_profile_id, + secondary_profile_id=requested.secondary_profile_id, + name=requested.name, + slug=requested.slug, + visibility=requested.visibility, + kind="compatibility", + rule={ + "mode": "balanced_branch_harmony", + "limit": requested.limit, + }, + ) + except sqlite3.IntegrityError as error: + raise HTTPException( + status_code=409, + detail="calendar slug already exists", + ) from error + @api.post("/calendars/{calendar_id}/sync") def sync_calendar(calendar_id: str, requested: DateRange) -> dict[str, object]: calendar, windows = _windows(metadata_store, calendar_id, requested) diff --git a/app/static/app.js b/app/static/app.js index 91444a0..126b3d1 100644 --- a/app/static/app.js +++ b/app/static/app.js @@ -1,4 +1,9 @@ -const state = { profiles: [], calendars: [], locations: [] }; +const state = { + profiles: [], + calendars: [], + locations: [], + compatibility: null, +}; const stems = [..."甲乙丙丁戊己庚辛壬癸"]; const branches = [..."子丑寅卯辰巳午未申酉戌亥"]; @@ -63,6 +68,14 @@ function serializeForm(form) { return Object.fromEntries(new FormData(form).entries()); } +function profileOption(profile, technical = false) { + const place = profile.birth_city_name || profile.timezone; + const detail = technical + ? ` · 일지 ${profile.chart.day.branch_korean}, 시간 ${profile.chart.hour.stem_korean}` + : ""; + return ``; +} + function pillarMarkup(label, pillar) { return `
${escapeHtml(label)} @@ -93,26 +106,38 @@ function renderProfiles() { $("#chart-result").hidden = true; return; } - select.innerHTML = state.profiles - .map((profile) => ``) - .join(""); + select.innerHTML = state.profiles.map((profile) => profileOption(profile, true)).join(""); select.value = state.profiles.some((profile) => profile.id === selected) ? selected : state.profiles.at(-1).id; showChart(state.profiles.find((profile) => profile.id === select.value)); + + document.querySelectorAll("[data-profile-choice]").forEach((choice) => { + const previous = choice.value; + choice.innerHTML = [ + '', + ...state.profiles.map((profile) => profileOption(profile)), + ].join(""); + choice.value = state.profiles.some((profile) => profile.id === previous) + ? previous + : ""; + toggleNewPersonFields(choice); + }); } function renderLocations() { - const select = $("#birth-city"); - const selected = select.value; - select.innerHTML = [ - ...state.locations.map((location) => ``), - '', - ].join(""); - select.value = state.locations.some((location) => location.id === selected) - ? selected - : ""; + document.querySelectorAll("#birth-city, [data-birth-city]").forEach((select) => { + const selected = select.value; + select.innerHTML = [ + ...state.locations.map((location) => ``), + '', + ].join(""); + select.value = state.locations.some((location) => location.id === selected) + ? selected + : (state.locations[0]?.id || ""); + }); updatePlaceFields(); + document.querySelectorAll("[data-person]").forEach(updatePairPlace); } function updatePlaceFields() { @@ -134,6 +159,36 @@ function updatePlaceFields() { : "IANA 시간대(예: Asia/Seoul)를 직접 입력하세요. 좌표는 필요하지 않습니다."; } +function toggleNewPersonFields(choice) { + const prefix = choice.dataset.profileChoice; + const fields = document.querySelector(`[data-new-profile="${prefix}"]`); + const creating = !choice.value; + fields.hidden = !creating; + fields.querySelectorAll("input, select").forEach((control) => { + control.disabled = !creating; + }); +} + +function updatePairPlace(personCard) { + const cityId = personCard.querySelector("[data-birth-city]").value; + const city = state.locations.find((location) => location.id === cityId); + const timezone = personCard.querySelector("[data-timezone]"); + const trueSolar = personCard.querySelector("[data-time-mode]").value === "true_solar"; + const note = personCard.querySelector("[data-place-note]"); + if (city) { + timezone.value = city.timezone; + timezone.readOnly = true; + note.textContent = trueSolar + ? `${city.label}의 경도를 자동 적용합니다. 위도와 좌표 입력은 필요하지 않습니다.` + : `${city.label}의 시간대 ${city.timezone}를 적용합니다.`; + return; + } + timezone.readOnly = false; + note.textContent = trueSolar + ? "진태양시는 위 도시 목록에서 선택해야 합니다." + : "IANA 시간대(예: Asia/Seoul)를 입력하세요. 좌표는 필요하지 않습니다."; +} + function predicateLabel(predicate) { const labels = { "day.branch": "일주의 지지", @@ -159,7 +214,11 @@ function renderCalendars() { } list.innerHTML = state.calendars .map((calendar) => { - const rule = calendar.rule.predicates.map(predicateLabel).join(calendar.rule.logic === "all" ? " 그리고 " : " 또는 "); + const primary = state.profiles.find((profile) => profile.id === calendar.profile_id); + const secondary = state.profiles.find((profile) => profile.id === calendar.secondary_profile_id); + const rule = calendar.kind === "compatibility" + ? `${primary?.name || "첫 번째 사람"} · ${secondary?.name || "두 번째 사람"}에게 고르게 어울리는 시간` + : calendar.rule.predicates.map(predicateLabel).join(calendar.rule.logic === "all" ? " 그리고 " : " 또는 "); const synced = calendar.last_synced_at ? new Date(calendar.last_synced_at).toLocaleString("ko-KR") : "아직 동기화하지 않음"; return `
@@ -197,6 +256,94 @@ function updateValueSelect(fieldSelector, valueSelector, preferred) { .join(""); } +function pairProfilePayload(form, prefix) { + const values = serializeForm(form); + const leapMonth = form.elements.namedItem(`${prefix}_is_leap_month`); + return { + name: values[`${prefix}_name`], + birth_calendar: values[`${prefix}_birth_calendar`], + birth_year: Number(values[`${prefix}_birth_year`]), + birth_month: Number(values[`${prefix}_birth_month`]), + birth_day: Number(values[`${prefix}_birth_day`]), + birth_time: values[`${prefix}_birth_time`], + is_leap_month: leapMonth.checked, + birth_city: values[`${prefix}_birth_city`] || null, + gender: values[`${prefix}_gender`], + timezone: values[`${prefix}_timezone`], + time_mode: values[`${prefix}_time_mode`], + longitude: null, + }; +} + +async function resolvePairProfile(form, prefix) { + const profileId = form.elements.namedItem(`${prefix}_profile_id`).value; + if (profileId) { + const profile = state.profiles.find((item) => item.id === profileId); + if (!profile) throw new Error("선택한 사람을 다시 불러오지 못했습니다."); + return profile; + } + return api("/api/profiles", { + method: "POST", + body: JSON.stringify(pairProfilePayload(form, prefix)), + }); +} + +function renderCompatibility(result, primary, secondary) { + state.compatibility = { result, primary, secondary }; + $("#pair-names").textContent = `${primary.name} · ${secondary.name}`; + $("#pair-result-count").textContent = `${result.count}개의 가까운 후보를 찾았습니다.`; + $("#pair-timezone").textContent = `시간은 첫 번째 사람의 ${primary.timezone} 기준입니다.`; + const list = $("#compatibility-list"); + if (!result.events.length) { + list.innerHTML = '
  • 1년 안에서 추천 기준을 만족하는 시간을 찾지 못했습니다.
  • '; + } else { + list.innerHTML = result.events.map((item) => { + const start = new Date(item.start); + const end = new Date(item.end); + const day = start.toLocaleDateString("ko-KR", { + year: "numeric", + month: "long", + day: "numeric", + weekday: "short", + timeZone: primary.timezone, + }); + const timeOptions = { + hour: "numeric", + minute: "2-digit", + timeZone: primary.timezone, + }; + const timeRange = `${start.toLocaleTimeString("ko-KR", timeOptions)}–${end.toLocaleTimeString("ko-KR", timeOptions)}`; + const reasons = item.reasons.slice(0, 3) + .map((reason) => `
  • ${escapeHtml(reason)}
  • `) + .join(""); + return `
  • +
    + + ${escapeHtml(timeRange)} +
    +
    + ${escapeHtml(item.score)}조화 점수 +
    +
    +

    ${escapeHtml(item.label)}

    +
      ${reasons}
    +
    + 사주 표기 보기 + 일지 ${escapeHtml(item.day_branch_korean)} · 시간 ${escapeHtml(item.hour_branch_korean)} + ${escapeHtml(item.day_pillar)} / ${escapeHtml(item.hour_pillar)} +
    +
    +
  • `; + }).join(""); + } + const calendarForm = document.getElementById("compatibility-calendar-form"); + calendarForm.elements.namedItem("primary_profile_id").value = primary.id; + calendarForm.elements.namedItem("secondary_profile_id").value = secondary.id; + calendarForm.elements.namedItem("name").value = "둘이 좋은 시간"; + $("#compatibility-result").hidden = false; + $("#compatibility-result").scrollIntoView({ behavior: "smooth", block: "start" }); +} + async function refresh() { [state.profiles, state.calendars, state.locations] = await Promise.all([ api("/api/profiles"), @@ -214,6 +361,25 @@ $("#time-mode").addEventListener("change", (event) => { $("#birth-city").addEventListener("change", updatePlaceFields); +$("#pair-form").addEventListener("change", (event) => { + const choice = event.target.closest("[data-profile-choice]"); + if (choice) { + toggleNewPersonFields(choice); + return; + } + const personCard = event.target.closest("[data-person]"); + if (!personCard) return; + if (event.target.matches("[data-calendar-kind]")) { + const lunar = event.target.value === "lunar"; + const leap = personCard.querySelector("[data-leap-month]"); + leap.hidden = !lunar; + if (!lunar) leap.querySelector("input").checked = false; + } + if (event.target.matches("[data-birth-city], [data-time-mode]")) { + updatePairPlace(personCard); + } +}); + $("#birth-calendar").addEventListener("change", (event) => { const lunar = event.target.value === "lunar"; $("#leap-month-field").hidden = !lunar; @@ -245,7 +411,7 @@ $("#profile-form").addEventListener("submit", async (event) => { birth_year: Number(values.birth_year), birth_month: Number(values.birth_month), birth_day: Number(values.birth_day), - is_leap_month: event.currentTarget.elements.is_leap_month.checked, + is_leap_month: event.currentTarget.elements.namedItem("is_leap_month").checked, birth_city: values.birth_city || null, longitude: null, }; @@ -263,6 +429,66 @@ $("#profile-form").addEventListener("submit", async (event) => { } }); +$("#pair-form").addEventListener("submit", async (event) => { + event.preventDefault(); + const form = event.currentTarget; + const button = form.querySelector('button[type="submit"]'); + button.disabled = true; + button.textContent = "두 사람의 시간을 계산하고 있습니다…"; + try { + const primary = await resolvePairProfile(form, "primary"); + const secondary = await resolvePairProfile(form, "secondary"); + if (primary.id === secondary.id) { + throw new Error("서로 다른 두 사람을 선택하세요."); + } + const result = await api("/api/compatibility/preview", { + method: "POST", + body: JSON.stringify({ + primary_profile_id: primary.id, + secondary_profile_id: secondary.id, + limit: 12, + }), + }); + await refresh(); + const primaryChoice = $('[data-profile-choice="primary"]'); + const secondaryChoice = $('[data-profile-choice="secondary"]'); + primaryChoice.value = primary.id; + secondaryChoice.value = secondary.id; + toggleNewPersonFields(primaryChoice); + toggleNewPersonFields(secondaryChoice); + renderCompatibility(result, primary, secondary); + notify(`${primary.name} · ${secondary.name}의 가까운 좋은 시간을 찾았습니다.`); + } catch (error) { + notify(`좋은 시간을 찾지 못했습니다: ${error.message}`, true); + } finally { + button.disabled = false; + button.textContent = "둘이 좋은 날과 시간 찾기"; + } +}); + +$("#compatibility-calendar-form").addEventListener("submit", async (event) => { + event.preventDefault(); + const values = serializeForm(event.currentTarget); + const button = event.currentTarget.querySelector('button[type="submit"]'); + button.disabled = true; + try { + const calendar = await api("/api/compatibility/calendars", { + method: "POST", + body: JSON.stringify({ + ...values, + limit: 36, + }), + }); + await refresh(); + notify(`‘${calendar.name}’ 캘린더를 저장했습니다. 아래에서 동기화할 수 있습니다.`); + $(".library").scrollIntoView({ behavior: "smooth", block: "start" }); + } catch (error) { + notify(`캘린더를 저장하지 못했습니다: ${error.message}`, true); + } finally { + button.disabled = false; + } +}); + $("#calendar-form").addEventListener("submit", async (event) => { event.preventDefault(); const values = serializeForm(event.currentTarget); @@ -313,11 +539,15 @@ $("#calendar-list").addEventListener("click", async (event) => { card.querySelector(".preview")?.remove(); const preview = document.createElement("div"); preview.className = "preview"; + const primary = state.profiles.find((profile) => profile.id === calendar.profile_id); + const displayOptions = primary ? { timeZone: primary.timezone } : {}; const items = result.events.slice(0, 9) .map((item) => `
  • - ${escapeHtml(new Date(item.start).toLocaleString("ko-KR"))} - · 일지 ${escapeHtml(item.day_branch_korean)}, 시간 ${escapeHtml(item.hour_stem_korean)} -
    한자 표기${escapeHtml(item.day_pillar)} / ${escapeHtml(item.hour_pillar)}
    + ${escapeHtml(new Date(item.start).toLocaleString("ko-KR", displayOptions))} + ${item.score + ? `· 조화 점수 ${escapeHtml(item.score)}점 · ${escapeHtml(item.label)}` + : `· 일지 ${escapeHtml(item.day_branch_korean)}, 시간 ${escapeHtml(item.hour_stem_korean)}`} +
    사주 표기${escapeHtml(item.day_pillar)} / ${escapeHtml(item.hour_pillar)}
  • `) .join(""); preview.innerHTML = `${result.count}개 시간을 찾았습니다.
      ${items}
    `; diff --git a/app/static/index.html b/app/static/index.html index 5a41060..3ae921f 100644 --- a/app/static/index.html +++ b/app/static/index.html @@ -20,23 +20,267 @@
    -

    Four Pillars → CalDAV

    -

    사주 명식을
    시간 캘린더로

    +
    +

    두 사람 → 좋은 시간 → CalDAV

    +

    + 둘이 좋은 날을 + 쉽게 찾으세요 +

    +

    - 양력이나 한국 음력 출생 시각에서 네 기둥을 계산하고, 원하는 - 사주 조건이 다시 만나는 실제 날짜와 시간을 개인 CalDAV 캘린더로 발행합니다. + 내 생일과 상대의 생일을 한 번에 넣으면 오늘부터 두 사람에게 + 고르게 어울리는 날짜와 시간을 찾아드립니다.

    - 문화·역법 참고 도구입니다. 운세의 과학적 타당성이나 인과관계를 - 주장하지 않습니다. + 사주를 잘 몰라도 됩니다. 결과에는 점수와 선정 이유를 쉬운 한국어로 + 보여드리며, 한자는 보조 설명에만 사용합니다.

    - +
    +
    +
    +

    01 · 함께 보기

    +

    두 사람의 좋은 시간 찾기

    +

    + 저장된 사람을 불러오거나 두 사람의 출생 정보를 바로 입력하세요. + 나머지는 자동으로 계산합니다. +

    +
    +
      +
    1. 1두 사람 입력
    2. +
    3. 2좋은 시간 확인
    4. +
    5. 3캘린더 저장
    6. +
    +
    + +
    +
    +
    + 나 첫 번째 사람 + +
    + + + +
    + + + +
    + + +
    + 성별·시간 기준 설정 +
    + + + +
    +

    + 선택한 도시의 시간대를 자동 적용합니다. +

    +
    +
    +
    + +
    + 상대 두 번째 사람 + +
    + + + +
    + + + +
    + + +
    + 성별·시간 기준 설정 +
    + + + +
    +

    + 선택한 도시의 시간대를 자동 적용합니다. +

    +
    +
    +
    +
    +

    + 출생 정보는 이 운영자 전용 서버에만 저장되며 CalDAV 일정에는 포함되지 않습니다. +

    + +
    + + +
    + +
    + + 고급 기능 + 명식과 조건을 직접 골라 캘린더 만들기 + +

    + 일주·시주·오행 조건을 직접 지정하려는 사용자만 펼쳐서 사용하세요. +

    + -
    +

    01 · 명식

    @@ -214,11 +458,12 @@

    맞춤 캘린더

    +
    -

    03 · 발행

    +

    03 · 내 캘린더

    내 캘린더 서가

    미리보기로 확인한 뒤 CalDAV에 동기화하세요.

    diff --git a/app/static/styles.css b/app/static/styles.css index a3b415b..610ec45 100644 --- a/app/static/styles.css +++ b/app/static/styles.css @@ -17,6 +17,10 @@ box-sizing: border-box; } +[hidden] { + display: none !important; +} + html { scroll-behavior: smooth; } @@ -118,10 +122,10 @@ footer { .hero { display: grid; - grid-template-columns: minmax(0, 1.2fr) minmax(280px, 0.8fr); - gap: 80px; - align-items: end; - padding: 100px 0 82px; + grid-template-columns: minmax(0, 1.35fr) minmax(300px, 0.65fr); + gap: 64px; + align-items: center; + padding: 72px 0 64px; } .eyebrow, @@ -149,9 +153,14 @@ h2 { h1 { margin-bottom: 0; - font-size: clamp(54px, 8vw, 104px); - letter-spacing: -0.065em; - line-height: 0.95; + max-width: 760px; + font-size: clamp(48px, 6.4vw, 78px); + letter-spacing: -0.055em; + line-height: 1.02; +} + +h1 span { + display: block; } h2 { @@ -173,6 +182,295 @@ h2 { font-size: 13px; } +.pair-flow { + border-top: 5px solid var(--ink); + border-right: 1px solid var(--hairline); + border-bottom: 1px solid var(--hairline); + border-left: 1px solid var(--hairline); + background: color-mix(in srgb, var(--paper-raised) 72%, transparent); +} + +.flow-heading { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: 48px; + align-items: end; + padding: 42px 48px 34px; + border-bottom: 1px solid var(--hairline); +} + +.flow-heading h2 { + margin-bottom: 10px; +} + +.flow-heading > div > p:last-child { + max-width: 620px; + margin-bottom: 0; + color: var(--muted); +} + +.flow-steps { + display: flex; + gap: 24px; + margin: 0; + padding: 0; + list-style: none; +} + +.flow-steps li { + display: grid; + gap: 5px; + min-width: 92px; +} + +.flow-steps b { + display: grid; + width: 28px; + height: 28px; + place-items: center; + border: 1px solid var(--vermilion); + color: var(--vermilion); + font-size: 12px; +} + +.flow-steps span { + color: var(--muted); + font-size: 12px; + white-space: nowrap; +} + +.pair-form { + padding: 40px 48px 48px; +} + +.people-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 20px; +} + +.person-card { + min-width: 0; + margin: 0; + padding: 28px; + border: 1px solid var(--hairline); + background: var(--paper-raised); +} + +.person-card legend { + padding: 0 10px; + font-family: var(--serif); + font-size: 23px; + font-weight: 700; +} + +.person-card legend span { + margin-right: 8px; + color: var(--vermilion); + font-family: var(--sans); + font-size: 12px; + letter-spacing: 0.08em; +} + +.new-person-fields { + margin-top: 8px; + padding-top: 24px; + border-top: 1px solid var(--hairline); +} + +.privacy-note { + margin: 18px 0; + color: var(--muted); + font-size: 13px; + text-align: center; +} + +.pair-submit { + display: block; + width: min(100%, 560px); + min-height: 60px; + margin-inline: auto; + font-size: 17px; +} + +.compatibility-result { + padding: 44px 48px 48px; + border-top: 5px solid var(--ink); +} + +.result-heading { + display: flex; + justify-content: space-between; + gap: 32px; + align-items: end; + margin-bottom: 24px; +} + +.result-heading h2, +.result-heading p { + margin-bottom: 0; +} + +.result-meta { + max-width: 240px; + text-align: right; +} + +.result-meta p { + color: var(--muted); +} + +.result-meta p + p { + margin-top: 4px; + font-size: 12px; +} + +.compatibility-list { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 14px; + margin: 0; + padding: 0; + list-style: none; +} + +.compatibility-card { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: 14px; + min-width: 0; + padding: 22px; + border: 1px solid var(--hairline); + background: var(--paper-raised); +} + +.candidate-when { + display: grid; + gap: 2px; +} + +.candidate-when time { + color: var(--muted); + font-size: 13px; +} + +.candidate-when strong { + font-family: var(--serif); + font-size: 23px; +} + +.score-badge { + display: grid; + width: 60px; + height: 60px; + place-items: center; + align-content: center; + border: 1px solid var(--vermilion); + color: var(--vermilion); + line-height: 1; +} + +.score-badge b { + font-family: var(--serif); + font-size: 21px; +} + +.score-badge span { + margin-top: 4px; + font-size: 9px; +} + +.candidate-copy { + grid-column: 1 / -1; + padding-top: 14px; + border-top: 1px solid var(--hairline); +} + +.candidate-copy h3 { + margin: 0 0 8px; + font-family: var(--serif); + font-size: 18px; +} + +.candidate-copy ul { + margin: 0; + padding-left: 18px; + color: var(--muted); + font-size: 12px; +} + +.candidate-copy details, +.method-note { + margin-top: 12px; + color: var(--muted); + font-size: 12px; +} + +.candidate-copy details span, +.candidate-copy details small { + display: block; + margin-top: 4px; +} + +.method-note { + padding: 18px 0; + border-bottom: 1px solid var(--hairline); +} + +.method-note summary { + cursor: pointer; + color: var(--ink); + font-weight: 700; +} + +.method-note p { + max-width: 820px; + margin: 10px 0 0; +} + +.calendar-save-form { + display: grid; + grid-template-columns: 1fr 1fr 0.8fr auto; + gap: 18px; + align-items: end; + margin-top: 26px; +} + +.calendar-save-form .button { + min-height: 49px; +} + +.expert-workspace { + margin-top: 48px; + border: 1px solid var(--hairline); +} + +.expert-workspace > summary { + display: flex; + gap: 14px; + align-items: center; + padding: 22px 26px; + cursor: pointer; + font-family: var(--serif); + font-size: 20px; + font-weight: 700; +} + +.expert-workspace > summary span { + padding: 3px 7px; + border: 1px solid var(--vermilion); + color: var(--vermilion); + font-family: var(--sans); + font-size: 10px; + letter-spacing: 0.08em; +} + +.expert-intro { + margin: -8px 26px 22px; + color: var(--muted); + font-size: 13px; +} + .rule { height: 5px; background: var(--ink); @@ -550,7 +848,9 @@ footer p { @media (max-width: 860px) { .hero, - .workspace { + .workspace, + .flow-heading, + .people-grid { grid-template-columns: 1fr; } @@ -571,6 +871,18 @@ footer p { .preview ol { grid-template-columns: 1fr 1fr; } + + .flow-steps { + justify-content: space-between; + } + + .compatibility-list { + grid-template-columns: 1fr 1fr; + } + + .calendar-save-form { + grid-template-columns: 1fr 1fr; + } } @media (max-width: 600px) { @@ -600,10 +912,45 @@ footer p { padding: 34px 22px; } + .flow-heading, + .pair-form, + .compatibility-result { + padding-right: 22px; + padding-left: 22px; + } + + .flow-steps { + gap: 10px; + } + + .flow-steps li { + min-width: 0; + } + + .flow-steps span { + white-space: normal; + } + + .person-card { + padding: 22px 18px; + } + + .result-heading { + display: block; + } + + .result-meta { + max-width: none; + margin-top: 12px; + text-align: left; + } + .form-grid, .predicate, .date-fields, - .advanced-grid { + .advanced-grid, + .compatibility-list, + .calendar-save-form { grid-template-columns: 1fr; } diff --git a/app/store.py b/app/store.py index ef75a21..5aadc9d 100644 --- a/app/store.py +++ b/app/store.py @@ -88,6 +88,15 @@ def initialize(self) -> None: "ALTER TABLE calendars ADD COLUMN visibility " "TEXT NOT NULL DEFAULT 'private'" ) + if "kind" not in calendar_columns: + connection.execute( + "ALTER TABLE calendars ADD COLUMN kind " + "TEXT NOT NULL DEFAULT 'rule'" + ) + if "secondary_profile_id" not in calendar_columns: + connection.execute( + "ALTER TABLE calendars ADD COLUMN secondary_profile_id TEXT" + ) connection.execute( """ UPDATE profiles @@ -192,6 +201,10 @@ def list_profiles(self) -> list[dict[str, object]]: def delete_profile(self, profile_id: str) -> bool: with self._connect() as connection: + connection.execute( + "DELETE FROM calendars WHERE secondary_profile_id = ?", + (profile_id,), + ) cursor = connection.execute("DELETE FROM profiles WHERE id = ?", (profile_id,)) return cursor.rowcount == 1 @@ -203,21 +216,26 @@ def create_calendar( slug: str, visibility: str, rule: dict[str, object], + kind: str = "rule", + secondary_profile_id: str | None = None, ) -> dict[str, object]: calendar_id = str(uuid4()) with self._connect() as connection: connection.execute( """ INSERT INTO calendars - (id, profile_id, name, slug, visibility, rule_json, created_at) - VALUES (?, ?, ?, ?, ?, ?, ?) + (id, profile_id, secondary_profile_id, name, slug, visibility, + kind, rule_json, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) """, ( calendar_id, profile_id, + secondary_profile_id, name, slug, visibility, + kind, json.dumps(rule, ensure_ascii=False, separators=(",", ":")), datetime.now(UTC).isoformat(), ), diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 1ddbff6..93433ab 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -7,6 +7,8 @@ flowchart LR L --> N[양력·한국 음력 입력 정규화] N --> S[(SQLite 원본 입력·양력 시각·규칙)] N --> E[사주 계산·규칙 엔진] + E --> P[두 사람 균형 점수·날짜별 후보] + P --> I E --> I[RFC 5545 VEVENT] I -->|CalDAV Basic, 내부망| R[Radicale] R --> C[(CalDAV 컬렉션)] @@ -28,6 +30,10 @@ flowchart LR 도시의 경도만 서버 내부에서 자동 적용합니다. - 규칙은 임의 코드나 식을 평가하지 않습니다. 8개 이하의 허용 필드 비교만 역직렬화합니다. +- 두 사람 추천은 두 프로필의 출생 일지와 후보의 날짜·시간 지지를 비교하는 + 고정된 허용 관계표만 사용합니다. 육충 후보를 제외하고 두 사람 중 낮은 점수를 + 더 크게 반영하며, 날짜마다 가장 높은 현재 이후 시간 하나만 남깁니다. 이 점수는 + 문화적 정렬 규칙이지 예측 모델의 확률이 아닙니다. - iCalendar 이벤트는 사용자가 선택한 `CLASS:PRIVATE`, `CLASS:CONFIDENTIAL`, `CLASS:PUBLIC` 중 하나와 `TRANSP:TRANSPARENT`로 생성됩니다. 이 분류는 캘린더 클라이언트 표시용이며 Radicale의 인증·`owner_only` 접근 제어를 바꾸지 않습니다. @@ -45,8 +51,9 @@ Radicale 볼륨에는 최소정보 iCalendar 리소스를 저장합니다. 어 Git 저장소에 포함하면 안 됩니다. 미리보기와 일반 동기화의 날짜 범위를 생략하면 프로필 시간대의 현재 날짜부터 -365일 뒤까지 검색합니다. 브라우저의 시간대나 UTC 날짜 변환은 이 기본값에 -관여하지 않습니다. +365일 뒤까지 검색하며 이미 끝난 시간은 제외합니다. 두 사람 추천은 이 범위에서 +날짜별 최고 후보 하나를 고른 뒤 가까운 순으로 반환합니다. 브라우저의 시간대나 +UTC 날짜 변환은 이 기본값에 관여하지 않습니다. ## 결정적 동기화 diff --git a/docs/research/README.md b/docs/research/README.md index 1d0c5df..a369c01 100644 --- a/docs/research/README.md +++ b/docs/research/README.md @@ -37,6 +37,13 @@ [비공식 역서의 음양력 오류 안내](https://www.kasi.re.kr/kor/publication/post/notice/385?clsf_cd=notice003). 한국 음력 변환은 한국천문연구원 기준을 따르는 구현을 선택해야 한다는 근거로 사용했습니다. +7. Chao Wei-pang, “The Chinese Science of Fate-Calculation,” + *Folklore Studies* 5(1), 1946, 279–315, + [DOI 10.2307/3182937](https://doi.org/10.2307/3182937), + [원문 정보](https://asianethnology.org/article/147961). + 일주를 중심 요소로 삼는 역사적 해석과 지지의 육합·삼합·충 관계를 확인하는 + 문화사 자료로 사용했습니다. 이 문헌은 관계 분류의 역사적 존재를 뒷받침할 + 뿐, 추천 점수의 과학적 타당성이나 미래 사건과의 인과를 입증하지 않습니다. ## 저장소의 계산 규칙 @@ -57,6 +64,23 @@ 대운의 순행·역행은 MVP 범위 밖입니다. - 생성 범위는 요청 한 번에 최대 730일입니다. +### 두 사람의 시간 추천 규칙 + +두 사람 추천은 위 문헌의 관계 분류를 제품에서 투명하게 조합한 저장소 고유의 +관례입니다. 단일 학파의 정답이나 객관적인 궁합·길일 판정으로 표현하지 않습니다. + +- 각 사람의 출생 일지와 후보 날짜의 지지, 후보 시간의 지지를 각각 비교합니다. +- 육합, 같은 삼합 계열, 같은 지지는 가점으로 설명하고 육충이 하나라도 있으면 + 그 시간은 추천하지 않습니다. +- 두 사람 점수 중 낮은 쪽에 70%, 두 점수의 평균에 30%를 두어 한 사람에게만 + 높은 후보가 상단에 오르기 어렵게 합니다. +- 60점 이상이며 설명할 관계가 있는 후보만 남기고, 하루에는 가장 높은 시간 + 하나만 표시합니다. 동점이면 더 이른 시간을 고릅니다. +- 날짜 범위를 생략하면 프로필 시간대의 현재 시각보다 뒤에 끝나는 구간부터 + 1년을 검색해 가까운 날짜 순으로 반환합니다. +- 점수는 비교와 정렬을 쉽게 만드는 제품 규칙이며 삶의 결정을 위한 확률, + 예측값 또는 과학적 측정치가 아닙니다. + ## 구현과 독립 검증 한국 음력 변환은 한국천문연구원 기준의 `korean-lunar-calendar`를 사용합니다. @@ -83,6 +107,7 @@ PyPI 배포물은 소스 아카이브만 제공하므로, 이 방식은 컨테 - 도시 선택에 따른 시간대·서울 경도 자동 적용과 진태양시 보정 범위 - 규칙 필드·리터럴 허용 목록 - 합성 예시의 이중 시각 구간과 현재 지역 날짜 이후 검색 +- 두 사람 모두에게 불리한 육충 제외, 균형 점수, 날짜별 한 후보와 현재 이후 필터 - 결정적 UID와 공개 수준별 `CLASS`·투명·최소정보 iCalendar 이벤트 ## 한계 diff --git a/scripts/acceptance_smoke.py b/scripts/acceptance_smoke.py index a5e1093..2758893 100644 --- a/scripts/acceptance_smoke.py +++ b/scripts/acceptance_smoke.py @@ -51,6 +51,45 @@ def api_json( return status, json.loads(raw) if raw else None +def assert_caldav_collection( + caldav_base: str, + collection_url: str, + username: str, + password: str, + expected_class: bytes, +) -> None: + status, listing = request( + "PROPFIND", + collection_url, + username, + password, + ( + b'' + b"" + ), + "application/xml", + {"Depth": "1"}, + ) + assert status == 207 and b".ics" in listing, (status, listing[:500]) + root = ElementTree.fromstring(listing) + href = next( + element.text + for element in root.iter() + if element.tag.endswith("href") + and element.text + and element.text.endswith(".ics") + ) + status, event = request( + "GET", + urljoin(caldav_base.rstrip("/") + "/", href.lstrip("/")), + username, + password, + ) + assert status == 200, (status, event[:500]) + assert b"CLASS:" + expected_class in event + assert b"X-SAJU" not in event + + def main() -> None: app_base = os.environ.get("APP_BASE_URL", "http://127.0.0.1:8000") app_user = os.environ["APP_USERNAME"] @@ -68,7 +107,11 @@ def main() -> None: raise ValueError("SMOKE_VISIBILITY must be private, confidential, or public") suffix = f"{int(time.time())}-{os.getpid()}" profile_id = "" + secondary_profile_id = "" collection_url = f"{caldav_base.rstrip('/')}/{quote(caldav_user, safe='')}/smoke-{suffix}/" + pair_collection_url = ( + f"{caldav_base.rstrip('/')}/{quote(caldav_user, safe='')}/pair-smoke-{suffix}/" + ) private_birth = os.environ.get("PRIVATE_BIRTH_LOCAL") birth_local = ( datetime.fromisoformat(private_birth) @@ -170,40 +213,113 @@ def main() -> None: assert status == 200 and isinstance(synced, dict), (status, synced) assert synced["event_count"] == preview["count"] - status, listing = request( - "PROPFIND", + assert_caldav_collection( + caldav_base, collection_url, caldav_user, caldav_password, - ( - b'' - b"" - ), - "application/xml", - {"Depth": "1"}, + visibility_classes[visibility], ) - assert status == 207 and b".ics" in listing, (status, listing[:500]) - root = ElementTree.fromstring(listing) - href = next( - element.text - for element in root.iter() - if element.tag.endswith("href") - and element.text - and element.text.endswith(".ics") + + status, secondary_profile = api_json( + "POST", + app_base, + "/api/profiles", + app_user, + app_password, + { + "name": f"pair-smoke-{suffix}", + "birth_calendar": "lunar", + "birth_year": 2000, + "birth_month": 1, + "birth_day": 2, + "birth_time": "08:30:00", + "is_leap_month": False, + "gender": "unspecified", + "birth_city": "seoul", + "timezone": "Asia/Seoul", + "time_mode": "civil", + "longitude": None, + }, ) - status, event = request( - "GET", - urljoin(caldav_base.rstrip("/") + "/", href.lstrip("/")), + assert status == 201 and isinstance(secondary_profile, dict), ( + status, + secondary_profile, + ) + secondary_profile_id = str(secondary_profile["id"]) + + status, pair_preview = api_json( + "POST", + app_base, + "/api/compatibility/preview", + app_user, + app_password, + { + "primary_profile_id": profile_id, + "secondary_profile_id": secondary_profile_id, + "limit": 12, + }, + ) + assert status == 200 and isinstance(pair_preview, dict), (status, pair_preview) + assert int(pair_preview["count"]) > 0 + + status, pair_calendar = api_json( + "POST", + app_base, + "/api/compatibility/calendars", + app_user, + app_password, + { + "primary_profile_id": profile_id, + "secondary_profile_id": secondary_profile_id, + "name": "둘이 좋은 시간", + "slug": f"pair-smoke-{suffix}", + "visibility": visibility, + "limit": 12, + }, + ) + assert status == 201 and isinstance(pair_calendar, dict), ( + status, + pair_calendar, + ) + status, pair_synced = api_json( + "POST", + app_base, + f"/api/calendars/{pair_calendar['id']}/sync", + app_user, + app_password, + {}, + ) + assert status == 200 and isinstance(pair_synced, dict), ( + status, + pair_synced, + ) + assert pair_synced["event_count"] == pair_preview["count"] + assert_caldav_collection( + caldav_base, + pair_collection_url, caldav_user, caldav_password, + visibility_classes[visibility], + ) + print( + "SAJU_CALDAV_SMOKE_OK " + f"rule_event_count={preview['count']} " + f"pair_event_count={pair_preview['count']}" ) - assert status == 200, (status, event[:500]) - assert b"CLASS:" + visibility_classes[visibility] in event - assert b"X-SAJU" not in event - print(f"SAJU_CALDAV_SMOKE_OK event_count={preview['count']}") finally: if collection_url: request("DELETE", collection_url, caldav_user, caldav_password) + if pair_collection_url: + request("DELETE", pair_collection_url, caldav_user, caldav_password) + if secondary_profile_id: + api_json( + "DELETE", + app_base, + f"/api/profiles/{secondary_profile_id}", + app_user, + app_password, + ) if profile_id: api_json( "DELETE", diff --git a/tests/test_api.py b/tests/test_api.py index 662839e..a6aec8b 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -74,6 +74,29 @@ def _create_profile(client: TestClient) -> dict[str, object]: return response.json() +def _create_secondary_profile(client: TestClient) -> dict[str, object]: + response = client.post( + "/api/profiles", + auth=_auth(), + json={ + "name": "두 번째 공개 예시", + "birth_calendar": "solar", + "birth_year": 2000, + "birth_month": 1, + "birth_day": 2, + "birth_time": "12:15:00", + "is_leap_month": False, + "gender": "unspecified", + "timezone": "Asia/Seoul", + "birth_city": "seoul", + "time_mode": "civil", + "longitude": None, + }, + ) + assert response.status_code == 201, response.text + return response.json() + + def _create_calendar(client: TestClient, profile_id: str) -> dict[str, object]: response = client.post( "/api/calendars", @@ -116,8 +139,10 @@ def test_operator_console_and_static_assets_are_served(tmp_path: Path) -> None: assert client.get("/").status_code == 401 page = client.get("/", auth=_auth()) assert page.status_code == 200 - assert "사주 명식을" in page.text - assert "시간 캘린더로" in page.text + assert "두 사람의 좋은 시간 찾기" in page.text + assert "둘이 좋은 날과 시간 찾기" in page.text + assert "사주를 잘 몰라도 됩니다" in page.text + assert 'id="compatibility-result"' in page.text assert "출생 도시" in page.text assert 'name="longitude"' not in page.text assert 'name="visibility"' in page.text @@ -167,6 +192,87 @@ def test_acceptance_profile_calendar_preview_and_sync(tmp_path: Path) -> None: assert publisher.calls[0]["visibility"] == "confidential" +def test_two_person_preview_calendar_and_sync_are_plain_korean( + tmp_path: Path, +) -> None: + client, publisher = _client(tmp_path) + primary = _create_profile(client) + secondary = _create_secondary_profile(client) + + preview = client.post( + "/api/compatibility/preview", + auth=_auth(), + json={ + "primary_profile_id": primary["id"], + "secondary_profile_id": secondary["id"], + "start_date": "2000-01-01", + "end_date": "2000-01-31", + "limit": 8, + }, + ) + assert preview.status_code == 200, preview.text + payload = preview.json() + assert payload["method"] == "balanced_branch_harmony" + assert payload["primary_name"] == "공개 테스트 예시" + assert payload["secondary_name"] == "두 번째 공개 예시" + assert 1 <= payload["count"] <= 8 + assert all(event["score"] >= 60 for event in payload["events"]) + assert all(event["label"].endswith("시간") for event in payload["events"]) + assert all(event["reasons"] for event in payload["events"]) + + created = client.post( + "/api/compatibility/calendars", + auth=_auth(), + json={ + "primary_profile_id": primary["id"], + "secondary_profile_id": secondary["id"], + "name": "둘이 좋은 시간", + "slug": "good-time-together", + "visibility": "private", + "limit": 8, + }, + ) + assert created.status_code == 201, created.text + calendar = created.json() + assert calendar["kind"] == "compatibility" + assert calendar["secondary_profile_id"] == secondary["id"] + + calendar_preview = client.post( + f"/api/calendars/{calendar['id']}/preview", + auth=_auth(), + json={"start_date": "2000-01-01", "end_date": "2000-01-31"}, + ) + assert calendar_preview.status_code == 200, calendar_preview.text + assert calendar_preview.json()["events"][0]["reasons"] + + synced = client.post( + f"/api/calendars/{calendar['id']}/sync", + auth=_auth(), + json={"start_date": "2000-01-01", "end_date": "2000-01-31"}, + ) + assert synced.status_code == 200, synced.text + assert publisher.calls[-1]["calendar_name"] == "둘이 좋은 시간" + assert publisher.calls[-1]["visibility"] == "private" + assert synced.json()["event_count"] == len(publisher.calls[-1]["windows"]) + + +def test_two_person_flow_requires_distinct_profiles(tmp_path: Path) -> None: + client, _ = _client(tmp_path) + profile = _create_profile(client) + + response = client.post( + "/api/compatibility/preview", + auth=_auth(), + json={ + "primary_profile_id": profile["id"], + "secondary_profile_id": profile["id"], + }, + ) + + assert response.status_code == 422 + assert response.json()["detail"] == "서로 다른 두 사람의 프로필을 선택하세요" + + def test_city_automatically_supplies_timezone_and_true_solar_longitude( tmp_path: Path, ) -> None: diff --git a/tests/test_compatibility.py b/tests/test_compatibility.py new file mode 100644 index 0000000..5822b10 --- /dev/null +++ b/tests/test_compatibility.py @@ -0,0 +1,144 @@ +from datetime import date, datetime + +from app.compatibility import generate_compatibility_candidates, score_window +from app.events import MatchingWindow +from app.saju import Chart, Pillar, calculate_chart + + +def _chart(day_branch: str) -> Chart: + return Chart( + year=Pillar("甲", "子"), + month=Pillar("丙", "寅"), + day=Pillar("戊", day_branch), + hour=Pillar("庚", "申"), + calculation_local=datetime(2000, 1, 1, 12), + ) + + +def test_balanced_score_rewards_both_people_and_explains_it_in_korean() -> None: + current = Chart( + year=Pillar("己", "卯"), + month=Pillar("丙", "子"), + day=Pillar("戊", "午"), + hour=Pillar("己", "未"), + calculation_local=datetime(2000, 1, 1, 14), + ) + window = MatchingWindow( + start=datetime(2000, 1, 1, 13), + end=datetime(2000, 1, 1, 15), + chart=current, + ) + + candidate = score_window( + window, + _chart("午"), + _chart("未"), + "나", + "상대", + ) + + assert candidate is not None + assert candidate.score >= 70 + assert candidate.primary_score >= 60 + assert candidate.secondary_score >= 60 + assert any("나의 일지" in reason for reason in candidate.reasons) + assert any("상대의 일지" in reason for reason in candidate.reasons) + + +def test_a_clash_for_either_person_is_not_recommended() -> None: + current = Chart( + year=Pillar("己", "卯"), + month=Pillar("丙", "子"), + day=Pillar("戊", "子"), + hour=Pillar("戊", "午"), + calculation_local=datetime(2000, 1, 1, 12), + ) + window = MatchingWindow( + start=datetime(2000, 1, 1, 11), + end=datetime(2000, 1, 1, 13), + chart=current, + ) + + assert score_window(window, _chart("午"), _chart("未"), "나", "상대") is None + + +def test_generator_returns_at_most_one_upcoming_time_per_day() -> None: + primary = calculate_chart( + datetime(2000, 1, 1, 12, 15), + "Asia/Seoul", + "civil", + None, + ) + secondary = calculate_chart( + datetime(2000, 1, 2, 12, 15), + "Asia/Seoul", + "civil", + None, + ) + + candidates = generate_compatibility_candidates( + primary, + secondary, + "나", + "상대", + date(2000, 1, 1), + date(2000, 1, 31), + "Asia/Seoul", + "civil", + None, + limit=12, + ) + + assert candidates + assert len(candidates) <= 12 + assert len({candidate.window.start.date() for candidate in candidates}) == len( + candidates + ) + assert candidates == sorted(candidates, key=lambda candidate: candidate.window.start) + assert all(candidate.score >= 60 for candidate in candidates) + + +def test_generator_applies_current_time_before_choosing_each_days_best() -> None: + primary = calculate_chart( + datetime(2000, 1, 1, 12, 15), + "Asia/Seoul", + "civil", + None, + ) + secondary = calculate_chart( + datetime(2000, 1, 2, 12, 15), + "Asia/Seoul", + "civil", + None, + ) + original = generate_compatibility_candidates( + primary, + secondary, + "나", + "상대", + date(2000, 1, 1), + date(2000, 1, 31), + "Asia/Seoul", + "civil", + None, + limit=12, + ) + assert original + current = original[0].window.end + + upcoming = generate_compatibility_candidates( + primary, + secondary, + "나", + "상대", + date(2000, 1, 1), + date(2000, 1, 31), + "Asia/Seoul", + "civil", + None, + limit=12, + not_before=current, + ) + + assert upcoming + assert all(candidate.window.end > current for candidate in upcoming) diff --git a/tests/test_events.py b/tests/test_events.py index b823ea9..2c91ebd 100644 --- a/tests/test_events.py +++ b/tests/test_events.py @@ -2,7 +2,7 @@ import pytest -from app.events import generate_windows +from app.events import generate_windows, iter_chart_windows from app.rules import validate_rule from app.saju import calculate_chart @@ -90,3 +90,35 @@ def test_generation_range_is_bounded() -> None: "civil", None, ) + + +@pytest.mark.parametrize( + ("time_mode", "longitude"), + [("civil", None), ("true_solar", 126.978)], +) +def test_optimized_window_charts_match_direct_midpoint_calculation( + time_mode: str, + longitude: float | None, +) -> None: + windows = list( + iter_chart_windows( + date(2000, 1, 1), + date(2000, 1, 1), + "Asia/Seoul", + time_mode, + longitude, + ) + ) + + for window in windows: + civil_midpoint = window.start.replace(tzinfo=None) + ( + window.end.replace(tzinfo=None) - window.start.replace(tzinfo=None) + ) / 2 + direct = calculate_chart( + civil_midpoint, + "Asia/Seoul", + time_mode, + longitude, + ) + assert window.chart.day == direct.day + assert window.chart.hour == direct.hour diff --git a/tests/test_store.py b/tests/test_store.py index 4fef30c..27f731a 100644 --- a/tests/test_store.py +++ b/tests/test_store.py @@ -129,3 +129,5 @@ def test_initialize_migrates_and_backfills_legacy_profiles(tmp_path: Path) -> No for row in sqlite3.connect(database).execute("PRAGMA table_info(calendars)") } assert "visibility" in calendar_columns + assert "kind" in calendar_columns + assert "secondary_profile_id" in calendar_columns From d3a45258cd1a6c2a12eaec10a21029a483fceb42 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 26 Jul 2026 19:36:33 +0900 Subject: [PATCH 2/4] docs: clarify ranking and coordinate wording --- app/compatibility.py | 2 +- app/static/app.js | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/app/compatibility.py b/app/compatibility.py index 0410869..1f88747 100644 --- a/app/compatibility.py +++ b/app/compatibility.py @@ -162,7 +162,7 @@ def generate_compatibility_candidates( limit: int = 24, not_before: datetime | None = None, ) -> list[CompatibilityCandidate]: - """Return the earliest balanced candidate per eligible calendar day.""" + """Return the highest-scoring candidate per day, breaking ties by start time.""" if not 1 <= limit <= 96: raise ValueError("limit must be between 1 and 96") diff --git a/app/static/app.js b/app/static/app.js index 126b3d1..496ef02 100644 --- a/app/static/app.js +++ b/app/static/app.js @@ -149,7 +149,7 @@ function updatePlaceFields() { timezone.value = city.timezone; timezone.readOnly = true; $("#place-note").textContent = trueSolar - ? `${city.label}의 경도를 서버에서 자동 적용합니다. 위도와 좌표 입력은 필요하지 않습니다.` + ? `${city.label}의 대표 경도를 서버에서 자동 적용하므로 좌표를 직접 입력할 필요가 없습니다.` : `${city.label}의 시간대 ${city.timezone}를 적용합니다. 표준시는 좌표를 사용하지 않습니다.`; return; } @@ -179,7 +179,7 @@ function updatePairPlace(personCard) { timezone.value = city.timezone; timezone.readOnly = true; note.textContent = trueSolar - ? `${city.label}의 경도를 자동 적용합니다. 위도와 좌표 입력은 필요하지 않습니다.` + ? `${city.label}의 대표 경도를 자동 적용하므로 좌표를 직접 입력할 필요가 없습니다.` : `${city.label}의 시간대 ${city.timezone}를 적용합니다.`; return; } From f7e173ad84990098e586117bf2aef70cd89dd1fc Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 26 Jul 2026 19:50:21 +0900 Subject: [PATCH 3/4] fix: preserve intraday pillar transitions --- app/compatibility.py | 10 ++++----- app/events.py | 44 ++++++++++++++++++++++++++++--------- tests/test_compatibility.py | 42 +++++++++++++++++++++++++++++++++++ tests/test_events.py | 22 +++++++++++++++++++ 4 files changed, 103 insertions(+), 15 deletions(-) diff --git a/app/compatibility.py b/app/compatibility.py index 1f88747..846388c 100644 --- a/app/compatibility.py +++ b/app/compatibility.py @@ -189,11 +189,11 @@ def generate_compatibility_candidates( candidate_date = candidate.window.start.date() previous = best_by_date.get(candidate_date) if previous is None or ( - candidate.score, - -candidate.window.start.hour, - ) > ( - previous.score, - -previous.window.start.hour, + candidate.score > previous.score + or ( + candidate.score == previous.score + and candidate.window.start < previous.window.start + ) ): best_by_date[candidate_date] = candidate diff --git a/app/events.py b/app/events.py index c15dd5b..f83047a 100644 --- a/app/events.py +++ b/app/events.py @@ -80,14 +80,25 @@ def iter_chart_windows( for day_offset in range((end_date - start_date).days + 1): calculation_date = start_date + timedelta(days=day_offset) midnight = datetime.combine(calculation_date, time.min) - calculation_noon = midnight + timedelta(hours=12) - civil_noon = _civil_from_calculation_time( - calculation_noon, + end_of_day = midnight + timedelta(days=1, microseconds=-1) + civil_midnight = _civil_from_calculation_time( + midnight, zone, time_mode, longitude, ) - day_chart = calculate_chart(civil_noon, timezone, time_mode, longitude) + civil_end_of_day = _civil_from_calculation_time( + end_of_day, + zone, + time_mode, + longitude, + ) + start_chart = calculate_chart(civil_midnight, timezone, time_mode, longitude) + end_chart = calculate_chart(civil_end_of_day, timezone, time_mode, longitude) + has_pillar_transition = ( + start_chart.year != end_chart.year + or start_chart.month != end_chart.month + ) for start_hour, end_hour in SEGMENTS: calculation_start = midnight + timedelta(hours=start_hour) calculation_end = midnight + timedelta(hours=end_hour) @@ -98,12 +109,25 @@ def iter_chart_windows( calculation_midpoint = calculation_start + ( calculation_end - calculation_start ) / 2 - current = Chart( - year=day_chart.year, - month=day_chart.month, - day=day_chart.day, - hour=_hour_pillar(day_chart.day.stem, calculation_midpoint.hour), - calculation_local=calculation_midpoint, + civil_midpoint = _civil_from_calculation_time( + calculation_midpoint, + zone, + time_mode, + longitude, + ) + current = ( + calculate_chart(civil_midpoint, timezone, time_mode, longitude) + if has_pillar_transition + else Chart( + year=start_chart.year, + month=start_chart.month, + day=start_chart.day, + hour=_hour_pillar( + start_chart.day.stem, + calculation_midpoint.hour, + ), + calculation_local=calculation_midpoint, + ) ) yield MatchingWindow( start=civil_start.replace(tzinfo=zone), diff --git a/tests/test_compatibility.py b/tests/test_compatibility.py index 5822b10..3aeccc3 100644 --- a/tests/test_compatibility.py +++ b/tests/test_compatibility.py @@ -1,5 +1,6 @@ from datetime import date, datetime +import app.compatibility as compatibility_module from app.compatibility import generate_compatibility_candidates, score_window from app.events import MatchingWindow from app.saju import Chart, Pillar, calculate_chart @@ -142,3 +143,44 @@ def test_generator_applies_current_time_before_choosing_each_days_best() -> None assert upcoming assert all(candidate.window.end > current for candidate in upcoming) + + +def test_generator_tie_breaks_with_the_full_start_time(monkeypatch) -> None: + chart = _chart("午") + candidate_chart = Chart( + year=chart.year, + month=chart.month, + day=chart.day, + hour=Pillar("己", "未"), + calculation_local=chart.calculation_local, + ) + later = MatchingWindow( + start=datetime(2000, 1, 1, 11, 40), + end=datetime(2000, 1, 1, 13, 40), + chart=candidate_chart, + ) + earlier = MatchingWindow( + start=datetime(2000, 1, 1, 11, 20), + end=datetime(2000, 1, 1, 13, 20), + chart=candidate_chart, + ) + monkeypatch.setattr( + compatibility_module, + "iter_chart_windows", + lambda *args, **kwargs: iter((later, earlier)), + ) + + candidates = generate_compatibility_candidates( + chart, + chart, + "나", + "상대", + date(2000, 1, 1), + date(2000, 1, 1), + "Asia/Seoul", + "civil", + None, + ) + + assert len(candidates) == 1 + assert candidates[0].window.start == earlier.start diff --git a/tests/test_events.py b/tests/test_events.py index 2c91ebd..c29671a 100644 --- a/tests/test_events.py +++ b/tests/test_events.py @@ -120,5 +120,27 @@ def test_optimized_window_charts_match_direct_midpoint_calculation( time_mode, longitude, ) + assert window.chart.year == direct.year + assert window.chart.month == direct.month assert window.chart.day == direct.day assert window.chart.hour == direct.hour + + +def test_window_charts_follow_an_intraday_solar_term_transition() -> None: + windows = list( + iter_chart_windows( + date(2024, 2, 4), + date(2024, 2, 4), + "Asia/Seoul", + "civil", + None, + ) + ) + + before = next(window for window in windows if window.start.hour == 15) + after = next(window for window in windows if window.start.hour == 17) + + assert before.chart.year.ganzhi == "癸卯" + assert before.chart.month.ganzhi == "乙丑" + assert after.chart.year.ganzhi == "甲辰" + assert after.chart.month.ganzhi == "丙寅" From f99910f2496b616711d7618b9c7683002c6de619 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 26 Jul 2026 20:00:08 +0900 Subject: [PATCH 4/4] perf: keep common window generation fast --- app/events.py | 26 +++++++++++++++----------- tests/test_compatibility.py | 4 +--- 2 files changed, 16 insertions(+), 14 deletions(-) diff --git a/app/events.py b/app/events.py index f83047a..9d2b7f6 100644 --- a/app/events.py +++ b/app/events.py @@ -109,16 +109,21 @@ def iter_chart_windows( calculation_midpoint = calculation_start + ( calculation_end - calculation_start ) / 2 - civil_midpoint = _civil_from_calculation_time( - calculation_midpoint, - zone, - time_mode, - longitude, - ) - current = ( - calculate_chart(civil_midpoint, timezone, time_mode, longitude) - if has_pillar_transition - else Chart( + if has_pillar_transition: + civil_midpoint = _civil_from_calculation_time( + calculation_midpoint, + zone, + time_mode, + longitude, + ) + current = calculate_chart( + civil_midpoint, + timezone, + time_mode, + longitude, + ) + else: + current = Chart( year=start_chart.year, month=start_chart.month, day=start_chart.day, @@ -128,7 +133,6 @@ def iter_chart_windows( ), calculation_local=calculation_midpoint, ) - ) yield MatchingWindow( start=civil_start.replace(tzinfo=zone), end=civil_end.replace(tzinfo=zone), diff --git a/tests/test_compatibility.py b/tests/test_compatibility.py index 3aeccc3..0fe2fb9 100644 --- a/tests/test_compatibility.py +++ b/tests/test_compatibility.py @@ -1,6 +1,5 @@ from datetime import date, datetime -import app.compatibility as compatibility_module from app.compatibility import generate_compatibility_candidates, score_window from app.events import MatchingWindow from app.saju import Chart, Pillar, calculate_chart @@ -165,8 +164,7 @@ def test_generator_tie_breaks_with_the_full_start_time(monkeypatch) -> None: chart=candidate_chart, ) monkeypatch.setattr( - compatibility_module, - "iter_chart_windows", + "app.compatibility.iter_chart_windows", lambda *args, **kwargs: iter((later, earlier)), )