From d8bf79fd99ef3f99d2ef40d007f6fa9167b5e8a2 Mon Sep 17 00:00:00 2001 From: Jakedoes1111 Date: Sat, 8 Nov 2025 13:40:34 +1100 Subject: [PATCH] Add privacy metadata and launch readiness tooling --- .env.example | 10 ++ .github/workflows/ci.yml | 33 +++++ IMPLEMENTATION_PLAN.md | 8 +- README.md | 8 +- next.config.ts | 16 ++- package-lock.json | 28 +++++ package.json | 2 + public/sample.csv | 22 ++-- src/app/api/providers/[provider]/route.ts | 6 +- src/app/compass/page.tsx | 14 +-- src/app/page.tsx | 21 ++-- src/app/systems/bazi/page.tsx | 4 + src/app/systems/fs/page.tsx | 34 +++--- src/app/systems/geomancy/page.tsx | 8 +- src/app/systems/gk/page.tsx | 2 +- src/app/systems/hd/page.tsx | 16 +-- src/app/systems/iching/page.tsx | 14 ++- src/app/systems/ja/page.tsx | 28 +++-- src/app/systems/mianxiang/page.tsx | 4 +- src/app/systems/numerology/page.tsx | 2 + src/app/systems/palmistry/page.tsx | 4 +- src/app/systems/qmdj/page.tsx | 2 + src/app/systems/tarot/page.tsx | 2 + src/app/systems/wa-ha/actions.ts | 2 + src/app/systems/zwds/page.tsx | 4 +- src/app/timeline/page.tsx | 8 +- src/calculators/chinese-calendar.ts | 1 + src/calculators/ephemeris.ts | 2 +- src/calculators/fs.ts | 2 +- src/calculators/gk.ts | 2 +- src/calculators/hd.ts | 2 +- src/calculators/qmdj.ts | 2 +- src/calculators/zwds.ts | 2 +- src/components/Compass.tsx | 25 ++-- src/components/DatasetList.tsx | 18 +-- src/components/FilterBar.tsx | 6 +- src/components/SystemCards.tsx | 4 +- src/components/modals/HowItWorksModal.tsx | 4 +- src/hooks/useElementSize.ts | 4 +- src/lib/csv.ts | 31 ++++- src/lib/ephemeris.ts | 4 +- src/lib/filters/applyFilters.ts | 2 +- src/lib/normalise.ts | 16 ++- src/providers/bootstrap.ts | 58 +++++---- .../DemoChineseCalendarProvider.ts | 1 + .../SolarlunarChineseCalendarProvider.ts | 10 +- .../AstronomyEngineEphemerisProvider.ts | 4 +- src/providers/qmdj/DemoQMDJProvider.ts | 2 +- src/schema.ts | 77 ++++++------ src/server/datasets/store.ts | 7 ++ .../providers/ephemeris/swissephStub.ts | 13 +- src/store/useStore.ts | 113 ++++++++++-------- src/types/solarlunar.d.ts | 16 +++ tests/api/chineseCalendar.route.test.ts | 37 ++++++ tests/components/Compass.test.tsx | 14 +++ tests/components/DataImporter.test.tsx | 4 + tests/components/Timeline.test.tsx | 14 +++ tests/e2e/roundtrip.spec.ts | 6 +- tests/lib/applyFilters.test.ts | 59 +++++++++ tests/server/datasets/store.test.ts | 5 + tests/weights.test.ts | 2 + 61 files changed, 624 insertions(+), 247 deletions(-) create mode 100644 .env.example create mode 100644 .github/workflows/ci.yml create mode 100644 src/types/solarlunar.d.ts create mode 100644 tests/lib/applyFilters.test.ts diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..544ca81 --- /dev/null +++ b/.env.example @@ -0,0 +1,10 @@ +# Toggle demo providers (ephemeris, Chinese calendar, Zi Wei Dou Shu, etc.) +# Default: enabled in development, disabled in production builds. +NEXT_PUBLIC_ENABLE_DEMO_PROVIDERS=true + +# Optional Swiss Ephemeris configuration +SWISSEPH_DATA_PATH="" +SWISSEPH_JPL_FILE="" +SWISSEPH_ENGINE="swiss" +SWISSEPH_LICENSE_KEY="" +SWISSEPH_LICENSE_FILE="" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..e598947 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,33 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + +jobs: + build-and-test: + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: 20 + cache: npm + + - name: Install dependencies + run: npm ci + + - name: Run lint + run: npm run lint + + - name: Run unit tests + run: npm test -- --run + + - name: Build application + run: npm run build + env: + NEXT_PUBLIC_ENABLE_DEMO_PROVIDERS: "false" diff --git a/IMPLEMENTATION_PLAN.md b/IMPLEMENTATION_PLAN.md index 96bd324..fef4751 100644 --- a/IMPLEMENTATION_PLAN.md +++ b/IMPLEMENTATION_PLAN.md @@ -29,7 +29,7 @@ This document captures the implementation roadmap for finishing the MetaMap Next - Remove already-generated `.next/` and `test-results/` directories from the repo history/worktree (delete locally; do not commit generated artefacts). 4. **Enrich sample data** - - Populate `public/sample.csv` with 3–5 representative rows covering different systems, directions, timing windows, and `privacy:paid` note usage. + - Populate `public/sample.csv` with 3–5 representative rows covering different systems, directions, timing windows, and `privacy` column usage (public/internal/paid). - Update README import instructions to reference the richer sample. 5. **Add basic smoke tests** @@ -42,7 +42,7 @@ Estimated effort: ~1 day. ## 3. Core Goals for Completion - Integrate real calculator providers for each `UNKNOWN` system (ephemeris, Chinese calendar, Zi Wei Dou Shu, Qi Men Dun Jia, Feng Shui, Human Design, Gene Keys). -- Persist calculator outputs into the dataset via a consistent interface (with provenance, subsystem tagging, `privacy:paid` annotations). +- Persist calculator outputs into the dataset via a consistent interface (with provenance, subsystem tagging, and `privacy` annotations). - Provide UI feedback states (loading, error, variant indicators) once real data is produced. - Expand testing to cover calculators, normalisation edge cases, and E2E flows. - Prepare deployment artefacts (Docker image, CI pipeline) and documentation for operators/users. @@ -112,7 +112,7 @@ Estimated effort: ~1 day. 3. Align Life Gua calculation with provider results to avoid duplication. #### 2.6 Human Design (HD) -1. Integrate BodyGraph provider (likely requires 3rd-party API: Jovian Archive, Genetic Matrix). Respect licensing; add `privacy:paid`. +1. Integrate BodyGraph provider (likely requires 3rd-party API: Jovian Archive, Genetic Matrix). Respect licensing; set `privacy` to `paid`. 2. Update UI to show defined centres, type, authority. Consider dynamic SVG. 3. Since data may be sensitive/licensed, gate behind configuration flag. @@ -191,7 +191,7 @@ For each provider: ## 5. Implementation Notes & Risks -- **Licensing:** Several providers (Swiss Ephemeris, HD, GK) may require paid licenses. Honour `privacy:paid` flags and document limitations. +- **Licensing:** Several providers (Swiss Ephemeris, HD, GK) may require paid licenses. Honour `privacy=paid` flags and document limitations. - **Timezones:** Ensure providers operate with consistent timezone conversions. Prefer using Luxon `DateTime` objects across server and client. - **Performance:** Heavy libraries (ephemeris) should stay server-side; client components should fetch via API to avoid bloating bundles. - **Security:** Sanitise inputs coming from dataset (notes may contain user text). When exporting, guard against CSV injection (prefix `'` if first char is `=`, `+`, etc.). diff --git a/README.md b/README.md index cf8931d..d1758e1 100644 --- a/README.md +++ b/README.md @@ -64,7 +64,8 @@ Schema lives in `src/schema.ts` (Zod + inferred TypeScript types). CSV column or person_id,birth_datetime_local,birth_timezone,system,subsystem, source_tool,source_url_or_ref,data_point,verbatim_text,category, subcategory,direction_cardinal,direction_degrees,timing_window_start, -timing_window_end,polarity,strength,confidence,weight_system,notes +timing_window_end,polarity,strength,confidence,weight_system,privacy, +provenance,notes ``` Core enum sets: `System`, `Category`, `DirectionCardinal`, `Polarity`. Validation rules: @@ -73,6 +74,7 @@ Core enum sets: `System`, `Category`, `DirectionCardinal`, `Polarity`. Validatio - `direction_degrees` integer 0–359; auto-derives cardinal if missing. - `strength` integer −2…+2, `confidence` between 0…1. - `weight_system > 0` (defaults: HD 0.6, GK 0.5, others 1.0). +- `privacy` values: `public`, `internal`, `paid`. Use `provenance` to track provider+timestamp metadata. - Timezone must be an IANA tzdb identifier. Utility helpers (`src/lib`) cover intervals, direction mapping, CSV serialization, deduplication, and numerology math. @@ -85,7 +87,7 @@ Utility helpers (`src/lib`) cover intervals, direction mapping, CSV serializatio 2. Use the **Import data** panel on the overview (`/`) to append or replace rows. Zod validates every line and surfaces row-level errors. 3. **Export data** downloads the currently filtered dataset to CSV/JSON, maintaining schema ordering and ISO timestamps. -Sample starter file lives at `public/sample.csv` with representative rows spanning natal astrology, Jyotiṣa, Feng Shui, BaZi, Qi Men Dun Jia, Human Design, Gene Keys, numerology, and Tarot (including a `privacy:paid` note example). +Sample starter file lives at `public/sample.csv` with representative rows spanning natal astrology, Jyotiṣa, Feng Shui, BaZi, Qi Men Dun Jia, Human Design, Gene Keys, numerology, and Tarot—including entries flagged with `privacy=paid` and `privacy=internal` for filter testing. --- @@ -97,7 +99,7 @@ Interfaces live in `src/calculators/`: - `ChineseCalendarProvider` – BaZi pillars, luck cycles, sexagenary conversions. - `ZWDSProvider`, `QMDJProvider`, `FSProvider`, `HDProvider`, `GKProvider`. -Inject your implementation into the relevant system route (under `app/systems/**`). When a provider is absent, UI components surface `UNKNOWN` banners. Mark paid or private sources with `notes:"privacy:paid"` so users can filter them out. +Inject your implementation into the relevant system route (under `app/systems/**`). When a provider is absent, UI components surface `UNKNOWN` banners. Mark paid or private sources by setting the `privacy` field to `paid` or `internal` so collaborators can filter them out. Until a calculator is integrated, MetaMap never invents WA/HA/JA/BaZi/ZWDS/QMDJ/FS/HD/GK results—only deterministic math (e.g., numerology) is pre-filled. diff --git a/next.config.ts b/next.config.ts index cdb11aa..de3478e 100644 --- a/next.config.ts +++ b/next.config.ts @@ -1,15 +1,21 @@ -import { resolve } from "node:path"; import type { NextConfig } from "next"; +const swissephStub = "@/server/providers/ephemeris/swissephStub"; + const nextConfig: NextConfig = { + experimental: { + turbopackUseSystemTlsCerts: true, + }, + turbopack: { + resolveAlias: { + swisseph: swissephStub, + }, + }, webpack: (config) => { config.resolve = config.resolve ?? {}; config.resolve.alias = config.resolve.alias ?? {}; if (!config.resolve.alias.swisseph) { - config.resolve.alias.swisseph = resolve( - __dirname, - "src/server/providers/ephemeris/swissephStub", - ); + config.resolve.alias.swisseph = swissephStub; } return config; }, diff --git a/package-lock.json b/package-lock.json index 05afcee..ae820b8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -30,6 +30,8 @@ "@testing-library/user-event": "^14.6.1", "@types/d3": "^7.4.3", "@types/jest": "^29.5.12", + "@types/jsdom": "^27.0.0", + "@types/luxon": "^3.7.1", "@types/node": "^20", "@types/papaparse": "^5.3.15", "@types/react": "^19", @@ -2883,6 +2885,18 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/jsdom": { + "version": "27.0.0", + "resolved": "https://registry.npmjs.org/@types/jsdom/-/jsdom-27.0.0.tgz", + "integrity": "sha512-NZyFl/PViwKzdEkQg96gtnB8wm+1ljhdDay9ahn4hgb+SfVtPCbm3TlmDUFXTA+MGN3CijicnMhG18SI5H3rFw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "@types/tough-cookie": "*", + "parse5": "^7.0.0" + } + }, "node_modules/@types/json-schema": { "version": "7.0.15", "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", @@ -2897,6 +2911,13 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/luxon": { + "version": "3.7.1", + "resolved": "https://registry.npmjs.org/@types/luxon/-/luxon-3.7.1.tgz", + "integrity": "sha512-H3iskjFIAn5SlJU7OuxUmTEpebK6TKB8rxZShDslBMZJ5u9S//KM1sbdAisiSrqwLQncVjnpi2OK2J51h+4lsg==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/node": { "version": "20.19.23", "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.23.tgz", @@ -2944,6 +2965,13 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/tough-cookie": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/@types/tough-cookie/-/tough-cookie-4.0.5.tgz", + "integrity": "sha512-/Ad8+nIOV7Rl++6f1BdKxFSMgmoqEoYbHRpPcx3JEfv8VRsQe9Z4mCXeJBzxs7mbHY/XOZZuXlRNfhpVPbs6ZA==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/yargs": { "version": "17.0.34", "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.34.tgz", diff --git a/package.json b/package.json index 3cbf38b..ff9dc1b 100644 --- a/package.json +++ b/package.json @@ -38,6 +38,8 @@ "@testing-library/user-event": "^14.6.1", "@types/d3": "^7.4.3", "@types/jest": "^29.5.12", + "@types/jsdom": "^27.0.0", + "@types/luxon": "^3.7.1", "@types/node": "^20", "@types/papaparse": "^5.3.15", "@types/react": "^19", diff --git a/public/sample.csv b/public/sample.csv index 715921f..aaf12ea 100644 --- a/public/sample.csv +++ b/public/sample.csv @@ -1,10 +1,12 @@ -person_id,birth_datetime_local,birth_timezone,system,subsystem,source_tool,source_url_or_ref,data_point,verbatim_text,category,subcategory,direction_cardinal,direction_degrees,timing_window_start,timing_window_end,polarity,strength,confidence,weight_system,notes -default-person,1992-09-01T06:03:00,Australia/Sydney,WA,Sidereal · Lahiri,Astronomy Engine Ephemeris,,Sun 08° Virgo,Sun at 08° Virgo in 1st house,Personality,Core Self,,,,,+,2,0.9,1.0,source:astronomy-engine -default-person,1992-09-01T06:03:00,Australia/Sydney,JA,Lahiri,Astronomy Engine Ephemeris,,Nakṣatra Chitrā,Moon longitude 186.49° (sidereal),Timing,Nakṣatra,,,,,+,0,0.8,1.0,ayanamsa:lahiri -default-person,1992-09-01T06:03:00,Australia/Sydney,FS,Period 8,Traditional Flying Star,,North palace prosperity,Star 9 (base 1 / period 8),Direction,Flying Star,N,0,,,+,1,0.7,1.0,period:8; facing:0 -default-person,1992-09-01T06:03:00,Australia/Sydney,BaZi,Standard,SolarLunar Chinese Calendar,,Day pillar Ji-Si (己巳),Ji-Si (己巳) stem/branch,Timing,Day Master,,,,,+,1,0.85,1.0,gender:unspecified -default-person,1992-09-01T06:03:00,Australia/Sydney,QMDJ,Zhi Run · yang,Lo Shu QMDJ,,Center palace,Wu | Open | Chief,Guidance,Palace,,,,,+,0,0.7,1.0,arrangement:yang -default-person,1992-09-01T06:03:00,Australia/Sydney,HD,BodyGraph,Human Design Gate Mapper,,Type,Manifesting Generator,Personality,Type,,,,,+,0,0.7,0.6,authority:Sacral -default-person,1992-09-01T06:03:00,Australia/Sydney,GK,Life's Work,Gene Keys Profile,,Gene Key 29,Line 2,Guidance,Sphere,,,,,+,0,0.6,0.5,sequence:Activation -default-person,1992-09-01T06:03:00,Australia/Sydney,Numerology_Pythagorean,,MetaMap numerology,,Life Path 13/4,Life Path 13/4,Personality,Life Path,,,,,+,1,0.6,1.0,auto-calculated:numerology -default-person,1992-09-01T06:03:00,Australia/Sydney,Tarot,Celtic Cross,MetaMap RNG,,The Sun,UNKNOWN,Guidance,Outcome,,,2024-03-01T10:15:00+11:00,2024-03-01T10:15:00+11:00,+,0,0.5,1.0,spread:celtic; position:Outcome +person_id,birth_datetime_local,birth_timezone,system,subsystem,source_tool,source_url_or_ref,data_point,verbatim_text,category,subcategory,direction_cardinal,direction_degrees,timing_window_start,timing_window_end,polarity,strength,confidence,weight_system,privacy,provenance,notes +default-person,1992-09-01T06:03:00,Australia/Sydney,WA,Sidereal · Lahiri,Swiss Ephemeris,,Sun 08° Virgo,Sun at 08° Virgo in 1st house,Personality,Core Self,,,,,+,2,0.9,1,paid,provider:ephemeris:swiss,house_system=P; ayanamsa=lahiri +default-person,1992-09-01T06:03:00,Australia/Sydney,JA,Lahiri,Swiss Ephemeris,,Nakṣatra Chitrā,Moon longitude 186.49° (sidereal),Timing,Nakṣatra,,,,,+,0,0.85,1,public,provider:ephemeris:lahiri,sequence:vimshottari +default-person,1992-09-01T06:03:00,Australia/Sydney,FS,Period 8,Traditional Flying Star,,North palace prosperity,Star 9 (base 1 / period 8),Direction,Flying Star,N,0,,,+,1,0.75,1,public,provider:fs:period-8,facing:0° +default-person,1992-09-01T06:03:00,Australia/Sydney,BaZi,Standard,SolarLunar Chinese Calendar,,Day pillar Ji-Si (己巳),Ji-Si (己巳) stem/branch,Timing,Pillar,,,,,+,1,0.88,1,public,provider:chineseCalendar:standard,gender:unspecified +default-person,1992-09-01T06:03:00,Australia/Sydney,QMDJ,Zhi Run · yang,Lo Shu QMDJ,,Centre palace,Wu | Open | Chief,Guidance,Palace,,,,,+,0,0.72,1,public,provider:qmdj:zhi-run:yang,arrangement:yang +default-person,1992-09-01T06:03:00,Australia/Sydney,HD,BodyGraph,Human Design Gate Mapper,,Type,Manifesting Generator,Personality,Type,,,,,+,0,0.7,0.6,paid,provider:hd,authority:Sacral +default-person,1992-09-01T06:03:00,Australia/Sydney,GK,Activation Sequence,Gene Keys Profile,,Life's Work,Gene Key 29 · Line 2,Guidance,Sphere,,,,,+,0,0.65,0.5,paid,provider:gk,sequence:Activation +default-person,1992-09-01T06:03:00,Australia/Sydney,Numerology_Pythagorean,,MetaMap numerology,,Life Path 13/4,Life Path 13/4,Personality,Life Path,,,,,+,1,0.6,1,public,internal:numerology,auto-calculated:numerology +default-person,1992-09-01T06:03:00,Australia/Sydney,Numerology_Chaldean,,MetaMap numerology,,Birth number 4,Birth number 4,Learning,Birth Number,,,,,+,1,0.6,1,public,internal:numerology,auto-calculated:numerology +default-person,1992-09-01T06:03:00,Australia/Sydney,Tarot,Celtic Cross,MetaMap RNG,,The Sun,UNKNOWN,Guidance,Outcome,,,2024-03-01T10:15:00+11:00,2024-03-01T10:15:00+11:00,0,0,0.5,1,internal,rng:tarot,spread:celtic; position:Outcome +default-person,1992-09-01T06:03:00,Australia/Sydney,Geomancy,Judge,MetaMap RNG,,Judge 15,UNKNOWN,Guidance,Judge,,,2024-03-01T09:00:00+11:00,2024-03-01T09:00:00+11:00,0,0,0.5,1,internal,rng:geomancy,figure:● ○ ● ○ diff --git a/src/app/api/providers/[provider]/route.ts b/src/app/api/providers/[provider]/route.ts index 1fd306b..889c75b 100644 --- a/src/app/api/providers/[provider]/route.ts +++ b/src/app/api/providers/[provider]/route.ts @@ -12,14 +12,15 @@ import type { } from "@/calculators"; type ProviderParams = { - params: { provider: string }; + params: { provider: string } | Promise<{ provider: string }>; }; const isProviderKey = (key: string): key is ProviderKey => { return ["ephemeris", "chineseCalendar", "zwds", "qmdj", "fs", "hd", "gk"].includes(key); }; -export async function POST(request: Request, { params }: ProviderParams) { +export async function POST(request: Request, context: ProviderParams) { + const params = await context.params; if (!isProviderKey(params.provider)) { return NextResponse.json( { error: `Unknown provider "${params.provider}"` }, @@ -118,6 +119,7 @@ export async function POST(request: Request, { params }: ProviderParams) { provider.luckPillars({ dateTime: birth, zone, + gender: payload.gender, variant: payload.variant, }), ]); diff --git a/src/app/compass/page.tsx b/src/app/compass/page.tsx index cbd4098..2720dd5 100644 --- a/src/app/compass/page.tsx +++ b/src/app/compass/page.tsx @@ -2,8 +2,8 @@ import Link from "next/link"; import { useMemo } from "react"; -import { shallow } from "zustand/shallow"; import { useStore } from "@/store/useStore"; +import type { MetaMapStore } from "@/store/useStore"; import { useStoreHydration } from "@/hooks/useStoreHydration"; import { applyFilters } from "@/lib/filters"; import { Compass } from "@/components/Compass"; @@ -11,13 +11,11 @@ import { FilterBar } from "@/components/FilterBar"; const CompassPage = () => { const hydrated = useStoreHydration(); - const { dataset, filters } = useStore( - (state) => ({ - dataset: state.dataset, - filters: state.filters, - }), - shallow, - ); + const selection: Pick = useStore((state) => ({ + dataset: state.dataset, + filters: state.filters, + })); + const { dataset, filters } = selection; if (!hydrated) { return null; diff --git a/src/app/page.tsx b/src/app/page.tsx index 51d862a..a3c5768 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -3,6 +3,7 @@ import Link from "next/link"; import { useMemo } from "react"; import { useStore } from "@/store/useStore"; +import type { MetaMapStore } from "@/store/useStore"; import { useStoreHydration } from "@/hooks/useStoreHydration"; import { applyFilters } from "@/lib/filters"; import { conflictCount, systemCount, totalRows, unknownShare } from "@/lib/stats"; @@ -17,21 +18,21 @@ import { Timeline } from "@/components/Timeline"; import { Compass } from "@/components/Compass"; import { HowItWorksModal } from "@/components/modals/HowItWorksModal"; import { WeightsPanel } from "@/components/WeightsPanel"; -import { shallow } from "zustand/shallow"; import { DatasetList } from "@/components/DatasetList"; import { ProviderStatusPanel } from "@/components/ProviderStatusPanel"; export default function Home() { const hydrated = useStoreHydration(); - const { dataset, filters, tzdbVersion, birthDetails } = useStore( - (state) => ({ - dataset: state.dataset, - filters: state.filters, - tzdbVersion: state.tzdbVersion, - birthDetails: state.birthDetails, - }), - shallow, - ); + const selection: Pick< + MetaMapStore, + "dataset" | "filters" | "tzdbVersion" | "birthDetails" + > = useStore((state) => ({ + dataset: state.dataset, + filters: state.filters, + tzdbVersion: state.tzdbVersion, + birthDetails: state.birthDetails, + })); + const { dataset, filters, tzdbVersion, birthDetails } = selection; if (!hydrated) { return null; diff --git a/src/app/systems/bazi/page.tsx b/src/app/systems/bazi/page.tsx index 0d5cda3..4df3d6b 100644 --- a/src/app/systems/bazi/page.tsx +++ b/src/app/systems/bazi/page.tsx @@ -86,6 +86,8 @@ const BaZiPage = () => { strength: 0, confidence: 0.85, weight_system: 1, + privacy: "public", + provenance: `provider:chineseCalendar:${requestPayload.variant}`, notes: `pillar=${pillar.pillar}`, }); }); @@ -110,6 +112,8 @@ const BaZiPage = () => { strength: 0, confidence: 0.8, weight_system: 1, + privacy: "public", + provenance: `provider:chineseCalendar:${requestPayload.variant}`, notes: `duration=${luck.durationYears}y`, }); }); diff --git a/src/app/systems/fs/page.tsx b/src/app/systems/fs/page.tsx index 72a0686..ba126eb 100644 --- a/src/app/systems/fs/page.tsx +++ b/src/app/systems/fs/page.tsx @@ -96,15 +96,17 @@ const FsPage = () => { direction_degrees: cell.palace === "Centre" ? null : facing, timing_window_start: null, timing_window_end: null, - polarity: "+", - strength: 0, - confidence: 0.7, - weight_system: 1, - notes: `facing=${facing}`, - }); + polarity: "+", + strength: 0, + confidence: 0.7, + weight_system: 1, + privacy: "public", + provenance: `provider:fs:period-${period}`, + notes: `facing=${facing}`, }); + }); - appendRow({ + appendRow({ person_id: "default-person", birth_datetime_local: birthIso, birth_timezone: birthDetails.timezone, @@ -120,13 +122,15 @@ const FsPage = () => { direction_degrees: null, timing_window_start: null, timing_window_end: null, - polarity: "+", - strength: 0, - confidence: 0.6, - weight_system: 1, - notes: `favourable=${response.data.eightMansions.favourableDirections.join("|")}`, - }); - }; + polarity: "+", + strength: 0, + confidence: 0.6, + weight_system: 1, + privacy: "public", + provenance: "provider:fs:eight-mansions", + notes: `favourable=${response.data.eightMansions.favourableDirections.join("|")}`, + }); +}; const displayStars = flyingStars ?? placeholderStars; @@ -244,7 +248,7 @@ const FsPage = () => { diff --git a/src/app/systems/geomancy/page.tsx b/src/app/systems/geomancy/page.tsx index 7f9e216..6a152cf 100644 --- a/src/app/systems/geomancy/page.tsx +++ b/src/app/systems/geomancy/page.tsx @@ -11,7 +11,7 @@ type Figure = [number, number, number, number]; const randomFigure = (): Figure => { const bytes = new Uint8Array(4); crypto.getRandomValues(bytes); - return bytes.map((value) => (value % 2 === 0 ? 0 : 1)) as Figure; + return Array.from(bytes, (value) => (value % 2 === 0 ? 0 : 1)) as Figure; }; const addFigures = (a: Figure, b: Figure): Figure => @@ -62,6 +62,8 @@ const GeomancyPage = () => { strength: 0, confidence: 0.5, weight_system: 1, + privacy: "internal", + provenance: "rng:geomancy", notes: `figure:${toString(rightWitness)}`, }); appendRow({ @@ -85,6 +87,8 @@ const GeomancyPage = () => { strength: 0, confidence: 0.5, weight_system: 1, + privacy: "internal", + provenance: "rng:geomancy", notes: `figure:${toString(leftWitness)}`, }); appendRow({ @@ -108,6 +112,8 @@ const GeomancyPage = () => { strength: 0, confidence: 0.5, weight_system: 1, + privacy: "internal", + provenance: "rng:geomancy", notes: `figure:${toString(judgeFigure)}`, }); }; diff --git a/src/app/systems/gk/page.tsx b/src/app/systems/gk/page.tsx index c2c31bd..65b5420 100644 --- a/src/app/systems/gk/page.tsx +++ b/src/app/systems/gk/page.tsx @@ -125,7 +125,7 @@ const GkPage = () => {

Integration notes

diff --git a/src/app/systems/hd/page.tsx b/src/app/systems/hd/page.tsx index 47d30d8..66277d0 100644 --- a/src/app/systems/hd/page.tsx +++ b/src/app/systems/hd/page.tsx @@ -87,12 +87,14 @@ const HdPage = () => { direction_degrees: null, timing_window_start: null, timing_window_end: null, - polarity: "+", - strength: 0, - confidence: 0.7, - weight_system: 0.6, - notes: `authority=${response.data.authority ?? "UNKNOWN"}`, - }); + polarity: "+", + strength: 0, + confidence: 0.7, + weight_system: 0.6, + privacy: "public", + provenance: "provider:hd", + notes: `authority=${response.data.authority ?? "UNKNOWN"}`, + }); }; return ( @@ -156,7 +158,7 @@ const HdPage = () => {

Integration notes

diff --git a/src/app/systems/iching/page.tsx b/src/app/systems/iching/page.tsx index c9b6b25..b4f6094 100644 --- a/src/app/systems/iching/page.tsx +++ b/src/app/systems/iching/page.tsx @@ -83,12 +83,14 @@ const IchingPage = () => { direction_degrees: null, timing_window_start: now, timing_window_end: now, - polarity: "0", - strength: 0, - confidence: 0.5, - weight_system: 1, - notes: `lookup:lines=${lines.join("-")}; method:${method}`, - }); + polarity: "0", + strength: 0, + confidence: 0.5, + weight_system: 1, + privacy: "internal", + provenance: `rng:iching:${method}`, + notes: `lookup:lines=${lines.join("-")}; method:${method}`, + }); }; const derivedHexagram = useMemo( diff --git a/src/app/systems/ja/page.tsx b/src/app/systems/ja/page.tsx index d58e486..69cb241 100644 --- a/src/app/systems/ja/page.tsx +++ b/src/app/systems/ja/page.tsx @@ -155,12 +155,14 @@ const JaPage = () => { direction_degrees: null, timing_window_start: null, timing_window_end: null, - polarity: "+", - strength: 0, - confidence: 0.8, - weight_system: 1, - notes: "", - }); + polarity: "+", + strength: 0, + confidence: 0.8, + weight_system: 1, + privacy: "public", + provenance: `provider:ephemeris:${birthDetails.ayanamsa}`, + notes: "", + }); generatedDashas.forEach((dasha) => { appendRow({ @@ -179,13 +181,15 @@ const JaPage = () => { direction_degrees: null, timing_window_start: dasha.start, timing_window_end: dasha.finish, - polarity: "+", - strength: 0, - confidence: 0.7, - weight_system: 1, - notes: "", - }); + polarity: "+", + strength: 0, + confidence: 0.7, + weight_system: 1, + privacy: "public", + provenance: `provider:ephemeris:${birthDetails.ayanamsa}`, + notes: "", }); + }); }; return ( diff --git a/src/app/systems/mianxiang/page.tsx b/src/app/systems/mianxiang/page.tsx index 704d4d1..00930dc 100644 --- a/src/app/systems/mianxiang/page.tsx +++ b/src/app/systems/mianxiang/page.tsx @@ -51,7 +51,9 @@ const MianXiangPage = () => { strength: 0, confidence: 0.4, weight_system: 1, - notes: `privacy:local; annotation:${notes || "none"}`, + privacy: "internal", + provenance: "local:mianxiang", + notes: `annotation:${notes || "none"}`, }); setNotes(""); }; diff --git a/src/app/systems/numerology/page.tsx b/src/app/systems/numerology/page.tsx index 2607a31..ddcf30c 100644 --- a/src/app/systems/numerology/page.tsx +++ b/src/app/systems/numerology/page.tsx @@ -41,6 +41,8 @@ const NumerologyPage = () => { strength: 1, confidence: 0.6, weight_system: 1, + privacy: "public", + provenance: "internal:numerology", notes: `auto-calculated:${system}`, }); }; diff --git a/src/app/systems/palmistry/page.tsx b/src/app/systems/palmistry/page.tsx index 9e4060a..8448b03 100644 --- a/src/app/systems/palmistry/page.tsx +++ b/src/app/systems/palmistry/page.tsx @@ -51,7 +51,9 @@ const PalmistryPage = () => { strength: 0, confidence: 0.4, weight_system: 1, - notes: `privacy:local; annotation:${notes || "none"}`, + privacy: "internal", + provenance: "local:palmistry", + notes: `annotation:${notes || "none"}`, }); setNotes(""); }; diff --git a/src/app/systems/qmdj/page.tsx b/src/app/systems/qmdj/page.tsx index 3475225..e610b70 100644 --- a/src/app/systems/qmdj/page.tsx +++ b/src/app/systems/qmdj/page.tsx @@ -79,6 +79,8 @@ const QmdjPage = () => { strength: 0, confidence: 0.7, weight_system: 1, + privacy: "public", + provenance: `provider:qmdj:${school}:${arrangement}`, notes: `arrangement=${arrangement}`, }); }); diff --git a/src/app/systems/tarot/page.tsx b/src/app/systems/tarot/page.tsx index 9796096..8792c66 100644 --- a/src/app/systems/tarot/page.tsx +++ b/src/app/systems/tarot/page.tsx @@ -92,6 +92,8 @@ const TarotPage = () => { strength: 0, confidence: 0.5, weight_system: 1, + privacy: "internal", + provenance: "rng:tarot", notes: `spread:${spread}; position:${positions[index]}`, }), ); diff --git a/src/app/systems/wa-ha/actions.ts b/src/app/systems/wa-ha/actions.ts index 9ca5034..f73e428 100644 --- a/src/app/systems/wa-ha/actions.ts +++ b/src/app/systems/wa-ha/actions.ts @@ -46,6 +46,8 @@ const buildBaseRow = ( strength: 0, confidence: 0.85, weight_system: 1, + privacy: "paid", + provenance: "", notes: `longitude=${position.longitude.toFixed(2)}${ position.house != null ? `;house=${position.house}` : "" }`, diff --git a/src/app/systems/zwds/page.tsx b/src/app/systems/zwds/page.tsx index 0f115e3..bde07ab 100644 --- a/src/app/systems/zwds/page.tsx +++ b/src/app/systems/zwds/page.tsx @@ -63,6 +63,8 @@ const ZwdsPage = () => { strength: 0, confidence: 0.75, weight_system: 1, + privacy: "public", + provenance: `provider:zwds:${variant}`, notes: palace.notes ?? "", }); }); @@ -157,7 +159,7 @@ const ZwdsPage = () => {

Integration notes

diff --git a/src/app/timeline/page.tsx b/src/app/timeline/page.tsx index 4969c26..2b333b3 100644 --- a/src/app/timeline/page.tsx +++ b/src/app/timeline/page.tsx @@ -3,8 +3,8 @@ import Link from "next/link"; import { useMemo } from "react"; -import { shallow } from "zustand/shallow"; -import { useStore } from "@/store/useStore"; +import { useStore } from "@/store/useStore"; +import type { MetaMapStore } from "@/store/useStore"; import { useStoreHydration } from "@/hooks/useStoreHydration"; import { applyFilters } from "@/lib/filters"; import { Timeline } from "@/components/Timeline"; @@ -12,14 +12,14 @@ import { FilterBar } from "@/components/FilterBar"; const TimelinePage = () => { const hydrated = useStoreHydration(); - const { dataset, filters, birthDetails } = useStore( + const selection: Pick = useStore( (state) => ({ dataset: state.dataset, filters: state.filters, birthDetails: state.birthDetails, }), - shallow, ); + const { dataset, filters, birthDetails } = selection; if (!hydrated) { return null; diff --git a/src/calculators/chinese-calendar.ts b/src/calculators/chinese-calendar.ts index 97f5a4f..0068770 100644 --- a/src/calculators/chinese-calendar.ts +++ b/src/calculators/chinese-calendar.ts @@ -35,6 +35,7 @@ export interface ChineseCalendarProvider { luckPillars: (input: { dateTime: DateTime; zone: string; + gender?: "yin" | "yang"; variant?: string; }) => Promise; } diff --git a/src/calculators/ephemeris.ts b/src/calculators/ephemeris.ts index 4d59c5a..ab8dc7d 100644 --- a/src/calculators/ephemeris.ts +++ b/src/calculators/ephemeris.ts @@ -9,7 +9,7 @@ export interface EphemerisOptions { /** * Defines the contract for ephemeris integrations such as Swiss Ephemeris or JPL. - * Implementations may rely on paid data sources and should annotate privacy requirements. + * Implementations may rely on paid data sources and should update the dataset privacy flag accordingly. */ export interface EphemerisProvider { getPositions: ( diff --git a/src/calculators/fs.ts b/src/calculators/fs.ts index 1e06c6c..f7259b6 100644 --- a/src/calculators/fs.ts +++ b/src/calculators/fs.ts @@ -13,7 +13,7 @@ export interface EightMansionsResult { /** * Feng Shui provider interface for Flying Stars and Eight Mansions computations. - * TODO: wire a concrete provider, annotating `privacy:paid` in notes when relevant. + * TODO: wire a concrete provider, annotating the privacy field when relevant. */ export interface FSProvider { computeFlyingStars: (input: { diff --git a/src/calculators/gk.ts b/src/calculators/gk.ts index 0b9aa0e..6c60bb3 100644 --- a/src/calculators/gk.ts +++ b/src/calculators/gk.ts @@ -1,6 +1,6 @@ /** * Gene Keys hologenetic profile provider contract. - * TODO: connect a provider for sequence calculations (mark proprietary data with privacy notes). + * TODO: connect a provider for sequence calculations (set privacy to paid when licensing requires it). */ export interface GKProvider { computeProfile: (input: { diff --git a/src/calculators/hd.ts b/src/calculators/hd.ts index 6ec0116..3408af0 100644 --- a/src/calculators/hd.ts +++ b/src/calculators/hd.ts @@ -1,6 +1,6 @@ /** * Human Design chart provider interface. - * Integrations typically require proprietary datasets (mark rows with notes:"privacy:paid"). + * Integrations typically require proprietary datasets (set the privacy field to "paid"). * TODO: implement a provider for BodyGraph calculations. */ export interface HDProvider { diff --git a/src/calculators/qmdj.ts b/src/calculators/qmdj.ts index 1973326..29a5909 100644 --- a/src/calculators/qmdj.ts +++ b/src/calculators/qmdj.ts @@ -14,7 +14,7 @@ export interface QMDJBoard { /** * Contract for Qi Men Dun Jia computational engines. - * TODO: integrate third-party calculator respecting licensing ("privacy:paid" where applicable). + * TODO: integrate third-party calculator respecting licensing (set `privacy` to `paid` where applicable). */ export interface QMDJProvider { generateBoard: (input: { diff --git a/src/calculators/zwds.ts b/src/calculators/zwds.ts index 20321fc..ad00fd4 100644 --- a/src/calculators/zwds.ts +++ b/src/calculators/zwds.ts @@ -8,7 +8,7 @@ export interface ZWDSPalaceReading { /** * Zi Wei Dou Shu provider contract. - * TODO: integrate licensed calculator and surface privacy requirements. + * TODO: integrate licensed calculator and surface privacy requirements via the privacy field. */ export interface ZWDSProvider { computeChart: (input: { diff --git a/src/components/Compass.tsx b/src/components/Compass.tsx index 0e1c6bd..91cf50d 100644 --- a/src/components/Compass.tsx +++ b/src/components/Compass.tsx @@ -32,12 +32,12 @@ export const Compass = ({ rows }: CompassProps) => { })); filtered.forEach((row) => { const degrees = row.direction_degrees ?? null; - const cardinal = - row.direction_cardinal && row.direction_cardinal !== "" - ? (row.direction_cardinal as (typeof sectors)[number]) - : degrees != null - ? degreesToCardinal(degrees) - : ""; + const cardinal = + row.direction_cardinal !== "" + ? (row.direction_cardinal as (typeof sectors)[number]) + : degrees != null + ? degreesToCardinal(degrees) + : ""; if (!cardinal) { return; } @@ -64,11 +64,14 @@ export const Compass = ({ rows }: CompassProps) => { .outerRadius(120 * (0.4 + 0.6 * item.ratio)) .startAngle(startAngle) .endAngle(endAngle); - return { - ...item, - path: arcGenerator() ?? "", - midAngle: startAngle + sectorSize / 2, - }; + return { + ...item, + path: + arcGenerator( + null as unknown as Parameters[0], + ) ?? "", + midAngle: startAngle + sectorSize / 2, + }; }); }, [sectorSummary]); diff --git a/src/components/DatasetList.tsx b/src/components/DatasetList.tsx index 2c502eb..60d6ecb 100644 --- a/src/components/DatasetList.tsx +++ b/src/components/DatasetList.tsx @@ -67,13 +67,17 @@ export const DatasetList = ({ rows }: DatasetListProps) => { Strength {row.strength} -

- {row.verbatim_text === UNKNOWN_TOKEN ? "UNKNOWN" : row.verbatim_text} -

- {row.notes &&

Notes: {row.notes}

} - - ); - })} +

+ {row.verbatim_text === UNKNOWN_TOKEN ? "UNKNOWN" : row.verbatim_text} +

+

+ Privacy: {row.privacy} + {row.provenance ? ` • Provenance: ${row.provenance}` : ""} +

+ {row.notes &&

Notes: {row.notes}

} + + ); + })} )} diff --git a/src/components/FilterBar.tsx b/src/components/FilterBar.tsx index 5ca1a99..c18e923 100644 --- a/src/components/FilterBar.tsx +++ b/src/components/FilterBar.tsx @@ -10,8 +10,8 @@ export const FilterBar = () => { const resetFilters = useStore((state) => state.resetFilters); const toggleItem = useCallback( - (key: "systems" | "categories", value: T) => { - const current = new Set(filters[key]); + (key: "systems" | "categories", value: string) => { + const current = new Set(filters[key] as string[]); if (current.has(value)) { current.delete(value); } else { @@ -220,7 +220,7 @@ export const FilterBar = () => { checked={filters.hidePrivacyPaid} onChange={(event) => setFilters({ hidePrivacyPaid: event.target.checked })} /> - Hide privacy:paid notes + Hide paid outputs diff --git a/src/components/SystemCards.tsx b/src/components/SystemCards.tsx index 8e7368e..ff23bdc 100644 --- a/src/components/SystemCards.tsx +++ b/src/components/SystemCards.tsx @@ -37,7 +37,7 @@ const items: CardItem[] = [ route: "/systems/bazi", description: "Four Pillars and Luck Pillars grids. Chinese calendar provider required for live data.", - warning: "UNKNOWN until a Chinese calendar provider is configured (privacy:paid if licensed).", + warning: "UNKNOWN until a Chinese calendar provider is configured; mark privacy as paid for licensed data.", }, { system: "ZWDS", @@ -66,7 +66,7 @@ const items: CardItem[] = [ route: "/systems/hd", description: "BodyGraph scaffold referencing system weight 0.6. Awaiting BodyGraph provider for live gates.", - warning: "TODO integrate HD provider; mark proprietary data as privacy:paid.", + warning: "TODO integrate HD provider; set privacy to paid when using licensed APIs.", }, { system: "GK", diff --git a/src/components/modals/HowItWorksModal.tsx b/src/components/modals/HowItWorksModal.tsx index 7bbb000..e775cda 100644 --- a/src/components/modals/HowItWorksModal.tsx +++ b/src/components/modals/HowItWorksModal.tsx @@ -40,8 +40,8 @@ export const HowItWorksModal = () => {

MetaMap honours multiple self-modelling traditions without invention. Data rows use the normalised schema, and calculators that are not yet integrated display{" "} - UNKNOWN. Proprietary or paid outputs are flagged with{" "} - privacy:paid so you can hide them as needed. + UNKNOWN. Proprietary or paid outputs are flagged via the privacy + column (set to paid) so you can hide them as needed.

VARIANT badges appear when configurable elements—such as the Jyotiṣa ayanāṃśa or Qi Men diff --git a/src/hooks/useElementSize.ts b/src/hooks/useElementSize.ts index bc7a0fb..26fa25a 100644 --- a/src/hooks/useElementSize.ts +++ b/src/hooks/useElementSize.ts @@ -4,12 +4,12 @@ export const useElementSize = () => { const ref = useRef(null); const [size, setSize] = useState({ width: 0, height: 0 }); - const observer = useRef(); + const observer = useRef(null); const cleanup = useCallback(() => { if (observer.current) { observer.current.disconnect(); - observer.current = undefined; + observer.current = null; } }, []); diff --git a/src/lib/csv.ts b/src/lib/csv.ts index 52b33ee..8f90ff2 100644 --- a/src/lib/csv.ts +++ b/src/lib/csv.ts @@ -1,6 +1,13 @@ import Papa from "papaparse"; import { DateTime } from "luxon"; -import { UNKNOWN_TOKEN, CSV_HEADERS, DataRowSchema, WeightDefaults, type DataRow, type System } from "@/schema"; +import { + UNKNOWN_TOKEN, + CSV_HEADERS, + DataRowSchema, + WeightDefaults, + type DataRow, + type System, +} from "@/schema"; import { createId } from "@/lib/id"; type ParseError = { row: number; message: string }; @@ -25,6 +32,20 @@ const ensureWeight = (system: System, weight?: unknown) => { const ensureVerbatim = (text?: unknown) => typeof text === "string" && text.trim().length > 0 ? text : UNKNOWN_TOKEN; +const normalisePrivacy = (value?: unknown): DataRow["privacy"] => { + if (typeof value !== "string") { + return "public"; + } + const normalised = value.trim().toLowerCase(); + if (normalised === "internal" || normalised === "paid") { + return normalised; + } + return "public"; +}; + +const ensureProvenance = (value?: unknown) => + typeof value === "string" ? value.trim() : ""; + export const parseCsv = (content: string): { rows: DataRow[]; errors: ParseError[] } => { const results = Papa.parse>(content, { header: true, @@ -57,6 +78,8 @@ export const parseCsv = (content: string): { rows: DataRow[]; errors: ParseError strength: Number(raw.strength), confidence: Number(raw.confidence), weight_system: ensureWeight(raw.system as System, raw.weight_system), + privacy: normalisePrivacy(raw.privacy), + provenance: ensureProvenance(raw.provenance), notes: raw.notes ?? "", }; @@ -116,6 +139,8 @@ export const parseJson = (content: string): { rows: DataRow[]; errors: ParseErro strength: Number(raw.strength), confidence: Number(raw.confidence), weight_system: ensureWeight(raw.system as System, raw.weight_system), + privacy: normalisePrivacy(raw.privacy), + provenance: ensureProvenance(raw.provenance), notes: (raw.notes as string) ?? "", }; const validation = DataRowSchema.safeParse(candidate); @@ -170,10 +195,12 @@ export const rowsToCsv = (rows: DataRow[]): string => strength: row.strength, confidence: row.confidence, weight_system: row.weight_system, + privacy: row.privacy, + provenance: row.provenance ?? "", notes: row.notes ?? "", })), { - columns: CSV_HEADERS, + columns: [...CSV_HEADERS], }, ); diff --git a/src/lib/ephemeris.ts b/src/lib/ephemeris.ts index dec6658..988332a 100644 --- a/src/lib/ephemeris.ts +++ b/src/lib/ephemeris.ts @@ -46,8 +46,8 @@ export interface EphemerisMetadata { provider: string; /** Human-readable provider version string. */ version: string; - /** Back-end engine used by Swiss Ephemeris (swiss|moshier|jpl). */ - engine: "swiss" | "moshier" | "jpl"; + /** Back-end engine used by the ephemeris provider. */ + engine: "swiss" | "moshier" | "jpl" | "astronomy-engine"; /** * Original request context allowing consumers to trace what configuration * produced the response. diff --git a/src/lib/filters/applyFilters.ts b/src/lib/filters/applyFilters.ts index 67b67f4..38705df 100644 --- a/src/lib/filters/applyFilters.ts +++ b/src/lib/filters/applyFilters.ts @@ -52,7 +52,7 @@ export const applyFilters = (rows: DatasetRow[], filters: FilterState): DatasetR if (filters.hideUnknown && row.verbatim_text === "UNKNOWN") { return false; } - if (filters.hidePrivacyPaid && row.notes.includes("privacy:paid")) { + if (filters.hidePrivacyPaid && row.privacy === "paid") { return false; } return true; diff --git a/src/lib/normalise.ts b/src/lib/normalise.ts index 55f00bb..1b3ce62 100644 --- a/src/lib/normalise.ts +++ b/src/lib/normalise.ts @@ -30,6 +30,16 @@ const mergeListField = (a?: string, b?: string) => { return Array.from(values).join("; "); }; +const mergePrivacy = (a: DataRow["privacy"], b: DataRow["privacy"]): DataRow["privacy"] => { + if (a === "paid" || b === "paid") { + return "paid"; + } + if (a === "internal" || b === "internal") { + return "internal"; + } + return "public"; +}; + const mergeNotes = (a?: string, b?: string) => [a, b] .map((value) => value?.trim()) @@ -96,9 +106,11 @@ export const dedupeRows = (rows: DataRow[]): NormalisedRow[] => { merged_from: Array.from(mergedFrom), source_tool: mergeListField(candidate.source_tool, directionApplied.source_tool), source_url_or_ref: mergeListField( - candidate.source_url_or_ref, - directionApplied.source_url_or_ref, + candidate.source_url_or_ref, + directionApplied.source_url_or_ref, ), + provenance: mergeListField(candidate.provenance, directionApplied.provenance), + privacy: mergePrivacy(candidate.privacy, directionApplied.privacy), notes: appendToken( mergeNotes(candidate.notes, directionApplied.notes), `merged:${Array.from(mergedFrom).join("+")}`, diff --git a/src/providers/bootstrap.ts b/src/providers/bootstrap.ts index 2c9feff..bd5f4dc 100644 --- a/src/providers/bootstrap.ts +++ b/src/providers/bootstrap.ts @@ -8,6 +8,14 @@ import { TraditionalFSProvider } from "@/providers/fs/TraditionalFSProvider"; import { HumanDesignGateProvider } from "@/providers/hd/HumanDesignGateProvider"; import { GeneKeysProfileProvider } from "@/providers/gk/GeneKeysProfileProvider"; +const demoProvidersEnabled = () => { + const flag = process.env.NEXT_PUBLIC_ENABLE_DEMO_PROVIDERS; + if (flag != null) { + return flag === "true" || flag === "1"; + } + return process.env.NODE_ENV !== "production"; +}; + let bootstrapped = false; export const ensureProvidersBootstrapped = () => { @@ -24,30 +32,32 @@ export const ensureProvidersBootstrapped = () => { }); } - registerProvider({ - key: "chineseCalendar", - provider: new SolarlunarChineseCalendarProvider(), - }); - registerProvider({ - key: "zwds", - provider: new ClassicZWDSProvider(), - }); - registerProvider({ - key: "qmdj", - provider: new LoShuQMDJProvider(), - }); - registerProvider({ - key: "fs", - provider: new TraditionalFSProvider(), - }); - registerProvider({ - key: "hd", - provider: new HumanDesignGateProvider(), - }); - registerProvider({ - key: "gk", - provider: new GeneKeysProfileProvider(), - }); + if (demoProvidersEnabled()) { + registerProvider({ + key: "chineseCalendar", + provider: new SolarlunarChineseCalendarProvider(), + }); + registerProvider({ + key: "zwds", + provider: new ClassicZWDSProvider(), + }); + registerProvider({ + key: "qmdj", + provider: new LoShuQMDJProvider(), + }); + registerProvider({ + key: "fs", + provider: new TraditionalFSProvider(), + }); + registerProvider({ + key: "hd", + provider: new HumanDesignGateProvider(), + }); + registerProvider({ + key: "gk", + provider: new GeneKeysProfileProvider(), + }); + } bootstrapped = true; }; diff --git a/src/providers/chineseCalendar/DemoChineseCalendarProvider.ts b/src/providers/chineseCalendar/DemoChineseCalendarProvider.ts index d8d26b1..7a1753e 100644 --- a/src/providers/chineseCalendar/DemoChineseCalendarProvider.ts +++ b/src/providers/chineseCalendar/DemoChineseCalendarProvider.ts @@ -68,6 +68,7 @@ export class DemoChineseCalendarProvider implements ChineseCalendarProvider { async luckPillars(input: { dateTime: DateTime; zone: string; + gender?: "yin" | "yang"; variant?: string; }): Promise { const inZone = input.dateTime.setZone(input.zone); diff --git a/src/providers/chineseCalendar/SolarlunarChineseCalendarProvider.ts b/src/providers/chineseCalendar/SolarlunarChineseCalendarProvider.ts index e155cd0..81dbefb 100644 --- a/src/providers/chineseCalendar/SolarlunarChineseCalendarProvider.ts +++ b/src/providers/chineseCalendar/SolarlunarChineseCalendarProvider.ts @@ -156,11 +156,19 @@ export class SolarlunarChineseCalendarProvider implements ChineseCalendarProvide async luckPillars(input: { dateTime: DateTime; zone: string; + gender?: "yin" | "yang"; variant?: string; }): Promise { const local = input.dateTime.setZone(input.zone); const data = solarLunar.solar2lunar(local.year, local.month, local.day); - const genderForward = input.variant === "yin" ? false : true; + const genderForward = + input.gender === "yin" + ? false + : input.gender === "yang" + ? true + : input.variant === "yin" + ? false + : true; const startAge = computeLuckStartingAge(local, genderForward); const stemSymbol = data.gzMonth[0] ?? "甲"; const startStem = resolveStem(stemSymbol); diff --git a/src/providers/ephemeris/AstronomyEngineEphemerisProvider.ts b/src/providers/ephemeris/AstronomyEngineEphemerisProvider.ts index a86b524..c393cd4 100644 --- a/src/providers/ephemeris/AstronomyEngineEphemerisProvider.ts +++ b/src/providers/ephemeris/AstronomyEngineEphemerisProvider.ts @@ -110,8 +110,8 @@ const computeBody = ( const latitude = ecliptic.elat; const futureLatitude = futureEcliptic.elat; const latitudeSpeed = futureLatitude - latitude; - const distance = ecliptic.radius; - const futureDistance = futureEcliptic.radius; + const distance = vector.Length(); + const futureDistance = futureVector.Length(); const distanceSpeed = futureDistance - distance; const houseOffset = wrapDegrees(longitude - houseAnchor); const house = Math.floor(houseOffset / 30) + 1; diff --git a/src/providers/qmdj/DemoQMDJProvider.ts b/src/providers/qmdj/DemoQMDJProvider.ts index 2f4afb4..70e4fe3 100644 --- a/src/providers/qmdj/DemoQMDJProvider.ts +++ b/src/providers/qmdj/DemoQMDJProvider.ts @@ -20,7 +20,7 @@ export class DemoQMDJProvider implements QMDJProvider { school: "Zhi Run" | "Chai Bu" | string; }): Promise { const dt = input.dateTime.setZone(input.zone); - const seed = Math.abs(Math.floor(dt.ts / (1000 * 60))) % stars.length; + const seed = Math.abs(Math.floor(dt.toMillis() / (1000 * 60))) % stars.length; const arrangementOffset = input.arrangement === "yang" ? 1 : -1; const starSet = rotateArray(stars, seed); const doorSet = rotateArray(doors, seed + arrangementOffset); diff --git a/src/schema.ts b/src/schema.ts index ac2ec0c..8eba1cd 100644 --- a/src/schema.ts +++ b/src/schema.ts @@ -39,24 +39,27 @@ export const CategoryValues = [ "Learning", ] as const; -export const DirectionCardinalValues = [ - "", - "N", - "NE", - "E", - "SE", - "S", - "SW", - "W", - "NW", -] as const; - -export const PolarityValues = ["+", "0", "−"] as const; - -export type System = (typeof SystemValues)[number]; -export type Category = (typeof CategoryValues)[number]; -export type DirectionCardinal = (typeof DirectionCardinalValues)[number]; -export type Polarity = (typeof PolarityValues)[number]; +export const DirectionCardinalValues = [ + "", + "N", + "NE", + "E", + "SE", + "S", + "SW", + "W", + "NW", +] as const; + +export const PolarityValues = ["+", "0", "−"] as const; + +export const PrivacyLevelValues = ["public", "internal", "paid"] as const; + +export type System = (typeof SystemValues)[number]; +export type Category = (typeof CategoryValues)[number]; +export type DirectionCardinal = (typeof DirectionCardinalValues)[number]; +export type Polarity = (typeof PolarityValues)[number]; +export type PrivacyLevel = (typeof PrivacyLevelValues)[number]; const TIMEZONE_SET = new Set(getTimeZones().map((tz) => tz.name)); @@ -103,12 +106,14 @@ export const DataRowSchema = z direction_degrees: directionDegreesSchema, timing_window_start: timingField, timing_window_end: timingField, - polarity: z.enum(PolarityValues), - strength: z.number().int().min(-2).max(2), - confidence: z.number().min(0).max(1), - weight_system: z.number().positive(), - notes: z.string().optional().default(""), - }) + polarity: z.enum(PolarityValues), + strength: z.number().int().min(-2).max(2), + confidence: z.number().min(0).max(1), + weight_system: z.number().positive(), + privacy: z.enum(PrivacyLevelValues).optional().default("public"), + provenance: z.string().optional().default(""), + notes: z.string().optional().default(""), + }) .superRefine((value, ctx) => { const { timing_window_start: start, timing_window_end: end } = value; if ((start && !end) || (!start && end)) { @@ -140,11 +145,11 @@ export const DataRowSchema = z export type DataRow = z.infer; -export const CSV_HEADERS = [ - "person_id", - "birth_datetime_local", - "birth_timezone", - "system", +export const CSV_HEADERS = [ + "person_id", + "birth_datetime_local", + "birth_timezone", + "system", "subsystem", "source_tool", "source_url_or_ref", @@ -156,12 +161,14 @@ export const CSV_HEADERS = [ "direction_degrees", "timing_window_start", "timing_window_end", - "polarity", - "strength", - "confidence", - "weight_system", - "notes", -] as const satisfies ReadonlyArray>; + "polarity", + "strength", + "confidence", + "weight_system", + "privacy", + "provenance", + "notes", +] as const satisfies ReadonlyArray>; export const WeightDefaults: Record = Object.freeze({ WA: 1, diff --git a/src/server/datasets/store.ts b/src/server/datasets/store.ts index e5d8147..5f25a98 100644 --- a/src/server/datasets/store.ts +++ b/src/server/datasets/store.ts @@ -36,10 +36,17 @@ const applyProvenance = (row: DataRow, provenance: DatasetProvenance): DataRow = const existingNotes = row.notes?.trim() ?? ""; const notes = existingNotes.length > 0 ? `${existingNotes} | ${provenanceNote}` : provenanceNote; const sourceTool = row.source_tool && row.source_tool.length > 0 ? row.source_tool : provenance.provider; + const provenanceTag = `${provenance.provider}:${provenance.timestamp}`; + const existingProvenance = row.provenance?.trim() ?? ""; + const combinedProvenance = + existingProvenance.length > 0 + ? `${existingProvenance} | ${provenanceTag}` + : provenanceTag; return { ...row, notes, source_tool: sourceTool, + provenance: combinedProvenance, }; }; diff --git a/src/server/providers/ephemeris/swissephStub.ts b/src/server/providers/ephemeris/swissephStub.ts index 5b00fa9..874f1c7 100644 --- a/src/server/providers/ephemeris/swissephStub.ts +++ b/src/server/providers/ephemeris/swissephStub.ts @@ -110,7 +110,13 @@ const computeLatitude = (julianDay: number, code: number, latAmp: number) => { return Number((Math.sin(angle) * latAmp).toFixed(4)); }; -const swe_julday = (year: number, month: number, day: number, hour: number) => { +const swe_julday = ( + year: number, + month: number, + day: number, + hour: number, + _gregflag?: number, +) => { let y = year; let m = month; if (m <= 2) { @@ -210,14 +216,15 @@ const swe_houses_pos = ( _eps: number, _houseSystem: string, bodyLongitude: number, + _bodyLatitude?: number, ) => { const reference = normaliseDegrees(bodyLongitude - (armc - 60)); const housePosition = reference / 30 + 1; return { housePosition, housePositionSpeed: 0, housePosition2: 0 }; }; -const swe_set_ephe_path = () => {}; -const swe_set_jpl_file = () => {}; +const swe_set_ephe_path = (_path?: string) => {}; +const swe_set_jpl_file = (_file?: string) => {}; const swe_version = () => "swisseph-stub"; const swe_get_ayanamsa_ut = () => currentSiderealOffset; diff --git a/src/store/useStore.ts b/src/store/useStore.ts index 7c9afd1..3f1b416 100644 --- a/src/store/useStore.ts +++ b/src/store/useStore.ts @@ -17,6 +17,9 @@ import { type ProviderKey, type ProviderStatus } from "@/providers"; import tzdbPackage from "@vvo/tzdb/package.json"; export type DatasetRow = NormalisedRow; +type DataRowInput = + & Omit + & Partial>; export interface BirthDetails { birthDate: string; // ISO date @@ -53,9 +56,9 @@ export interface MetaMapStore { providerStatus: ProviderStatus[]; providerErrors: Partial>; providerLoading: Partial>; - addRows: (rows: DataRow[]) => void; - replaceRows: (rows: DataRow[]) => void; - appendRow: (row: DataRow) => void; + addRows: (rows: DataRowInput[]) => void; + replaceRows: (rows: DataRowInput[]) => void; + appendRow: (row: DataRowInput) => void; pruneRows: (predicate: (row: DatasetRow) => boolean) => void; clearDataset: () => void; setFilters: (filters: Partial) => void; @@ -66,10 +69,10 @@ export interface MetaMapStore { confirmTimezone: (confirmed: boolean) => void; setHasHydrated: (value: boolean) => void; fetchProviderStatus: () => Promise; - invokeProvider: ( + invokeProvider: ( key: ProviderKey, payload: Payload, - ) => Promise<{ status: number; data: Response | null }>; + ) => Promise<{ status: number; data: Result | null }>; clearProviderError: (key: ProviderKey) => void; } @@ -121,6 +124,8 @@ const buildSeedRows = (details: BirthDetails): DataRow[] => { strength: 1, confidence: 0.6, weight_system: 1, + privacy: "public", + provenance: "internal:numerology", notes: "", }; @@ -153,6 +158,13 @@ const reweight = ( weight_system: weights[row.system] ?? WeightDefaults[row.system] ?? 1, })); +const withRowDefaults = (row: DataRowInput): DataRow => ({ + ...row, + id: row.id ?? createId(), + privacy: row.privacy ?? "public", + provenance: row.provenance ?? "", +}); + const tzdbVersion = `@vvo/tzdb ${tzdbPackage.version}`; const isBrowser = typeof window !== "undefined"; @@ -165,6 +177,48 @@ export const useStore = create()( normaliseRows(buildSeedRows(defaultBirthDetails)), WeightDefaults, ); + const invokeProvider = async ( + key: ProviderKey, + payload: Payload, + ): Promise<{ status: number; data: Result | null }> => { + set((state) => ({ + providerLoading: { ...state.providerLoading, [key]: true }, + providerErrors: { ...state.providerErrors, [key]: undefined }, + })); + try { + const response = await fetch(`/api/providers/${key}`, { + method: "POST", + headers: { + "Content-Type": "application/json", + Accept: "application/json", + }, + body: JSON.stringify(payload ?? {}), + }); + const data = (await response.json().catch(() => null)) as Result | null; + if (!response.ok) { + const message = + data && typeof data === "object" && data !== null && "error" in data + ? String((data as { error: unknown }).error) + : `Provider request failed (${response.status})`; + set((state) => ({ + providerErrors: { ...state.providerErrors, [key]: message }, + })); + } + return { status: response.status, data }; + } catch (error) { + const message = + error instanceof Error ? error.message : "Unknown provider invocation error"; + set((state) => ({ + providerErrors: { ...state.providerErrors, [key]: message }, + })); + return { status: 0, data: null }; + } finally { + set((state) => ({ + providerLoading: { ...state.providerLoading, [key]: false }, + })); + } + }; + return { dataset: seedRows, birthDetails: defaultBirthDetails, @@ -176,16 +230,18 @@ export const useStore = create()( providerErrors: {}, providerLoading: {}, addRows: (rows) => { - const combined = normaliseRows([...get().dataset, ...rows]); + const withDefaults = rows.map(withRowDefaults); + const combined = normaliseRows([...get().dataset, ...withDefaults]); set({ dataset: reweight(combined, get().weights) }); }, replaceRows: (rows) => { - const normalised = normaliseRows(rows); + const withDefaults = rows.map(withRowDefaults); + const normalised = normaliseRows(withDefaults); set({ dataset: reweight(normalised, get().weights) }); }, appendRow: (row) => { - const idRow = { ...row, id: row.id ?? createId() }; - const combined = normaliseRows([...get().dataset, idRow]); + const withDefaults = withRowDefaults(row); + const combined = normaliseRows([...get().dataset, withDefaults]); set({ dataset: reweight(combined, get().weights) }); }, pruneRows: (predicate) => { @@ -249,44 +305,7 @@ export const useStore = create()( console.error("Provider status request failed", error); } }, - invokeProvider: async (key, payload) => { - set((state) => ({ - providerLoading: { ...state.providerLoading, [key]: true }, - providerErrors: { ...state.providerErrors, [key]: undefined }, - })); - try { - const response = await fetch(`/api/providers/${key}`, { - method: "POST", - headers: { - "Content-Type": "application/json", - Accept: "application/json", - }, - body: JSON.stringify(payload ?? {}), - }); - const data = (await response.json().catch(() => null)) as Response | null; - if (!response.ok) { - const message = - data && typeof data === "object" && data !== null && "error" in data - ? String((data as { error: unknown }).error) - : `Provider request failed (${response.status})`; - set((state) => ({ - providerErrors: { ...state.providerErrors, [key]: message }, - })); - } - return { status: response.status, data }; - } catch (error) { - const message = - error instanceof Error ? error.message : "Unknown provider invocation error"; - set((state) => ({ - providerErrors: { ...state.providerErrors, [key]: message }, - })); - return { status: 0, data: null }; - } finally { - set((state) => ({ - providerLoading: { ...state.providerLoading, [key]: false }, - })); - } - }, + invokeProvider: invokeProvider as MetaMapStore["invokeProvider"], clearProviderError: (key) => set((state) => ({ providerErrors: { ...state.providerErrors, [key]: undefined }, diff --git a/src/types/solarlunar.d.ts b/src/types/solarlunar.d.ts new file mode 100644 index 0000000..e9bb555 --- /dev/null +++ b/src/types/solarlunar.d.ts @@ -0,0 +1,16 @@ +declare module "solarlunar" { + interface SolarLunarResult { + gzYear: string; + gzMonth: string; + gzDay: string; + term?: number; + } + + interface SolarLunar { + solar2lunar(year: number, month: number, day: number): SolarLunarResult; + getTerm(year: number, term: number): number; + } + + const solarLunar: SolarLunar; + export default solarLunar; +} diff --git a/tests/api/chineseCalendar.route.test.ts b/tests/api/chineseCalendar.route.test.ts index 1d50ca0..0e93f29 100644 --- a/tests/api/chineseCalendar.route.test.ts +++ b/tests/api/chineseCalendar.route.test.ts @@ -24,4 +24,41 @@ describe("POST /api/providers/chineseCalendar", () => { expect(json.pillars).toHaveLength(4); expect(json.luckPillars).toHaveLength(8); }); + + it("passes gender through to luck pillar scheduling", async () => { + const basePayload = { + birthIso: "1992-09-01T06:03:00", + timezone: "Australia/Sydney", + }; + + const maleResponse = await POST( + new Request("http://localhost/api/providers/chineseCalendar", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ ...basePayload, gender: "yang" }), + }), + { params: { provider: "chineseCalendar" } }, + ); + + const femaleResponse = await POST( + new Request("http://localhost/api/providers/chineseCalendar", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ ...basePayload, gender: "yin" }), + }), + { params: { provider: "chineseCalendar" } }, + ); + + expect(maleResponse.status).toBe(200); + expect(femaleResponse.status).toBe(200); + + const maleJson = await maleResponse.json(); + const femaleJson = await femaleResponse.json(); + + expect(maleJson.luckPillars).toHaveLength(8); + expect(femaleJson.luckPillars).toHaveLength(8); + expect(femaleJson.luckPillars[0].startingAge).not.toBe( + maleJson.luckPillars[0].startingAge, + ); + }); }); diff --git a/tests/components/Compass.test.tsx b/tests/components/Compass.test.tsx index 38e2db9..a6e5fbe 100644 --- a/tests/components/Compass.test.tsx +++ b/tests/components/Compass.test.tsx @@ -25,6 +25,8 @@ const baseRow: DatasetRow = { strength: 2, confidence: 0.5, weight_system: 1, + privacy: "public", + provenance: "", notes: "", merged_from: [], conflict_set: null, @@ -46,4 +48,16 @@ describe("Compass", () => { screen.getByText(/Facing North prosperity sector/i), ).toBeInTheDocument(); }); + + it("renders multiple sectors with privacy metadata intact", () => { + const rows: DatasetRow[] = [ + baseRow, + { ...baseRow, id: "east", direction_cardinal: "E", direction_degrees: 90, privacy: "paid" }, + ]; + + render(); + + expect(screen.getByText(/N • 1 rows • Weighted 1.00/i)).toBeInTheDocument(); + expect(screen.getByText(/E • 1 rows • Weighted 1.00/i)).toBeInTheDocument(); + }); }); diff --git a/tests/components/DataImporter.test.tsx b/tests/components/DataImporter.test.tsx index 18de2e6..0386f13 100644 --- a/tests/components/DataImporter.test.tsx +++ b/tests/components/DataImporter.test.tsx @@ -37,6 +37,8 @@ const mockRow: DataRow = { strength: 1, confidence: 0.5, weight_system: 1, + privacy: "public", + provenance: "", notes: "", }; @@ -66,5 +68,7 @@ describe("DataImporter", () => { ); expect(parseCsvMock).toHaveBeenCalledOnce(); + const dataset = useStore.getState().dataset; + expect(dataset.some((row) => row.id === mockRow.id && row.privacy === "public")).toBe(true); }); }); diff --git a/tests/components/Timeline.test.tsx b/tests/components/Timeline.test.tsx index ccee051..94c1fde 100644 --- a/tests/components/Timeline.test.tsx +++ b/tests/components/Timeline.test.tsx @@ -24,6 +24,8 @@ const makeRow = (overrides: Partial): DatasetRow => ({ strength: 1, confidence: 0.5, weight_system: 1, + privacy: "public", + provenance: "", notes: "", merged_from: [], conflict_set: null, @@ -41,4 +43,16 @@ describe("Timeline", () => { screen.queryByText(/No timing windows available/i), ).not.toBeInTheDocument(); }); + + it("shows paid rows alongside public entries", () => { + const rows = [ + makeRow({ id: "public", data_point: "Public", privacy: "public" }), + makeRow({ id: "paid", data_point: "Paid", privacy: "paid" }), + ]; + + render(); + + expect(screen.getByText("Public")).toBeInTheDocument(); + expect(screen.getByText("Paid")).toBeInTheDocument(); + }); }); diff --git a/tests/e2e/roundtrip.spec.ts b/tests/e2e/roundtrip.spec.ts index dd81787..eaca059 100644 --- a/tests/e2e/roundtrip.spec.ts +++ b/tests/e2e/roundtrip.spec.ts @@ -1,9 +1,9 @@ import { promises as fs } from "node:fs"; import { test, expect } from "@playwright/test"; -const csvContent = `person_id,birth_datetime_local,birth_timezone,system,subsystem,source_tool,source_url_or_ref,data_point,verbatim_text,category,subcategory,direction_cardinal,direction_degrees,timing_window_start,timing_window_end,polarity,strength,confidence,weight_system,notes -default-person,1992-09-01T06:03:00,Australia/Sydney,Numerology_Pythagorean,,MetaMap numerology,,Life Path 13/4,UNKNOWN,Personality,, ,,,+,1,0.6,1.0, -default-person,1992-09-01T06:03:00,Australia/Sydney,Numerology_Chaldean,,MetaMap numerology,,Birth number 1,UNKNOWN,Learning,, ,,,+,1,0.6,1.0, +const csvContent = `person_id,birth_datetime_local,birth_timezone,system,subsystem,source_tool,source_url_or_ref,data_point,verbatim_text,category,subcategory,direction_cardinal,direction_degrees,timing_window_start,timing_window_end,polarity,strength,confidence,weight_system,privacy,provenance,notes +default-person,1992-09-01T06:03:00,Australia/Sydney,Numerology_Pythagorean,,MetaMap numerology,,Life Path 13/4,UNKNOWN,Personality,,,,,+,1,0.6,1.0,public,internal:numerology,auto +default-person,1992-09-01T06:03:00,Australia/Sydney,Numerology_Chaldean,,MetaMap numerology,,Birth number 1,UNKNOWN,Learning,,,,,+,1,0.6,1.0,public,internal:numerology,auto `; test.describe("Import/export round trip", () => { diff --git a/tests/lib/applyFilters.test.ts b/tests/lib/applyFilters.test.ts new file mode 100644 index 0000000..01e1c4a --- /dev/null +++ b/tests/lib/applyFilters.test.ts @@ -0,0 +1,59 @@ +import { describe, expect, it } from "vitest"; +import { applyFilters } from "@/lib/filters"; +import type { DatasetRow, FilterState } from "@/store/useStore"; + +const baseRow = (): DatasetRow => ({ + id: "row-1", + person_id: "person", + birth_datetime_local: "2024-01-01T00:00:00", + birth_timezone: "UTC", + system: "WA", + subsystem: "", + source_tool: "", + source_url_or_ref: "", + data_point: "Test", + verbatim_text: "UNKNOWN", + category: "Guidance", + subcategory: "", + direction_cardinal: "", + direction_degrees: null, + timing_window_start: null, + timing_window_end: null, + polarity: "+", + strength: 0, + confidence: 1, + weight_system: 1, + privacy: "public", + provenance: "", + notes: "", + merged_from: [], + conflict_set: null, +}); + +const defaultFilters: FilterState = { + systems: [], + categories: [], + subsystem: "", + polarity: null, + confidenceRange: [0, 1], + strengthRange: [-2, 2], + timeRange: [null, null], + showConflictsOnly: false, + hideUnknown: false, + hidePrivacyPaid: false, +}; + +describe("applyFilters", () => { + it("hides paid rows when hidePrivacyPaid is enabled", () => { + const rows = [baseRow(), { ...baseRow(), id: "paid", privacy: "paid" }]; + const filtered = applyFilters(rows, { ...defaultFilters, hidePrivacyPaid: true }); + expect(filtered).toHaveLength(1); + expect(filtered[0].id).toBe("row-1"); + }); + + it("retains paid rows when the filter is disabled", () => { + const rows = [baseRow(), { ...baseRow(), id: "paid", privacy: "paid" }]; + const filtered = applyFilters(rows, defaultFilters); + expect(filtered).toHaveLength(2); + }); +}); diff --git a/tests/server/datasets/store.test.ts b/tests/server/datasets/store.test.ts index 7adb1cd..c3fbff6 100644 --- a/tests/server/datasets/store.test.ts +++ b/tests/server/datasets/store.test.ts @@ -25,6 +25,8 @@ describe("InMemoryDatasetStore", () => { strength: 0, confidence: 0.85, weight_system: 1, + privacy: "public", + provenance: "", notes: "longitude=150.12;house=3", ...overrides, }); @@ -41,6 +43,7 @@ describe("InMemoryDatasetStore", () => { ); expect(record.row.source_tool).toBe("ephemeris"); + expect(record.row.provenance).toContain("ephemeris:2024-01-01T00:00:00.000Z"); expect(record.row.notes).toContain("provenance:timestamp=2024-01-01T00:00:00.000Z"); expect(record.row.notes).toContain("provider=ephemeris"); expect(record.row.notes).toContain('"houseSystem":"Placidus"'); @@ -64,5 +67,7 @@ describe("InMemoryDatasetStore", () => { const provenanceCount = (record.row.notes.match(/provenance:/g) ?? []).length; expect(provenanceCount).toBe(1); expect(record.row.notes.startsWith("existing")).toBe(true); + expect(record.row.provenance).toContain("ephemeris:2024-01-02T00:00:00.000Z"); + expect(record.row.provenance.split("|").length).toBe(1); }); }); diff --git a/tests/weights.test.ts b/tests/weights.test.ts index c4ef814..3eb7569 100644 --- a/tests/weights.test.ts +++ b/tests/weights.test.ts @@ -23,6 +23,8 @@ const makeRow = (overrides: Partial): DatasetRow => ({ strength: 0, confidence: 1, weight_system: 1, + privacy: "public", + provenance: "", notes: "", merged_from: [], conflict_set: null,