Compare commits

...

11 Commits

Author SHA1 Message Date
grabowski 09e0fdc9ac feat(maintenance): reminders CLI + systemd timer drop-in
Deploy to LXC / deploy (push) Successful in 15s
Validate / validate (push) Successful in 35s
Phase 3-4 of maintenance reminders.

scripts/maintenance-reminders.ts: thin CLI that opens the same
pg pool as the app and calls runRemindersOnce. Flags:
--soon-days N (default 7), --company <id> (default all),
--dry-run (count without firing), --backfill (mark all currently
due as already-sent, mutually exclusive with --dry-run). Prints a
single-line JSON summary so journald/jq handles it cleanly.

package.json: + reminders:check script.

DEPLOYMENT.md: documents the systemd .service + .timer pair for
06:00 daily, with Persistent=true so a missed run during host
downtime still fires on next boot. Includes the first-deploy
protocol (--dry-run to scope, then either let day-one alert or
--backfill for a clean slate).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 16:15:03 +07:00
grabowski f5e4743120 feat(maintenance): reminder service — findDueSchedules + runRemindersOnce
Phase 2 of maintenance reminders. Pure server-side, no UI changes.

findDueSchedules joins active time-based schedules to assets and
properties, computes the kind (overdue if past, due_soon if within
the warning window), and returns a flat shape ready for the
notification body. Usage-based schedules are intentionally excluded
— they need a different trigger (usage-reading crossover).

runRemindersOnce orchestrates: for each due schedule, look up the
company's admin+manager user_ids, atomically claim the
(schedule, kind, due_at) tuple via INSERT … ON CONFLICT DO NOTHING,
and only call notify() if we won the insert. The unique index on
maintenance_reminders_sent makes the dedup atomic across concurrent
runs and across re-invocations of the cron.

Two opt-in flags on the orchestrator: dryRun (count what would
fire without touching the DB or fanning out) and backfill (record
everything currently due as already-sent so day-one of the cron
is silent).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 16:13:46 +07:00
grabowski b4108c5a36 feat(maintenance): schema for reminders dedup log
Phase 1 of the maintenance-reminders feature.

- notification_kind: + maintenance_due_soon, maintenance_overdue.
  These are distinct from maintenance_event_recorded (service
  performed) so the bell list can group/filter reminder vs service
  cleanly.
- New maintenance_reminder_kind enum: due_soon | overdue.
- New maintenance_reminders_sent table with UNIQUE(schedule_id,
  kind, due_at). The cron uses INSERT … ON CONFLICT DO NOTHING on
  this composite to make the fire-once-per-window guarantee atomic
  even under concurrent runs. Once the schedule's next_due_at
  advances after a service event, the tuple is fresh and a new
  reminder fires.

No service code yet — Phase 2 wires the readers and orchestrator.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 16:11:34 +07:00
grabowski 435bcb981f docs(roadmap): rewrite README roadmap to reflect actual state
Deploy to LXC / deploy (push) Successful in 16s
Validate / validate (push) Successful in 31s
The original table marked Phase 1 as "next" but Phases 1-5a have
all shipped. Replaces the single table with three sections:
Shipped (now includes RBAC, rooms/floors/accounts/expenses, the
sub-property hierarchy, and the fnm/pnpm tooling switch — none of
which were on the original roadmap), 5b-pending (reports,
cross-app APIs to budget/repair), and a Phase 6 quality-of-life
backlog covering maintenance-reminder cron, OIDC handlers,
self-service password reset, permission inheritance, bulk CSV
property import, audit log viewer, and project↔property linking.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 15:51:14 +07:00
grabowski 011e7a2165 chore(graph): refresh graphify after sub-property feature
Deploy to LXC / deploy (push) Successful in 15s
Validate / validate (push) Successful in 30s
Re-extracted 42 changed code files via AST and 3 changed docs
(README, DEPLOYMENT, drizzle/README) via one semantic subagent.
Merged into the existing graph: 453→555 nodes, 486→633 edges,
137 communities.

Top god nodes now reflect the new shape: load() at the center of
every page-server route, buildfor_life_ops as the doc-side anchor,
and Drizzle ORM + Zod as the bridge between expenses and the rest
of the service layer.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 15:47:00 +07:00
grabowski c3aaf82642 feat(properties): warn when parenting exceeds depth cap of 5
Deploy to LXC / deploy (push) Successful in 16s
Validate / validate (push) Successful in 29s
Phase 5 polish. Soft cap — properties still save, but a console.warn
fires so journalctl picks up a clear signal when a tree grows
pathologically deep. Triggered on create + on parent reassignment
via updateProperty. Justification for the warning: getDescendantIds
runs an unbounded recursive CTE, and deep hierarchies are also
painful to navigate in the existing list view.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 15:36:30 +07:00
grabowski 90207135c8 feat(properties): list view renders parent/child as a depth-first tree
Deploy to LXC / deploy (push) Successful in 16s
Validate / validate (push) Successful in 30s
The flat list ordered by updatedAt scattered apartments away from
their building. Server-side flatten now does a depth-first walk —
parents render immediately above their children, alphabetical at
each level — and tags each row with its depth. The UI indents the
name column by 1.5rem per level and prefixes children with "└" for
a visible parent/child line.

Orphan rows (parent_id pointing outside the live company set) fall
back to root depth so nothing is silently dropped, even though the
restrict-on-delete FK should prevent that case.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 15:02:46 +07:00
grabowski c61be187e6 feat(properties): roll-up toggle on expenses/assets, new Maintenance + Todos tabs
Deploy to LXC / deploy (push) Successful in 15s
Validate / validate (push) Successful in 31s
Phase 4 — wires the descendant readers from Phase 2 into the UI.

- Expenses tab: ?descendants=1 query param toggles between
  "this property" and "include sub-properties". Affects the chart
  series, the 12-month summary, the recent-expense list, and the
  totals-by-kind chips.
- Assets tab: same toggle, expanded list across the property tree.
- New Maintenance tab on every property: schedules + recent events
  for assets at this property (or descendants), with overdue/active
  status badges. Scheduling itself stays at the asset level.
- New Todos tab: lists checklist instances scoped to this property
  (now possible thanks to the 'property' value added to
  checklist_scope in Phase 2). Open vs completed split.

Toggle only renders when childCount > 0 so leaf properties don't see
chrome they can't use. URL bookmarks without ?descendants stay on
the strict per-property view, preserving existing behavior.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 12:59:17 +07:00
grabowski 3106286629 feat(properties): parent picker, breadcrumb, sub-properties tab
Phase 3 of the sub-property hierarchy feature.

- New/edit forms grow a "Parent property" select. Edit-side options
  exclude the current property and its descendants so the picker
  itself can't create a cycle; service-layer assertNoCycle is the
  belt-and-braces guard if a malicious form bypasses the dropdown.
- New form accepts ?parent=<id> as a preselect so "Add sub-property"
  links from the parent's tab land in a pre-wired form.
- Property detail layout: breadcrumb (Parent › Child) when parent
  is set, plus a new "Sub-properties (N)" tab.
- Dedicated Sub-properties tab lists direct children with a
  + New sub-property button.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 12:54:36 +07:00
grabowski 3b34458a99 feat(properties): tree-aware readers for expenses, maintenance, checklists
Phase 2. Each service grows a `*ForProperties` (plural) variant that
takes a resolved property id array; the existing single-property
functions now delegate to them with `[propertyId]`. The route layer
will compute the descendant set via getDescendantIds when the user
toggles "include sub-properties" — this commit only adds the readers
behind the scenes.

Also extends the checklist_scope enum with 'property' so checklists
can attach directly to a property and the rollup query has a
well-typed scope to filter on.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 12:51:59 +07:00
grabowski 8117253841 feat(properties): add parent_id for sub-property hierarchy
Phase 1 of the parent/child rollup feature. Self-FK on properties
with ON DELETE RESTRICT, plus a CHECK that blocks self-reference at
the DB level. Service-layer helpers (getDescendantIds,
getAncestorIds, assertNoCycle) walk the tree via recursive CTEs and
guard against cycles and cross-company parents. softDeleteProperty
now refuses to delete a property with live children.

No UI yet — readers and roll-up routes land in Phase 2.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 12:49:04 +07:00
88 changed files with 20676 additions and 2505 deletions
+60
View File
@@ -190,6 +190,66 @@ systemctl status buildfor_life_ops
journalctl -u buildfor_life_ops -f
```
### Maintenance reminders timer
The app records `next_due_at` on every time-based maintenance schedule but does not poll itself. A daily systemd timer runs `pnpm run reminders:check`, which scans for schedules entering the 7-day warning window or already overdue and fans out via the existing in-app + email + Matrix notifier. Re-runs are idempotent — `maintenance_reminders_sent` deduplicates per `(schedule, kind, due_at)`.
`/etc/systemd/system/buildfor_life_ops-reminders.service`:
```ini
[Unit]
Description=buildfor_life_ops maintenance reminder cron
After=postgresql.service network.target
Wants=postgresql.service
[Service]
Type=oneshot
User=ops
Group=ops
WorkingDirectory=/home/ops/buildfor_life_ops
EnvironmentFile=/home/ops/buildfor_life_ops/.env
Environment=NODE_ENV=production
ExecStart=/home/ops/.local/share/fnm/aliases/default/bin/pnpm run reminders:check
```
`/etc/systemd/system/buildfor_life_ops-reminders.timer`:
```ini
[Unit]
Description=Run buildfor_life_ops maintenance reminders daily
[Timer]
OnCalendar=*-*-* 06:00:00
Persistent=true
Unit=buildfor_life_ops-reminders.service
[Install]
WantedBy=timers.target
```
Enable:
```bash
sudo systemctl daemon-reload
sudo systemctl enable --now buildfor_life_ops-reminders.timer
sudo systemctl list-timers buildfor_life_ops-reminders.timer
```
**First-run protocol** to avoid a deluge of stale alerts on day one:
```bash
# Inspect what would fire without notifying.
sudo -iu ops bash -lc 'cd ~/buildfor_life_ops && pnpm run reminders:check -- --dry-run'
# If the count is reasonable, run normally — the timer will pick up subsequent
# windows automatically. Or, if you want a clean slate, mark everything
# currently-due as already-notified (no fan-out), so day-one alerts only
# new breaches:
sudo -iu ops bash -lc 'cd ~/buildfor_life_ops && pnpm run reminders:check -- --backfill'
```
Logs end up in `journalctl -u buildfor_life_ops-reminders.service`. Each run prints a single JSON line (`{ ok, scanned, fired, skippedDedup, noRecipients, ... }`) so `journalctl --output=cat | grep '"ok":true' | jq` gives a clean trend view.
## 11. Reverse proxy (nginx)
`/etc/nginx/sites-available/buildfor_life_ops`:
+32 -8
View File
@@ -161,14 +161,38 @@ static/ public static assets (drop favicon here)
## Roadmap
| Phase | Scope | State |
| --- | --- | --- |
| 0 | Scaffold: stack wiring + auth + layout shell + storage interface + tenancy schema + git remote | ✅ shipped |
| 1 | Properties + Assets with typed custom fields + mobility history + asset logs + document upload per scope | next |
| 2 | Checklist templates + maintenance schedules (time + usage) + maintenance events + usage readings | |
| 3 | Projects + WorkPackages → Tasks → Subtasks + **structured decision events** (title, body, alternatives, cost_impact, approved_by, tags) | |
| 4 | Wiki (global + project + property) with EasyMDE + revisions + FTS | |
| 5 *(later)* | QR label generation, email/in-app notifications, reports, S3 storage adapter, cross-app APIs | |
### Shipped
| Phase | Scope |
| --- | --- |
| 0 | Scaffold: stack wiring + auth + layout shell + storage interface + tenancy schema + git remote |
| 1 | Properties + Assets with typed custom fields + mobility history + asset logs + document upload per scope |
| 2 | Checklist templates + maintenance schedules (time + usage) + maintenance events + usage readings |
| 3 | Projects + WorkPackages → Tasks → Subtasks + structured decision events (title, body, alternatives, cost_impact, approved_by, tags) |
| 4 | Wiki (global + project + property) with EasyMDE + revisions + FTS |
| 5a | QR label generation, in-app + email + Matrix notifications, S3 storage adapter |
| — | RBAC: user/company admin, role middleware, last-admin guards |
| — | Property structure: rooms + floors, utility account/meter records, expenses with CSV import + electricity/water chart |
| — | Sub-property hierarchy: parent_id self-FK, recursive-CTE descendant rollups, "Include sub-properties" toggle on Expenses/Assets/Maintenance/Todos tabs, depth-first nested list view, depth-cap warning at 5 levels |
| — | Tooling: switched to fnm + pnpm, Gitea CI deploy workflow with public-HTTPS clone, full `DEPLOYMENT.md` |
### 5b — pending Phase 5 items
- **Reports** — cross-cutting outputs (monthly building roll-up, annual asset summary). Per-domain CSV exports already exist; this is the aggregated/scheduled layer
- **Cross-app APIs** — wire `buildfor_life_ops` to siblings `buildfor_life_budget` and `buildfor_life_repair` so decisions can link to budget line items and repair tickets
### 6 — quality-of-life backlog
Things that came up after the original roadmap was written. Not prioritised — pull whichever the next stakeholder ask depends on.
- **Maintenance reminders** — cron-style trigger that fires `maintenance_event_recorded` / `task_assigned` notifications when `next_due_at` crosses a threshold. The notification fan-out (app + email + Matrix) is already there; only the scheduler is missing
- **OIDC SSO** — env vars + schema are wired (`OIDC_ENABLED`, `OIDC_ISSUER`, etc.), but the route handlers were never written
- **Self-service password reset** — currently admin-initiated only via `pnpm run create-user`
- **Permission inheritance for sub-properties** — RBAC is per-company today; "manager of Building A" does not imply "manager of Apt 3B"
- **Bulk property CSV import** — "create N apartments under this building" in one upload, mirroring the existing expenses CSV importer
- **"Move to parent" quick action** — UI sugar over the existing edit form for re-parenting
- **Audit log viewer** — `audit_action` enum + audit infrastructure exists, but no UI to browse it
- **Project ↔ property linking** — they're disjoint today; many real workflows want "this project is happening at this property"
## Key design decisions
+3
View File
@@ -0,0 +1,3 @@
ALTER TABLE "properties" ADD COLUMN "parent_id" uuid;--> statement-breakpoint
ALTER TABLE "properties" ADD CONSTRAINT "properties_parent_id_properties_id_fk" FOREIGN KEY ("parent_id") REFERENCES "public"."properties"("id") ON DELETE restrict ON UPDATE no action;--> statement-breakpoint
CREATE INDEX "properties_by_parent" ON "properties" USING btree ("company_id","parent_id");
+7
View File
@@ -0,0 +1,7 @@
-- A property cannot be its own parent. Deeper cycle prevention (e.g.
-- A→B→A) is enforced in the service layer because Postgres can't express
-- recursive CHECK constraints; the assertNoCycle helper in
-- src/lib/server/services/properties.ts walks ancestors before save.
ALTER TABLE "properties"
ADD CONSTRAINT "properties_parent_not_self"
CHECK (parent_id IS NULL OR parent_id <> id);
@@ -0,0 +1 @@
ALTER TYPE "public"."checklist_scope" ADD VALUE 'property';
+14
View File
@@ -0,0 +1,14 @@
CREATE TYPE "public"."maintenance_reminder_kind" AS ENUM('due_soon', 'overdue');--> statement-breakpoint
ALTER TYPE "public"."notification_kind" ADD VALUE 'maintenance_due_soon' BEFORE 'generic';--> statement-breakpoint
ALTER TYPE "public"."notification_kind" ADD VALUE 'maintenance_overdue' BEFORE 'generic';--> statement-breakpoint
CREATE TABLE "maintenance_reminders_sent" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"schedule_id" uuid NOT NULL,
"kind" "maintenance_reminder_kind" NOT NULL,
"due_at" timestamp with time zone NOT NULL,
"fired_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
ALTER TABLE "maintenance_reminders_sent" ADD CONSTRAINT "maintenance_reminders_sent_schedule_id_maintenance_schedules_id_fk" FOREIGN KEY ("schedule_id") REFERENCES "public"."maintenance_schedules"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
CREATE UNIQUE INDEX "mrs_schedule_kind_due_uq" ON "maintenance_reminders_sent" USING btree ("schedule_id","kind","due_at");--> statement-breakpoint
CREATE INDEX "mrs_by_schedule" ON "maintenance_reminders_sent" USING btree ("schedule_id","kind");
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+28
View File
@@ -113,6 +113,34 @@
"when": 1776932900000,
"tag": "0015_expenses_updated_at_trigger",
"breakpoints": true
},
{
"idx": 16,
"version": "7",
"when": 1777268853483,
"tag": "0016_property_parent_id",
"breakpoints": true
},
{
"idx": 17,
"version": "7",
"when": 1777268853484,
"tag": "0017_property_parent_check",
"breakpoints": true
},
{
"idx": 18,
"version": "7",
"when": 1777268985448,
"tag": "0018_checklist_scope_property",
"breakpoints": true
},
{
"idx": 19,
"version": "7",
"when": 1777281036233,
"tag": "0019_maintenance_reminders",
"breakpoints": true
}
]
}
-117
View File
@@ -1,117 +0,0 @@
{
"nodes": [
{"id": "readme_buildfor_life_ops", "label": "buildfor_life_ops", "file_type": "document", "source_file": "README.md", "source_location": "L1-L3", "source_url": null, "captured_at": null, "author": null, "contributor": null},
{"id": "readme_buildfor_life_budget", "label": "buildfor_life_budget (sibling)", "file_type": "document", "source_file": "README.md", "source_location": "L4,L178", "source_url": "https://git.b4l.co.th/B4L/buildfor_life_budget", "captured_at": null, "author": null, "contributor": null},
{"id": "readme_buildfor_life_repair", "label": "buildfor_life_repair (sibling)", "file_type": "document", "source_file": "README.md", "source_location": "L4,L179", "source_url": null, "captured_at": null, "author": null, "contributor": null},
{"id": "readme_stack_sveltekit5", "label": "SvelteKit 5 (adapter-node)", "file_type": "document", "source_file": "README.md", "source_location": "L8", "source_url": null, "captured_at": null, "author": null, "contributor": null},
{"id": "readme_stack_tailwind_v4", "label": "Tailwind v4 + @theme inline tokens", "file_type": "document", "source_file": "README.md", "source_location": "L9", "source_url": null, "captured_at": null, "author": null, "contributor": null},
{"id": "readme_stack_postgres_drizzle", "label": "PostgreSQL 16+ via Drizzle ORM + Zod", "file_type": "document", "source_file": "README.md", "source_location": "L10", "source_url": null, "captured_at": null, "author": null, "contributor": null},
{"id": "readme_auth_argon2id", "label": "Argon2id sessions (@node-rs/argon2 + @oslojs/crypto)", "file_type": "document", "source_file": "README.md", "source_location": "L11", "source_url": null, "captured_at": null, "author": null, "contributor": null},
{"id": "readme_easymde", "label": "EasyMDE markdown editor", "file_type": "document", "source_file": "README.md", "source_location": "L12", "source_url": null, "captured_at": null, "author": null, "contributor": null},
{"id": "readme_sharp", "label": "Sharp image thumbnails", "file_type": "document", "source_file": "README.md", "source_location": "L12", "source_url": null, "captured_at": null, "author": null, "contributor": null},
{"id": "readme_storage_adapter", "label": "StorageAdapter interface", "file_type": "document", "source_file": "README.md", "source_location": "L13", "source_url": null, "captured_at": null, "author": null, "contributor": null},
{"id": "readme_local_disk_storage", "label": "LocalDiskStorage", "file_type": "document", "source_file": "README.md", "source_location": "L13,L143", "source_url": null, "captured_at": null, "author": null, "contributor": null},
{"id": "readme_s3_storage", "label": "S3Storage (future)", "file_type": "document", "source_file": "README.md", "source_location": "L144", "source_url": null, "captured_at": null, "author": null, "contributor": null},
{"id": "readme_env_dotenv", "label": ".env configuration", "file_type": "document", "source_file": "README.md", "source_location": "L29-L44", "source_url": null, "captured_at": null, "author": null, "contributor": null},
{"id": "readme_create_user_script", "label": "npm run create-user script", "file_type": "document", "source_file": "README.md", "source_location": "L59-L66,L122", "source_url": null, "captured_at": null, "author": null, "contributor": null},
{"id": "readme_db_migrate", "label": "npm run db:migrate", "file_type": "document", "source_file": "README.md", "source_location": "L54,L86", "source_url": null, "captured_at": null, "author": null, "contributor": null},
{"id": "readme_db_generate", "label": "npm run db:generate", "file_type": "document", "source_file": "README.md", "source_location": "L85", "source_url": null, "captured_at": null, "author": null, "contributor": null},
{"id": "readme_db_push", "label": "npm run db:push (dev only)", "file_type": "document", "source_file": "README.md", "source_location": "L87", "source_url": null, "captured_at": null, "author": null, "contributor": null},
{"id": "readme_db_studio", "label": "npm run db:studio (Drizzle Studio)", "file_type": "document", "source_file": "README.md", "source_location": "L88", "source_url": null, "captured_at": null, "author": null, "contributor": null},
{"id": "readme_db_seed", "label": "npm run db:seed", "file_type": "document", "source_file": "README.md", "source_location": "L89", "source_url": null, "captured_at": null, "author": null, "contributor": null},
{"id": "readme_validate_script", "label": "npm run validate (check + build)", "file_type": "document", "source_file": "README.md", "source_location": "L84", "source_url": null, "captured_at": null, "author": null, "contributor": null},
{"id": "readme_auth_model", "label": "Auth model (sessions + hashed cookies)", "file_type": "document", "source_file": "README.md", "source_location": "L132-L138", "source_url": null, "captured_at": null, "author": null, "contributor": null},
{"id": "readme_sliding_renewal", "label": "Sliding session renewal (30d/15d)", "file_type": "document", "source_file": "README.md", "source_location": "L135", "source_url": null, "captured_at": null, "author": null, "contributor": null},
{"id": "readme_sha256_cookie_hash", "label": "SHA-256 cookie hashing before DB lookup", "file_type": "document", "source_file": "README.md", "source_location": "L134", "source_url": null, "captured_at": null, "author": null, "contributor": null},
{"id": "readme_company_users", "label": "company_users role mapping", "file_type": "document", "source_file": "README.md", "source_location": "L138", "source_url": null, "captured_at": null, "author": null, "contributor": null},
{"id": "readme_storage_model", "label": "Storage model (opaque storage_key)", "file_type": "document", "source_file": "README.md", "source_location": "L141-L144", "source_url": null, "captured_at": null, "author": null, "contributor": null},
{"id": "readme_hmac_signed_urls", "label": "HMAC-signed short-lived file URLs", "file_type": "document", "source_file": "README.md", "source_location": "L143", "source_url": null, "captured_at": null, "author": null, "contributor": null},
{"id": "readme_api_files_route", "label": "/api/files route (signature verification + streaming)", "file_type": "document", "source_file": "README.md", "source_location": "L120,L143", "source_url": null, "captured_at": null, "author": null, "contributor": null},
{"id": "readme_layout_app_group", "label": "(app) route group (authed shell)", "file_type": "document", "source_file": "README.md", "source_location": "L113-L116,L137", "source_url": null, "captured_at": null, "author": null, "contributor": null},
{"id": "readme_layout_auth_group", "label": "(auth) route group (login shell)", "file_type": "document", "source_file": "README.md", "source_location": "L117-L118", "source_url": null, "captured_at": null, "author": null, "contributor": null},
{"id": "readme_hooks_server", "label": "hooks.server.ts (session validation)", "file_type": "document", "source_file": "README.md", "source_location": "L99", "source_url": null, "captured_at": null, "author": null, "contributor": null},
{"id": "readme_env_ts", "label": "env.ts (Zod-validated process.env)", "file_type": "document", "source_file": "README.md", "source_location": "L108", "source_url": null, "captured_at": null, "author": null, "contributor": null},
{"id": "readme_db_schema_dir", "label": "src/lib/server/db/schema/", "file_type": "document", "source_file": "README.md", "source_location": "L105-L106", "source_url": null, "captured_at": null, "author": null, "contributor": null},
{"id": "readme_roadmap_phase0", "label": "Phase 0: scaffold (shipped)", "file_type": "document", "source_file": "README.md", "source_location": "L150", "source_url": null, "captured_at": null, "author": null, "contributor": null},
{"id": "readme_roadmap_phase1", "label": "Phase 1: Properties + Assets", "file_type": "document", "source_file": "README.md", "source_location": "L151", "source_url": null, "captured_at": null, "author": null, "contributor": null},
{"id": "readme_roadmap_phase2", "label": "Phase 2: Checklists + maintenance", "file_type": "document", "source_file": "README.md", "source_location": "L152", "source_url": null, "captured_at": null, "author": null, "contributor": null},
{"id": "readme_roadmap_phase3", "label": "Phase 3: Projects + structured decisions", "file_type": "document", "source_file": "README.md", "source_location": "L153", "source_url": null, "captured_at": null, "author": null, "contributor": null},
{"id": "readme_roadmap_phase4", "label": "Phase 4: Wiki + FTS", "file_type": "document", "source_file": "README.md", "source_location": "L154", "source_url": null, "captured_at": null, "author": null, "contributor": null},
{"id": "readme_roadmap_phase5", "label": "Phase 5: QR, notifications, S3", "file_type": "document", "source_file": "README.md", "source_location": "L155", "source_url": null, "captured_at": null, "author": null, "contributor": null},
{"id": "readme_decision_uuidv7", "label": "Decision: UUID v7 primary keys", "file_type": "document", "source_file": "README.md", "source_location": "L161", "source_url": null, "captured_at": null, "author": null, "contributor": null},
{"id": "readme_decision_timestamptz", "label": "Decision: timestamptz UTC everywhere", "file_type": "document", "source_file": "README.md", "source_location": "L162", "source_url": null, "captured_at": null, "author": null, "contributor": null},
{"id": "readme_decision_soft_delete", "label": "Decision: soft delete (deleted_at)", "file_type": "document", "source_file": "README.md", "source_location": "L163", "source_url": null, "captured_at": null, "author": null, "contributor": null},
{"id": "readme_decision_money_type", "label": "Decision: numeric(18,4) + char(3) currency", "file_type": "document", "source_file": "README.md", "source_location": "L164", "source_url": null, "captured_at": null, "author": null, "contributor": null},
{"id": "readme_decision_jsonb_custom_fields", "label": "Decision: JSONB custom fields + asset_field_defs", "file_type": "document", "source_file": "README.md", "source_location": "L165", "source_url": null, "captured_at": null, "author": null, "contributor": null},
{"id": "readme_decision_xor_location", "label": "Decision: XOR asset location (project XOR property)", "file_type": "document", "source_file": "README.md", "source_location": "L166", "source_url": null, "captured_at": null, "author": null, "contributor": null},
{"id": "readme_decision_asset_history", "label": "Decision: asset_location_history (movable assets)", "file_type": "document", "source_file": "README.md", "source_location": "L167", "source_url": null, "captured_at": null, "author": null, "contributor": null},
{"id": "readme_decision_immutable_keys", "label": "Decision: immutable custom-field keys", "file_type": "document", "source_file": "README.md", "source_location": "L168", "source_url": null, "captured_at": null, "author": null, "contributor": null},
{"id": "readme_decision_decision_scope", "label": "Decision: decisions scoped to project/property/asset/work_package", "file_type": "document", "source_file": "README.md", "source_location": "L169", "source_url": null, "captured_at": null, "author": null, "contributor": null},
{"id": "readme_decision_currency_default", "label": "Decision: company default currency in settings_json", "file_type": "document", "source_file": "README.md", "source_location": "L170", "source_url": null, "captured_at": null, "author": null, "contributor": null},
{"id": "readme_decision_tabs_routes", "label": "Decision: tabs = nested routes (not query-string)", "file_type": "document", "source_file": "README.md", "source_location": "L171", "source_url": null, "captured_at": null, "author": null, "contributor": null},
{"id": "readme_decision_theme_key", "label": "Decision: localStorage['theme'] key shared across siblings", "file_type": "document", "source_file": "README.md", "source_location": "L172", "source_url": null, "captured_at": null, "author": null, "contributor": null},
{"id": "drizzle_readme_migrations", "label": "Drizzle migrations directory", "file_type": "document", "source_file": "drizzle/README.md", "source_location": "L1-L5", "source_url": null, "captured_at": null, "author": null, "contributor": null},
{"id": "drizzle_readme_review_rationale", "label": "Review SQL after generate: enum/index/custom_fields", "file_type": "document", "source_file": "drizzle/README.md", "source_location": "L13-L18", "source_url": null, "captured_at": null, "author": null, "contributor": null},
{"id": "drizzle_readme_concurrently_note", "label": "Use CONCURRENTLY on large-table index changes", "file_type": "document", "source_file": "drizzle/README.md", "source_location": "L16", "source_url": null, "captured_at": null, "author": null, "contributor": null},
{"id": "drizzle_readme_immutable_key_ref", "label": "Immutable-key policy reference", "file_type": "document", "source_file": "drizzle/README.md", "source_location": "L17-L18", "source_url": null, "captured_at": null, "author": null, "contributor": null},
{"id": "apphtml_root", "label": "app.html root document", "file_type": "code", "source_file": "src/app.html", "source_location": "L1-L20", "source_url": null, "captured_at": null, "author": null, "contributor": null},
{"id": "apphtml_theme_bootstrap", "label": "Dark-mode bootstrap inline script (localStorage['theme'])", "file_type": "code", "source_file": "src/app.html", "source_location": "L7-L14", "source_url": null, "captured_at": null, "author": null, "contributor": null},
{"id": "apphtml_sveltekit_placeholders", "label": "%sveltekit.head% / %sveltekit.body% placeholders", "file_type": "code", "source_file": "src/app.html", "source_location": "L15,L18", "source_url": null, "captured_at": null, "author": null, "contributor": null},
{"id": "apphtml_tailwind_body_classes", "label": "Tailwind body classes with dark: variants", "file_type": "code", "source_file": "src/app.html", "source_location": "L17", "source_url": null, "captured_at": null, "author": null, "contributor": null},
{"id": "apphtml_preload_hover", "label": "data-sveltekit-preload-data=hover", "file_type": "code", "source_file": "src/app.html", "source_location": "L17", "source_url": null, "captured_at": null, "author": null, "contributor": null}
],
"edges": [
{"source": "readme_buildfor_life_ops", "target": "readme_buildfor_life_budget", "relation": "references", "confidence": "EXTRACTED", "confidence_score": 1.0, "source_file": "README.md", "source_location": "L4", "weight": 1.0},
{"source": "readme_buildfor_life_ops", "target": "readme_buildfor_life_repair", "relation": "references", "confidence": "EXTRACTED", "confidence_score": 1.0, "source_file": "README.md", "source_location": "L4", "weight": 1.0},
{"source": "readme_buildfor_life_ops", "target": "readme_stack_sveltekit5", "relation": "implements", "confidence": "EXTRACTED", "confidence_score": 1.0, "source_file": "README.md", "source_location": "L8", "weight": 1.0},
{"source": "readme_buildfor_life_ops", "target": "readme_stack_tailwind_v4", "relation": "implements", "confidence": "EXTRACTED", "confidence_score": 1.0, "source_file": "README.md", "source_location": "L9", "weight": 1.0},
{"source": "readme_buildfor_life_ops", "target": "readme_stack_postgres_drizzle", "relation": "implements", "confidence": "EXTRACTED", "confidence_score": 1.0, "source_file": "README.md", "source_location": "L10", "weight": 1.0},
{"source": "readme_buildfor_life_ops", "target": "readme_auth_argon2id", "relation": "implements", "confidence": "EXTRACTED", "confidence_score": 1.0, "source_file": "README.md", "source_location": "L11", "weight": 1.0},
{"source": "readme_buildfor_life_ops", "target": "readme_easymde", "relation": "references", "confidence": "EXTRACTED", "confidence_score": 1.0, "source_file": "README.md", "source_location": "L12", "weight": 1.0},
{"source": "readme_buildfor_life_ops", "target": "readme_sharp", "relation": "references", "confidence": "EXTRACTED", "confidence_score": 1.0, "source_file": "README.md", "source_location": "L12", "weight": 1.0},
{"source": "readme_storage_adapter", "target": "readme_local_disk_storage", "relation": "implements", "confidence": "EXTRACTED", "confidence_score": 1.0, "source_file": "README.md", "source_location": "L13", "weight": 1.0},
{"source": "readme_storage_adapter", "target": "readme_s3_storage", "relation": "implements", "confidence": "EXTRACTED", "confidence_score": 1.0, "source_file": "README.md", "source_location": "L144", "weight": 1.0},
{"source": "readme_local_disk_storage", "target": "readme_hmac_signed_urls", "relation": "implements", "confidence": "EXTRACTED", "confidence_score": 1.0, "source_file": "README.md", "source_location": "L143", "weight": 1.0},
{"source": "readme_api_files_route", "target": "readme_hmac_signed_urls", "relation": "implements", "confidence": "EXTRACTED", "confidence_score": 1.0, "source_file": "README.md", "source_location": "L120,L143", "weight": 1.0},
{"source": "readme_api_files_route", "target": "readme_local_disk_storage", "relation": "calls", "confidence": "EXTRACTED", "confidence_score": 1.0, "source_file": "README.md", "source_location": "L120", "weight": 1.0},
{"source": "readme_storage_model", "target": "readme_storage_adapter", "relation": "rationale_for", "confidence": "EXTRACTED", "confidence_score": 1.0, "source_file": "README.md", "source_location": "L141-L144", "weight": 1.0},
{"source": "readme_auth_model", "target": "readme_sha256_cookie_hash", "relation": "rationale_for", "confidence": "EXTRACTED", "confidence_score": 1.0, "source_file": "README.md", "source_location": "L134", "weight": 1.0},
{"source": "readme_auth_model", "target": "readme_sliding_renewal", "relation": "references", "confidence": "EXTRACTED", "confidence_score": 1.0, "source_file": "README.md", "source_location": "L135", "weight": 1.0},
{"source": "readme_auth_model", "target": "readme_company_users", "relation": "references", "confidence": "EXTRACTED", "confidence_score": 1.0, "source_file": "README.md", "source_location": "L138", "weight": 1.0},
{"source": "readme_hooks_server", "target": "readme_auth_model", "relation": "implements", "confidence": "EXTRACTED", "confidence_score": 1.0, "source_file": "README.md", "source_location": "L99,L135", "weight": 1.0},
{"source": "readme_layout_app_group", "target": "readme_auth_model", "relation": "implements", "confidence": "EXTRACTED", "confidence_score": 1.0, "source_file": "README.md", "source_location": "L137", "weight": 1.0},
{"source": "readme_env_dotenv", "target": "readme_env_ts", "relation": "shares_data_with", "confidence": "EXTRACTED", "confidence_score": 1.0, "source_file": "README.md", "source_location": "L108,L183", "weight": 1.0},
{"source": "readme_create_user_script", "target": "readme_db_schema_dir", "relation": "shares_data_with", "confidence": "INFERRED", "confidence_score": 0.8, "source_file": "README.md", "source_location": "L122", "weight": 1.0},
{"source": "readme_db_migrate", "target": "drizzle_readme_migrations", "relation": "calls", "confidence": "EXTRACTED", "confidence_score": 1.0, "source_file": "drizzle/README.md", "source_location": "L9", "weight": 1.0},
{"source": "readme_db_generate", "target": "drizzle_readme_migrations", "relation": "calls", "confidence": "EXTRACTED", "confidence_score": 1.0, "source_file": "drizzle/README.md", "source_location": "L8", "weight": 1.0},
{"source": "readme_db_push", "target": "drizzle_readme_migrations", "relation": "references", "confidence": "EXTRACTED", "confidence_score": 1.0, "source_file": "drizzle/README.md", "source_location": "L10", "weight": 1.0},
{"source": "readme_db_studio", "target": "drizzle_readme_migrations", "relation": "references", "confidence": "EXTRACTED", "confidence_score": 1.0, "source_file": "drizzle/README.md", "source_location": "L11", "weight": 1.0},
{"source": "drizzle_readme_review_rationale", "target": "drizzle_readme_migrations", "relation": "rationale_for", "confidence": "EXTRACTED", "confidence_score": 1.0, "source_file": "drizzle/README.md", "source_location": "L13-L18", "weight": 1.0},
{"source": "drizzle_readme_concurrently_note", "target": "drizzle_readme_review_rationale", "relation": "rationale_for", "confidence": "EXTRACTED", "confidence_score": 1.0, "source_file": "drizzle/README.md", "source_location": "L16", "weight": 1.0},
{"source": "drizzle_readme_immutable_key_ref", "target": "readme_decision_immutable_keys", "relation": "references", "confidence": "EXTRACTED", "confidence_score": 1.0, "source_file": "drizzle/README.md", "source_location": "L17-L18", "weight": 1.0},
{"source": "readme_decision_jsonb_custom_fields", "target": "readme_decision_immutable_keys", "relation": "conceptually_related_to", "confidence": "EXTRACTED", "confidence_score": 1.0, "source_file": "README.md", "source_location": "L165,L168", "weight": 1.0},
{"source": "readme_decision_xor_location", "target": "readme_decision_asset_history", "relation": "conceptually_related_to", "confidence": "EXTRACTED", "confidence_score": 1.0, "source_file": "README.md", "source_location": "L166-L167", "weight": 1.0},
{"source": "readme_roadmap_phase5", "target": "readme_s3_storage", "relation": "references", "confidence": "EXTRACTED", "confidence_score": 1.0, "source_file": "README.md", "source_location": "L155", "weight": 1.0},
{"source": "readme_roadmap_phase3", "target": "readme_decision_decision_scope", "relation": "references", "confidence": "EXTRACTED", "confidence_score": 1.0, "source_file": "README.md", "source_location": "L153,L169", "weight": 1.0},
{"source": "readme_roadmap_phase4", "target": "readme_easymde", "relation": "references", "confidence": "EXTRACTED", "confidence_score": 1.0, "source_file": "README.md", "source_location": "L154,L12", "weight": 1.0},
{"source": "readme_roadmap_phase1", "target": "readme_decision_jsonb_custom_fields", "relation": "references", "confidence": "EXTRACTED", "confidence_score": 1.0, "source_file": "README.md", "source_location": "L151,L165", "weight": 1.0},
{"source": "apphtml_root", "target": "apphtml_theme_bootstrap", "relation": "references", "confidence": "EXTRACTED", "confidence_score": 1.0, "source_file": "src/app.html", "source_location": "L7-L14", "weight": 1.0},
{"source": "apphtml_root", "target": "apphtml_sveltekit_placeholders", "relation": "references", "confidence": "EXTRACTED", "confidence_score": 1.0, "source_file": "src/app.html", "source_location": "L15,L18", "weight": 1.0},
{"source": "apphtml_root", "target": "apphtml_tailwind_body_classes", "relation": "references", "confidence": "EXTRACTED", "confidence_score": 1.0, "source_file": "src/app.html", "source_location": "L17", "weight": 1.0},
{"source": "apphtml_root", "target": "apphtml_preload_hover", "relation": "references", "confidence": "EXTRACTED", "confidence_score": 1.0, "source_file": "src/app.html", "source_location": "L17", "weight": 1.0},
{"source": "apphtml_theme_bootstrap", "target": "readme_decision_theme_key", "relation": "implements", "confidence": "EXTRACTED", "confidence_score": 1.0, "source_file": "README.md", "source_location": "L172", "weight": 1.0},
{"source": "apphtml_tailwind_body_classes", "target": "readme_stack_tailwind_v4", "relation": "implements", "confidence": "EXTRACTED", "confidence_score": 1.0, "source_file": "README.md", "source_location": "L9,L17", "weight": 1.0},
{"source": "apphtml_root", "target": "readme_stack_sveltekit5", "relation": "implements", "confidence": "INFERRED", "confidence_score": 0.9, "source_file": "src/app.html", "source_location": "L1-L20", "weight": 1.0},
{"source": "readme_decision_theme_key", "target": "apphtml_theme_bootstrap", "relation": "rationale_for", "confidence": "EXTRACTED", "confidence_score": 1.0, "source_file": "README.md", "source_location": "L172", "weight": 1.0},
{"source": "readme_buildfor_life_ops", "target": "readme_buildfor_life_budget", "relation": "semantically_similar_to", "confidence": "INFERRED", "confidence_score": 0.85, "source_file": "README.md", "source_location": "L4,L178", "weight": 1.0},
{"source": "readme_buildfor_life_ops", "target": "readme_buildfor_life_repair", "relation": "semantically_similar_to", "confidence": "INFERRED", "confidence_score": 0.85, "source_file": "README.md", "source_location": "L4,L179", "weight": 1.0},
{"source": "readme_local_disk_storage", "target": "readme_s3_storage", "relation": "semantically_similar_to", "confidence": "INFERRED", "confidence_score": 0.9, "source_file": "README.md", "source_location": "L13,L144", "weight": 1.0}
],
"hyperedges": [
{"id": "auth_session_flow", "label": "Session auth flow (cookie, hash, hook, gate)", "nodes": ["readme_auth_model", "readme_sha256_cookie_hash", "readme_sliding_renewal", "readme_hooks_server", "readme_layout_app_group"], "relation": "participate_in", "confidence": "EXTRACTED", "confidence_score": 0.95, "source_file": "README.md"},
{"id": "storage_abstraction_stack", "label": "Storage abstraction (adapter, local impl, signed URLs, file route)", "nodes": ["readme_storage_adapter", "readme_local_disk_storage", "readme_hmac_signed_urls", "readme_api_files_route", "readme_storage_model"], "relation": "implement", "confidence": "EXTRACTED", "confidence_score": 0.95, "source_file": "README.md"},
{"id": "theme_propagation_pattern", "label": "Cross-sibling theme propagation via localStorage", "nodes": ["readme_decision_theme_key", "apphtml_theme_bootstrap", "readme_buildfor_life_budget", "readme_buildfor_life_repair"], "relation": "form", "confidence": "INFERRED", "confidence_score": 0.8, "source_file": "README.md"}
],
"input_tokens": 0,
"output_tokens": 0
}
+249 -205
View File
@@ -1,45 +1,45 @@
# Graph Report - C:/dev/build_for_life_project (2026-04-23)
# Graph Report - . (2026-04-27)
## Corpus Check
- 189 files · ~54,875 words
- 47 files · ~0 words
- Verdict: corpus is large enough that graph structure adds value.
## Summary
- 453 nodes · 486 edges · 131 communities detected
- Extraction: 80% EXTRACTED · 20% INFERRED · 0% AMBIGUOUS · INFERRED: 97 edges (avg confidence: 0.8)
- 555 nodes · 633 edges · 137 communities detected
- Extraction: 83% EXTRACTED · 17% INFERRED · 0% AMBIGUOUS · INFERRED: 109 edges (avg confidence: 0.8)
- Token cost: 0 input · 0 output
## Community Hubs (Navigation)
- [[_COMMUNITY_Auth & Load Helpers|Auth & Load Helpers]]
- [[_COMMUNITY_Documents Service|Documents Service]]
- [[_COMMUNITY_Assets Service & CSV|Assets Service & CSV]]
- [[_COMMUNITY_Email & Markdown|Email & Markdown]]
- [[_COMMUNITY_Property Accounts|Property Accounts]]
- [[_COMMUNITY_Page Server Loaders|Page Server Loaders]]
- [[_COMMUNITY_Stack & Deployment Concepts|Stack & Deployment Concepts]]
- [[_COMMUNITY_Documents & Storage Adapters|Documents & Storage Adapters]]
- [[_COMMUNITY_Auth Sessions & Catalog|Auth Sessions & Catalog]]
- [[_COMMUNITY_CSV & API Endpoints|CSV & API Endpoints]]
- [[_COMMUNITY_Expenses Service|Expenses Service]]
- [[_COMMUNITY_Email & Markdown Rendering|Email & Markdown Rendering]]
- [[_COMMUNITY_Projects Service|Projects Service]]
- [[_COMMUNITY_App Shell & Theme|App Shell & Theme]]
- [[_COMMUNITY_Asset Core|Asset Core]]
- [[_COMMUNITY_Maintenance Core|Maintenance Core]]
- [[_COMMUNITY_Checklists|Checklists]]
- [[_COMMUNITY_Admin Scripts|Admin Scripts]]
- [[_COMMUNITY_Asset Types Editor|Asset Types Editor]]
- [[_COMMUNITY_Rooms & Floors|Rooms & Floors]]
- [[_COMMUNITY_Tasks|Tasks]]
- [[_COMMUNITY_User Management|User Management]]
- [[_COMMUNITY_DB Schema Helpers|DB Schema Helpers]]
- [[_COMMUNITY_Bootstrap Scripts|Bootstrap Scripts]]
- [[_COMMUNITY_Maintenance Schedules|Maintenance Schedules]]
- [[_COMMUNITY_Assets Service|Assets Service]]
- [[_COMMUNITY_App Shell & Bootstrap|App Shell & Bootstrap]]
- [[_COMMUNITY_Checklists Service|Checklists Service]]
- [[_COMMUNITY_Rooms & Floors Service|Rooms & Floors Service]]
- [[_COMMUNITY_Tasks & Subtasks|Tasks & Subtasks]]
- [[_COMMUNITY_Work Packages|Work Packages]]
- [[_COMMUNITY_Storage Layer|Storage Layer]]
- [[_COMMUNITY_Migration Workflow|Migration Workflow]]
- [[_COMMUNITY_Session Auth|Session Auth]]
- [[_COMMUNITY_Signed URL Storage Layer|Signed URL Storage Layer]]
- [[_COMMUNITY_Drizzle Migration Conventions|Drizzle Migration Conventions]]
- [[_COMMUNITY_Auth Model|Auth Model]]
- [[_COMMUNITY_Companies Service|Companies Service]]
- [[_COMMUNITY_Form Utilities|Form Utilities]]
- [[_COMMUNITY_Custom Fields Design|Custom Fields Design]]
- [[_COMMUNITY_Field Types|Field Types]]
- [[_COMMUNITY_Form Helper|Form Helper]]
- [[_COMMUNITY_Env Config|Env Config]]
- [[_COMMUNITY_Seed Script|Seed Script]]
- [[_COMMUNITY_Asset Location Design|Asset Location Design]]
- [[_COMMUNITY_Decision Design|Decision Design]]
- [[_COMMUNITY_Drizzle Config|Drizzle Config]]
- [[_COMMUNITY_JSONB Custom Fields Policy|JSONB Custom Fields Policy]]
- [[_COMMUNITY_Cluster 21|Cluster 21]]
- [[_COMMUNITY_Cluster 22|Cluster 22]]
- [[_COMMUNITY_Cluster 23|Cluster 23]]
- [[_COMMUNITY_Cluster 24|Cluster 24]]
- [[_COMMUNITY_Cluster 25|Cluster 25]]
- [[_COMMUNITY_Cluster 26|Cluster 26]]
- [[_COMMUNITY_Cluster 27|Cluster 27]]
- [[_COMMUNITY_Cluster 28|Cluster 28]]
- [[_COMMUNITY_Cluster 29|Cluster 29]]
- [[_COMMUNITY_Cluster 30|Cluster 30]]
- [[_COMMUNITY_Cluster 31|Cluster 31]]
- [[_COMMUNITY_Cluster 32|Cluster 32]]
@@ -141,30 +141,36 @@
- [[_COMMUNITY_Cluster 128|Cluster 128]]
- [[_COMMUNITY_Cluster 129|Cluster 129]]
- [[_COMMUNITY_Cluster 130|Cluster 130]]
- [[_COMMUNITY_Cluster 131|Cluster 131]]
- [[_COMMUNITY_Cluster 132|Cluster 132]]
- [[_COMMUNITY_Cluster 133|Cluster 133]]
- [[_COMMUNITY_Cluster 134|Cluster 134]]
- [[_COMMUNITY_Cluster 135|Cluster 135]]
- [[_COMMUNITY_Cluster 136|Cluster 136]]
## God Nodes (most connected - your core abstractions)
1. `load()` - 79 edges
2. `GET()` - 20 edges
3. `LocalDiskStorage` - 10 edges
4. `load()` - 9 edges
5. `S3Storage` - 8 edges
6. `buildfor_life_ops` - 8 edges
7. `fanOutExternal()` - 7 edges
8. `getTaskWithSubtasks()` - 7 edges
9. `handle()` - 6 edges
10. `uploadDocument()` - 6 edges
1. `load()` - 95 edges
2. `buildfor_life_ops` - 26 edges
3. `GET()` - 23 edges
4. `Drizzle ORM + Zod` - 14 edges
5. `LocalDiskStorage` - 10 edges
6. `load()` - 9 edges
7. `S3Storage` - 8 edges
8. `buildfor_life_ops` - 8 edges
9. `Drizzle migrations (drizzle/)` - 8 edges
10. `fanOutExternal()` - 7 edges
## Surprising Connections (you probably didn't know these)
- `load()` --calls--> `renderMarkdown()` [INFERRED]
src\routes\(auth)\login\+page.server.ts → src\lib\server\markdown.ts
src\routes\(app)\properties\[id]\todos\+page.server.ts → src\lib\server\markdown.ts
- `load()` --calls--> `listTemplates()` [INFERRED]
src\routes\(auth)\login\+page.server.ts → src\lib\server\services\checklists.ts
src\routes\(app)\properties\[id]\todos\+page.server.ts → src\lib\server\services\checklists.ts
- `load()` --calls--> `getCompany()` [INFERRED]
src\routes\(auth)\login\+page.server.ts → src\lib\server\services\companies.ts
src\routes\(app)\properties\[id]\todos\+page.server.ts → src\lib\server\services\companies.ts
- `load()` --calls--> `listDocumentsForScope()` [INFERRED]
src\routes\(auth)\login\+page.server.ts → src\lib\server\services\documents.ts
src\routes\(app)\properties\[id]\todos\+page.server.ts → src\lib\server\services\documents.ts
- `load()` --calls--> `countOverdueForCompany()` [INFERRED]
src\routes\(auth)\login\+page.server.ts → src\lib\server\services\maintenance.ts
src\routes\(app)\properties\[id]\todos\+page.server.ts → src\lib\server\services\maintenance.ts
## Hyperedges (group relationships)
- **Session auth flow (cookie, hash, hook, gate)** — readme_auth_model, readme_sha256_cookie_hash, readme_sliding_renewal, readme_hooks_server, readme_layout_app_group [EXTRACTED 0.95]
@@ -173,123 +179,123 @@
## Communities
### Community 0 - "Auth & Load Helpers"
### Community 0 - "Page Server Loaders"
Cohesion: 0.04
Nodes (12): requireAdmin(), requireCompany(), load(), parseSettings(), getPageWithCurrentRevision(), getRevision(), listPagesForScope(), listRevisions() (+4 more)
Nodes (15): requireAdmin(), requireCompany(), e2n(), flattenTree(), load(), parseRange(), parseSettings(), getPageWithCurrentRevision() (+7 more)
### Community 1 - "Documents Service"
### Community 1 - "Stack & Deployment Concepts"
Cohesion: 0.04
Nodes (59): Argon2id sessions (@node-rs/argon2), asset_location_history (movable assets), XOR location: asset at project OR property (CHECK), Blob storage snapshot/rsync backup, 10M upload size cap (BODY_SIZE_LIMIT + client_max_body_size), buildfor_life_budget (sibling), buildfor_life_ops, buildfor_life_repair (sibling) (+51 more)
### Community 2 - "Documents & Storage Adapters"
Cohesion: 0.07
Nodes (13): assertScope(), deleteDocument(), getDocument(), listDocumentsForScope(), signedUrlForDocument(), uploadDocument(), getStorage(), LocalDiskStorage (+5 more)
### Community 2 - "Assets Service & CSV"
Cohesion: 0.11
Nodes (13): listAssets(), csvResponse(), toCsv(), gatherCustomFieldsFromForm(), createDecision(), decisionScopeLink(), listDecisionsForScope(), clamp() (+5 more)
### Community 3 - "Auth Sessions & Catalog"
Cohesion: 0.1
Nodes (20): assertProperty(), createAccount(), deleteAccount(), listAccounts(), addFieldDef(), createCompanyAssetType(), deleteCompanyAssetType(), loadEditableType() (+12 more)
### Community 3 - "Email & Markdown"
### Community 4 - "CSV & API Endpoints"
Cohesion: 0.11
Nodes (12): csvResponse(), toCsv(), gatherCustomFieldsFromForm(), createDecision(), decisionScopeLink(), listDecisionsForScope(), clamp(), GET() (+4 more)
### Community 5 - "Expenses Service"
Cohesion: 0.12
Nodes (13): Drizzle ORM + Zod, assertAccountInProperty(), assertProperty(), createExpense(), importExpenses(), listExpensesForProperties(), listExpensesForProperty(), monthlySeriesForProperties() (+5 more)
### Community 6 - "Email & Markdown Rendering"
Cohesion: 0.13
Nodes (15): getTransport(), isEmailConfigured(), sendEmail(), escapeHtml(), html(), renderMarkdown(), buildBodies(), isMatrixConfigured() (+7 more)
### Community 4 - "Property Accounts"
Cohesion: 0.15
Nodes (12): assertProperty(), createAccount(), deleteAccount(), listAccounts(), handle(), deleteFloor(), handleLogout(), createSession() (+4 more)
### Community 5 - "Projects Service"
### Community 7 - "Projects Service"
Cohesion: 0.11
Nodes (6): load(), unreadCountForUser(), getProject(), listProjects(), getProperty(), listProperties()
Nodes (13): load(), unreadCountForUser(), getProject(), listProjects(), assertNoCycle(), assertParentInCompany(), createProperty(), getAncestorIds() (+5 more)
### Community 6 - "App Shell & Theme"
### Community 8 - "Bootstrap Scripts"
Cohesion: 0.15
Nodes (17): main(), readArg(), slugify(), stripSurroundingQuotes(), main(), readArg(), stripSurroundingQuotes(), normalizeEmail() (+9 more)
### Community 9 - "Maintenance Schedules"
Cohesion: 0.18
Nodes (15): addInterval(), assertAsset(), countOverdueForCompany(), createSchedule(), deleteSchedule(), getSchedule(), listDueAndOverdue(), listEventsForAsset() (+7 more)
### Community 10 - "Assets Service"
Cohesion: 0.2
Nodes (11): assertContainer(), createAsset(), listAssets(), loadTypeWithFields(), moveAsset(), updateAsset(), validateCustomFields(), buildCustomFieldsSchema() (+3 more)
### Community 11 - "App Shell & Bootstrap"
Cohesion: 0.13
Nodes (16): data-sveltekit-preload-data=hover, app.html root document, %sveltekit.head% / %sveltekit.body% placeholders, Tailwind body classes with dark: variants, Dark-mode bootstrap inline script (localStorage['theme']), Argon2id sessions (@node-rs/argon2 + @oslojs/crypto), buildfor_life_budget (sibling), buildfor_life_ops (+8 more)
### Community 7 - "Asset Core"
Cohesion: 0.22
Nodes (10): assertContainer(), createAsset(), loadTypeWithFields(), moveAsset(), updateAsset(), validateCustomFields(), buildCustomFieldsSchema(), getCachedCustomFieldsSchema() (+2 more)
### Community 8 - "Maintenance Core"
Cohesion: 0.22
Nodes (13): addInterval(), assertAsset(), countOverdueForCompany(), createSchedule(), deleteSchedule(), getSchedule(), listDueAndOverdue(), listEventsForAsset() (+5 more)
### Community 9 - "Checklists"
### Community 12 - "Checklists Service"
Cohesion: 0.19
Nodes (7): addTemplateItem(), deleteTemplate(), getInstance(), getTemplate(), listTemplates(), removeTemplateItem(), setItemDone()
Nodes (7): addTemplateItem(), getInstance(), getTemplate(), listInstancesForProperties(), listTemplates(), removeTemplateItem(), setItemDone()
### Community 10 - "Admin Scripts"
Cohesion: 0.24
Nodes (9): main(), readArg(), slugify(), stripSurroundingQuotes(), main(), readArg(), stripSurroundingQuotes(), normalizeEmail() (+1 more)
### Community 13 - "Rooms & Floors Service"
Cohesion: 0.29
Nodes (9): assertProperty(), createFloor(), createRoom(), deleteFloor(), getRoom(), listFloors(), listRoomsWithCounts(), softDeleteRoom() (+1 more)
### Community 11 - "Asset Types Editor"
Cohesion: 0.31
Nodes (8): addFieldDef(), createCompanyAssetType(), deleteCompanyAssetType(), loadEditableType(), normalizeFieldKey(), removeFieldDef(), slugifyTypeSlug(), updateCompanyAssetType()
### Community 12 - "Rooms & Floors"
Cohesion: 0.33
Nodes (8): assertProperty(), createFloor(), createRoom(), getRoom(), listFloors(), listRoomsWithCounts(), softDeleteRoom(), updateRoom()
### Community 13 - "Tasks"
### Community 14 - "Tasks & Subtasks"
Cohesion: 0.36
Nodes (9): addSubtask(), assertWorkPackage(), createTask(), getTaskWithSubtasks(), listTasksForWorkPackage(), removeSubtask(), softDeleteTask(), toggleSubtask() (+1 more)
### Community 14 - "User Management"
Cohesion: 0.38
Nodes (8): assertMembership(), countAdmins(), listCompanyUsers(), removeUserFromCompany(), resetUserPassword(), setUserActive(), setUserRoleInCompany(), updateDisplayName()
### Community 15 - "DB Schema Helpers"
Cohesion: 0.29
Nodes (0):
### Community 16 - "Work Packages"
### Community 15 - "Work Packages"
Cohesion: 0.48
Nodes (6): assertProject(), createWorkPackage(), getWorkPackage(), listWorkPackagesForProject(), softDeleteWorkPackage(), updateWorkPackage()
### Community 17 - "Storage Layer"
### Community 16 - "Signed URL Storage Layer"
Cohesion: 0.38
Nodes (7): /api/files route (signature verification + streaming), HMAC-signed short-lived file URLs, LocalDiskStorage, Phase 5: QR, notifications, S3, S3Storage (future), StorageAdapter interface, Storage model (opaque storage_key)
### Community 18 - "Migration Workflow"
### Community 17 - "Drizzle Migration Conventions"
Cohesion: 0.29
Nodes (7): Use CONCURRENTLY on large-table index changes, Drizzle migrations directory, Review SQL after generate: enum/index/custom_fields, npm run db:generate, npm run db:migrate, npm run db:push (dev only), npm run db:studio (Drizzle Studio)
### Community 19 - "Session Auth"
### Community 18 - "Auth Model"
Cohesion: 0.33
Nodes (6): Auth model (sessions + hashed cookies), company_users role mapping, hooks.server.ts (session validation), (app) route group (authed shell), SHA-256 cookie hashing before DB lookup, Sliding session renewal (30d/15d)
### Community 20 - "Companies Service"
### Community 19 - "Companies Service"
Cohesion: 0.6
Nodes (4): createCompanyWithAdmin(), getCompany(), slugify(), updateCompany()
### Community 21 - "Form Utilities"
Cohesion: 0.4
Nodes (1): e2n()
### Community 22 - "Custom Fields Design"
### Community 20 - "JSONB Custom Fields Policy"
Cohesion: 0.5
Nodes (4): Immutable-key policy reference, Decision: immutable custom-field keys, Decision: JSONB custom fields + asset_field_defs, Phase 1: Properties + Assets
### Community 23 - "Field Types"
### Community 21 - "Cluster 21"
Cohesion: 1.0
Nodes (2): parseCsv(), parseCsvDict()
### Community 22 - "Cluster 22"
Cohesion: 1.0
Nodes (0):
### Community 24 - "Form Helper"
Cohesion: 1.0
Nodes (0):
### Community 25 - "Env Config"
### Community 23 - "Cluster 23"
Cohesion: 1.0
Nodes (2): .env configuration, env.ts (Zod-validated process.env)
### Community 26 - "Seed Script"
### Community 24 - "Cluster 24"
Cohesion: 1.0
Nodes (2): npm run create-user script, src/lib/server/db/schema/
### Community 27 - "Asset Location Design"
Cohesion: 1.0
Nodes (2): Decision: asset_location_history (movable assets), Decision: XOR asset location (project XOR property)
### Community 28 - "Decision Design"
### Community 25 - "Cluster 25"
Cohesion: 1.0
Nodes (2): Decision: decisions scoped to project/property/asset/work_package, Phase 3: Projects + structured decisions
### Community 29 - "Drizzle Config"
### Community 26 - "Cluster 26"
Cohesion: 1.0
Nodes (2): Decision: asset_location_history (movable assets), Decision: XOR asset location (project XOR property)
### Community 27 - "Cluster 27"
Cohesion: 1.0
Nodes (0):
### Community 28 - "Cluster 28"
Cohesion: 1.0
Nodes (0):
### Community 29 - "Cluster 29"
Cohesion: 1.0
Nodes (0):
@@ -647,146 +653,172 @@ Nodes (0):
### Community 118 - "Cluster 118"
Cohesion: 1.0
Nodes (0):
Nodes (1): npm run db:seed
### Community 119 - "Cluster 119"
Cohesion: 1.0
Nodes (0):
Nodes (1): npm run validate (check + build)
### Community 120 - "Cluster 120"
Cohesion: 1.0
Nodes (1): npm run db:seed
Nodes (1): (auth) route group (login shell)
### Community 121 - "Cluster 121"
Cohesion: 1.0
Nodes (1): npm run validate (check + build)
Nodes (1): Phase 0: scaffold (shipped)
### Community 122 - "Cluster 122"
Cohesion: 1.0
Nodes (1): (auth) route group (login shell)
Nodes (1): Phase 2: Checklists + maintenance
### Community 123 - "Cluster 123"
Cohesion: 1.0
Nodes (1): Phase 0: scaffold (shipped)
Nodes (1): Decision: UUID v7 primary keys
### Community 124 - "Cluster 124"
Cohesion: 1.0
Nodes (1): Phase 2: Checklists + maintenance
Nodes (1): Decision: timestamptz UTC everywhere
### Community 125 - "Cluster 125"
Cohesion: 1.0
Nodes (1): Decision: UUID v7 primary keys
Nodes (1): Decision: soft delete (deleted_at)
### Community 126 - "Cluster 126"
Cohesion: 1.0
Nodes (1): Decision: timestamptz UTC everywhere
Nodes (1): Decision: numeric(18,4) + char(3) currency
### Community 127 - "Cluster 127"
Cohesion: 1.0
Nodes (1): Decision: soft delete (deleted_at)
Nodes (1): Decision: company default currency in settings_json
### Community 128 - "Cluster 128"
Cohesion: 1.0
Nodes (1): Decision: numeric(18,4) + char(3) currency
Nodes (1): Decision: tabs = nested routes (not query-string)
### Community 129 - "Cluster 129"
Cohesion: 1.0
Nodes (1): Decision: company default currency in settings_json
Nodes (0):
### Community 130 - "Cluster 130"
Cohesion: 1.0
Nodes (1): Decision: tabs = nested routes (not query-string)
Nodes (0):
### Community 131 - "Cluster 131"
Cohesion: 1.0
Nodes (0):
### Community 132 - "Cluster 132"
Cohesion: 1.0
Nodes (0):
### Community 133 - "Cluster 133"
Cohesion: 1.0
Nodes (0):
### Community 134 - "Cluster 134"
Cohesion: 1.0
Nodes (0):
### Community 135 - "Cluster 135"
Cohesion: 1.0
Nodes (0):
### Community 136 - "Cluster 136"
Cohesion: 1.0
Nodes (0):
## Knowledge Gaps
- **42 isolated node(s):** `buildfor_life_budget (sibling)`, `buildfor_life_repair (sibling)`, `PostgreSQL 16+ via Drizzle ORM + Zod`, `Argon2id sessions (@node-rs/argon2 + @oslojs/crypto)`, `Sharp image thumbnails` (+37 more)
- **73 isolated node(s):** `buildfor_life_budget (sibling)`, `buildfor_life_repair (sibling)`, `PostgreSQL 16+ via Drizzle ORM + Zod`, `Argon2id sessions (@node-rs/argon2 + @oslojs/crypto)`, `Sharp image thumbnails` (+68 more)
These have ≤1 connection - possible missing edges or undocumented components.
- **Thin community `Field Types`** (2 nodes): `needsEnumValues()`, `field-types.ts`
- **Thin community `Cluster 22`** (2 nodes): `needsEnumValues()`, `field-types.ts`
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
- **Thin community `Form Helper`** (2 nodes): `emptyToNull()`, `+page.server.ts`
- **Thin community `Cluster 23`** (2 nodes): `.env configuration`, `env.ts (Zod-validated process.env)`
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
- **Thin community `Env Config`** (2 nodes): `.env configuration`, `env.ts (Zod-validated process.env)`
- **Thin community `Cluster 24`** (2 nodes): `npm run create-user script`, `src/lib/server/db/schema/`
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
- **Thin community `Seed Script`** (2 nodes): `npm run create-user script`, `src/lib/server/db/schema/`
- **Thin community `Cluster 25`** (2 nodes): `Decision: decisions scoped to project/property/asset/work_package`, `Phase 3: Projects + structured decisions`
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
- **Thin community `Asset Location Design`** (2 nodes): `Decision: asset_location_history (movable assets)`, `Decision: XOR asset location (project XOR property)`
- **Thin community `Cluster 26`** (2 nodes): `Decision: asset_location_history (movable assets)`, `Decision: XOR asset location (project XOR property)`
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
- **Thin community `Decision Design`** (2 nodes): `Decision: decisions scoped to project/property/asset/work_package`, `Phase 3: Projects + structured decisions`
- **Thin community `Cluster 27`** (1 nodes): `drizzle.config.ts`
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
- **Thin community `Drizzle Config`** (1 nodes): `drizzle.config.ts`
- **Thin community `Cluster 28`** (1 nodes): `svelte.config.js`
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
- **Thin community `Cluster 30`** (1 nodes): `svelte.config.js`
- **Thin community `Cluster 29`** (1 nodes): `vite.config.ts`
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
- **Thin community `Cluster 31`** (1 nodes): `vite.config.ts`
- **Thin community `Cluster 30`** (1 nodes): `app.d.ts`
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
- **Thin community `Cluster 32`** (1 nodes): `app.d.ts`
- **Thin community `Cluster 31`** (1 nodes): `accounts.ts`
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
- **Thin community `Cluster 33`** (1 nodes): `accounts.ts`
- **Thin community `Cluster 32`** (1 nodes): `notifications.ts`
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
- **Thin community `Cluster 34`** (1 nodes): `notifications.ts`
- **Thin community `Cluster 33`** (1 nodes): `roles.ts`
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
- **Thin community `Cluster 35`** (1 nodes): `roles.ts`
- **Thin community `Cluster 34`** (1 nodes): `CustomFieldsForm.svelte`
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
- **Thin community `Cluster 36`** (1 nodes): `CustomFieldsForm.svelte`
- **Thin community `Cluster 35`** (1 nodes): `Sidebar.svelte`
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
- **Thin community `Cluster 37`** (1 nodes): `Sidebar.svelte`
- **Thin community `Cluster 36`** (1 nodes): `TabNav.svelte`
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
- **Thin community `Cluster 38`** (1 nodes): `TabNav.svelte`
- **Thin community `Cluster 37`** (1 nodes): `ThemeToggle.svelte`
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
- **Thin community `Cluster 39`** (1 nodes): `ThemeToggle.svelte`
- **Thin community `Cluster 38`** (1 nodes): `TopBar.svelte`
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
- **Thin community `Cluster 40`** (1 nodes): `TopBar.svelte`
- **Thin community `Cluster 39`** (1 nodes): `env.ts`
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
- **Thin community `Cluster 41`** (1 nodes): `env.ts`
- **Thin community `Cluster 40`** (1 nodes): `types.ts`
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
- **Thin community `Cluster 42`** (1 nodes): `types.ts`
- **Thin community `Cluster 41`** (1 nodes): `client.ts`
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
- **Thin community `Cluster 43`** (1 nodes): `client.ts`
- **Thin community `Cluster 42`** (1 nodes): `accounts.ts`
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
- **Thin community `Cluster 44`** (1 nodes): `accounts.ts`
- **Thin community `Cluster 43`** (1 nodes): `assets.ts`
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
- **Thin community `Cluster 45`** (1 nodes): `assets.ts`
- **Thin community `Cluster 44`** (1 nodes): `checklists.ts`
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
- **Thin community `Cluster 46`** (1 nodes): `checklists.ts`
- **Thin community `Cluster 45`** (1 nodes): `decisions.ts`
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
- **Thin community `Cluster 47`** (1 nodes): `decisions.ts`
- **Thin community `Cluster 46`** (1 nodes): `documents.ts`
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
- **Thin community `Cluster 48`** (1 nodes): `documents.ts`
- **Thin community `Cluster 47`** (1 nodes): `index.ts`
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
- **Thin community `Cluster 49`** (1 nodes): `index.ts`
- **Thin community `Cluster 48`** (1 nodes): `maintenance.ts`
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
- **Thin community `Cluster 50`** (1 nodes): `maintenance.ts`
- **Thin community `Cluster 49`** (1 nodes): `notifications.ts`
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
- **Thin community `Cluster 51`** (1 nodes): `notifications.ts`
- **Thin community `Cluster 50`** (1 nodes): `projects.ts`
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
- **Thin community `Cluster 52`** (1 nodes): `projects.ts`
- **Thin community `Cluster 51`** (1 nodes): `properties.ts`
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
- **Thin community `Cluster 53`** (1 nodes): `properties.ts`
- **Thin community `Cluster 52`** (1 nodes): `rooms.ts`
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
- **Thin community `Cluster 54`** (1 nodes): `rooms.ts`
- **Thin community `Cluster 53`** (1 nodes): `tenancy.ts`
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
- **Thin community `Cluster 55`** (1 nodes): `tenancy.ts`
- **Thin community `Cluster 54`** (1 nodes): `wiki.ts`
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
- **Thin community `Cluster 56`** (1 nodes): `wiki.ts`
- **Thin community `Cluster 55`** (1 nodes): `+error.svelte`
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
- **Thin community `Cluster 57`** (1 nodes): `+error.svelte`
- **Thin community `Cluster 56`** (1 nodes): `+layout.svelte`
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
- **Thin community `Cluster 58`** (1 nodes): `+layout.svelte`
- **Thin community `Cluster 57`** (1 nodes): `+layout.svelte`
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
- **Thin community `Cluster 59`** (1 nodes): `+layout.svelte`
- **Thin community `Cluster 58`** (1 nodes): `+page.svelte`
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
- **Thin community `Cluster 60`** (1 nodes): `+page.svelte`
- **Thin community `Cluster 59`** (1 nodes): `+page.svelte`
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
- **Thin community `Cluster 60`** (1 nodes): `+page.server.ts`
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
- **Thin community `Cluster 61`** (1 nodes): `+page.svelte`
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
- **Thin community `Cluster 62`** (1 nodes): `+page.server.ts`
- **Thin community `Cluster 62`** (1 nodes): `+page.svelte`
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
- **Thin community `Cluster 63`** (1 nodes): `+page.svelte`
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
- **Thin community `Cluster 64`** (1 nodes): `+page.svelte`
- **Thin community `Cluster 64`** (1 nodes): `+page.server.ts`
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
- **Thin community `Cluster 65`** (1 nodes): `+page.svelte`
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
- **Thin community `Cluster 66`** (1 nodes): `+page.server.ts`
- **Thin community `Cluster 66`** (1 nodes): `+page.svelte`
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
- **Thin community `Cluster 67`** (1 nodes): `+page.svelte`
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
@@ -794,11 +826,11 @@ Nodes (1): Decision: tabs = nested routes (not query-string)
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
- **Thin community `Cluster 69`** (1 nodes): `+page.svelte`
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
- **Thin community `Cluster 70`** (1 nodes): `+page.svelte`
- **Thin community `Cluster 70`** (1 nodes): `+layout.svelte`
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
- **Thin community `Cluster 71`** (1 nodes): `+page.svelte`
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
- **Thin community `Cluster 72`** (1 nodes): `+layout.svelte`
- **Thin community `Cluster 72`** (1 nodes): `+page.svelte`
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
- **Thin community `Cluster 73`** (1 nodes): `+page.svelte`
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
@@ -824,11 +856,11 @@ Nodes (1): Decision: tabs = nested routes (not query-string)
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
- **Thin community `Cluster 84`** (1 nodes): `+page.svelte`
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
- **Thin community `Cluster 85`** (1 nodes): `+page.svelte`
- **Thin community `Cluster 85`** (1 nodes): `+layout.svelte`
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
- **Thin community `Cluster 86`** (1 nodes): `+page.svelte`
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
- **Thin community `Cluster 87`** (1 nodes): `+layout.svelte`
- **Thin community `Cluster 87`** (1 nodes): `+page.svelte`
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
- **Thin community `Cluster 88`** (1 nodes): `+page.svelte`
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
@@ -836,11 +868,11 @@ Nodes (1): Decision: tabs = nested routes (not query-string)
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
- **Thin community `Cluster 90`** (1 nodes): `+page.svelte`
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
- **Thin community `Cluster 91`** (1 nodes): `+page.svelte`
- **Thin community `Cluster 91`** (1 nodes): `+page.server.ts`
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
- **Thin community `Cluster 92`** (1 nodes): `+page.svelte`
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
- **Thin community `Cluster 93`** (1 nodes): `+page.server.ts`
- **Thin community `Cluster 93`** (1 nodes): `+page.svelte`
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
- **Thin community `Cluster 94`** (1 nodes): `+page.svelte`
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
@@ -858,11 +890,11 @@ Nodes (1): Decision: tabs = nested routes (not query-string)
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
- **Thin community `Cluster 101`** (1 nodes): `+page.svelte`
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
- **Thin community `Cluster 102`** (1 nodes): `+page.svelte`
- **Thin community `Cluster 102`** (1 nodes): `+layout.svelte`
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
- **Thin community `Cluster 103`** (1 nodes): `+page.svelte`
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
- **Thin community `Cluster 104`** (1 nodes): `+layout.svelte`
- **Thin community `Cluster 104`** (1 nodes): `+page.svelte`
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
- **Thin community `Cluster 105`** (1 nodes): `+page.svelte`
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
@@ -874,11 +906,11 @@ Nodes (1): Decision: tabs = nested routes (not query-string)
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
- **Thin community `Cluster 109`** (1 nodes): `+page.svelte`
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
- **Thin community `Cluster 110`** (1 nodes): `+page.svelte`
- **Thin community `Cluster 110`** (1 nodes): `+page.server.ts`
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
- **Thin community `Cluster 111`** (1 nodes): `+page.svelte`
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
- **Thin community `Cluster 112`** (1 nodes): `+page.server.ts`
- **Thin community `Cluster 112`** (1 nodes): `+page.svelte`
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
- **Thin community `Cluster 113`** (1 nodes): `+page.svelte`
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
@@ -886,51 +918,63 @@ Nodes (1): Decision: tabs = nested routes (not query-string)
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
- **Thin community `Cluster 115`** (1 nodes): `+page.svelte`
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
- **Thin community `Cluster 116`** (1 nodes): `+page.svelte`
- **Thin community `Cluster 116`** (1 nodes): `+layout.svelte`
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
- **Thin community `Cluster 117`** (1 nodes): `+page.svelte`
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
- **Thin community `Cluster 118`** (1 nodes): `+layout.svelte`
- **Thin community `Cluster 118`** (1 nodes): `npm run db:seed`
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
- **Thin community `Cluster 119`** (1 nodes): `+page.svelte`
- **Thin community `Cluster 119`** (1 nodes): `npm run validate (check + build)`
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
- **Thin community `Cluster 120`** (1 nodes): `npm run db:seed`
- **Thin community `Cluster 120`** (1 nodes): `(auth) route group (login shell)`
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
- **Thin community `Cluster 121`** (1 nodes): `npm run validate (check + build)`
- **Thin community `Cluster 121`** (1 nodes): `Phase 0: scaffold (shipped)`
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
- **Thin community `Cluster 122`** (1 nodes): `(auth) route group (login shell)`
- **Thin community `Cluster 122`** (1 nodes): `Phase 2: Checklists + maintenance`
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
- **Thin community `Cluster 123`** (1 nodes): `Phase 0: scaffold (shipped)`
- **Thin community `Cluster 123`** (1 nodes): `Decision: UUID v7 primary keys`
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
- **Thin community `Cluster 124`** (1 nodes): `Phase 2: Checklists + maintenance`
- **Thin community `Cluster 124`** (1 nodes): `Decision: timestamptz UTC everywhere`
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
- **Thin community `Cluster 125`** (1 nodes): `Decision: UUID v7 primary keys`
- **Thin community `Cluster 125`** (1 nodes): `Decision: soft delete (deleted_at)`
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
- **Thin community `Cluster 126`** (1 nodes): `Decision: timestamptz UTC everywhere`
- **Thin community `Cluster 126`** (1 nodes): `Decision: numeric(18,4) + char(3) currency`
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
- **Thin community `Cluster 127`** (1 nodes): `Decision: soft delete (deleted_at)`
- **Thin community `Cluster 127`** (1 nodes): `Decision: company default currency in settings_json`
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
- **Thin community `Cluster 128`** (1 nodes): `Decision: numeric(18,4) + char(3) currency`
- **Thin community `Cluster 128`** (1 nodes): `Decision: tabs = nested routes (not query-string)`
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
- **Thin community `Cluster 129`** (1 nodes): `Decision: company default currency in settings_json`
- **Thin community `Cluster 129`** (1 nodes): `expenses.ts`
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
- **Thin community `Cluster 130`** (1 nodes): `Decision: tabs = nested routes (not query-string)`
- **Thin community `Cluster 130`** (1 nodes): `ExpenseChart.svelte`
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
- **Thin community `Cluster 131`** (1 nodes): `expenses.ts`
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
- **Thin community `Cluster 132`** (1 nodes): `+page.svelte`
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
- **Thin community `Cluster 133`** (1 nodes): `+page.svelte`
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
- **Thin community `Cluster 134`** (1 nodes): `+page.svelte`
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
- **Thin community `Cluster 135`** (1 nodes): `+page.svelte`
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
- **Thin community `Cluster 136`** (1 nodes): `+page.svelte`
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
## Suggested Questions
_Questions this graph is uniquely positioned to answer:_
- **Why does `load()` connect `Auth & Load Helpers` to `Documents Service`, `Assets Service & CSV`, `Email & Markdown`, `Property Accounts`, `Projects Service`, `Asset Core`, `Maintenance Core`, `Checklists`, `Rooms & Floors`, `Tasks`, `User Management`, `Work Packages`, `Companies Service`, `Form Utilities`?**
_High betweenness centrality (0.298) - this node is a cross-community bridge._
- **Why does `GET()` connect `Assets Service & CSV` to `Auth & Load Helpers`, `Documents Service`, `Property Accounts`, `Asset Core`, `Maintenance Core`?**
_High betweenness centrality (0.120) - this node is a cross-community bridge._
- **Why does `listCompanyUsers()` connect `User Management` to `Auth & Load Helpers`?**
_High betweenness centrality (0.040) - this node is a cross-community bridge._
- **Are the 34 inferred relationships involving `load()` (e.g. with `countOverdueForCompany()` and `listDueAndOverdue()`) actually correct?**
_`load()` has 34 INFERRED edges - model-reasoned connections that need verification._
- **Are the 13 inferred relationships involving `GET()` (e.g. with `syncFieldDefs()` and `handle()`) actually correct?**
_`GET()` has 13 INFERRED edges - model-reasoned connections that need verification._
- **Are the 5 inferred relationships involving `load()` (e.g. with `setActiveCompany()` and `unreadCountForUser()`) actually correct?**
_`load()` has 5 INFERRED edges - model-reasoned connections that need verification._
- **Why does `load()` connect `Page Server Loaders` to `Documents & Storage Adapters`, `Auth Sessions & Catalog`, `CSV & API Endpoints`, `Expenses Service`, `Email & Markdown Rendering`, `Projects Service`, `Bootstrap Scripts`, `Maintenance Schedules`, `Assets Service`, `Checklists Service`, `Rooms & Floors Service`, `Tasks & Subtasks`, `Work Packages`, `Companies Service`?**
_High betweenness centrality (0.308) - this node is a cross-community bridge._
- **Why does `Drizzle ORM + Zod` connect `Expenses Service` to `Page Server Loaders`, `Stack & Deployment Concepts`, `Projects Service`, `Bootstrap Scripts`, `Maintenance Schedules`, `Assets Service`, `Checklists Service`?**
_High betweenness centrality (0.173) - this node is a cross-community bridge._
- **Why does `buildfor_life_ops` connect `Stack & Deployment Concepts` to `Expenses Service`?**
_High betweenness centrality (0.137) - this node is a cross-community bridge._
- **Are the 41 inferred relationships involving `load()` (e.g. with `renderMarkdown()` and `requireCompany()`) actually correct?**
_`load()` has 41 INFERRED edges - model-reasoned connections that need verification._
- **Are the 15 inferred relationships involving `GET()` (e.g. with `syncFieldDefs()` and `handle()`) actually correct?**
_`GET()` has 15 INFERRED edges - model-reasoned connections that need verification._
- **What connects `buildfor_life_budget (sibling)`, `buildfor_life_repair (sibling)`, `PostgreSQL 16+ via Drizzle ORM + Zod` to the rest of the system?**
_42 weakly-connected nodes found - possible documentation gaps or missing edges._
_73 weakly-connected nodes found - possible documentation gaps or missing edges._
- **Should `Page Server Loaders` be split into smaller, more focused modules?**
_Cohesion score 0.04 - nodes in this community are weakly interconnected._
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
{"nodes": [{"id": "src_routes_app_assets_id_page_svelte", "label": "+page.svelte", "file_type": "code", "source_file": "src\\routes\\(app)\\assets\\[id]\\+page.svelte", "source_location": "L1"}], "edges": [], "raw_calls": []}
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
{"nodes": [{"id": "src_routes_app_assets_id_maintenance_page_svelte", "label": "+page.svelte", "file_type": "code", "source_file": "src\\routes\\(app)\\assets\\[id]\\maintenance\\+page.svelte", "source_location": "L1"}], "edges": [], "raw_calls": []}
@@ -0,0 +1 @@
{"nodes": [{"id": "src_routes_app_properties_id_assets_page_server_ts", "label": "+page.server.ts", "file_type": "code", "source_file": "src\\routes\\(app)\\properties\\[id]\\assets\\+page.server.ts", "source_location": "L1"}, {"id": "page_server_load", "label": "load()", "file_type": "code", "source_file": "src\\routes\\(app)\\properties\\[id]\\assets\\+page.server.ts", "source_location": "L6"}], "edges": [{"source": "src_routes_app_properties_id_assets_page_server_ts", "target": "kit", "relation": "imports_from", "confidence": "EXTRACTED", "source_file": "src\\routes\\(app)\\properties\\[id]\\assets\\+page.server.ts", "source_location": "L1", "weight": 1.0}, {"source": "src_routes_app_properties_id_assets_page_server_ts", "target": "assets", "relation": "imports_from", "confidence": "EXTRACTED", "source_file": "src\\routes\\(app)\\properties\\[id]\\assets\\+page.server.ts", "source_location": "L2", "weight": 1.0}, {"source": "src_routes_app_properties_id_assets_page_server_ts", "target": "properties", "relation": "imports_from", "confidence": "EXTRACTED", "source_file": "src\\routes\\(app)\\properties\\[id]\\assets\\+page.server.ts", "source_location": "L3", "weight": 1.0}, {"source": "src_routes_app_properties_id_assets_page_server_ts", "target": "src_routes_app_properties_id_assets_types", "relation": "imports_from", "confidence": "EXTRACTED", "source_file": "src\\routes\\(app)\\properties\\[id]\\assets\\+page.server.ts", "source_location": "L4", "weight": 1.0}, {"source": "src_routes_app_properties_id_assets_page_server_ts", "target": "page_server_load", "relation": "contains", "confidence": "EXTRACTED", "source_file": "src\\routes\\(app)\\properties\\[id]\\assets\\+page.server.ts", "source_location": "L6", "weight": 1.0}], "raw_calls": [{"caller_nid": "page_server_load", "callee": "error", "source_file": "src\\routes\\(app)\\properties\\[id]\\assets\\+page.server.ts", "source_location": "L7"}, {"caller_nid": "page_server_load", "callee": "get", "source_file": "src\\routes\\(app)\\properties\\[id]\\assets\\+page.server.ts", "source_location": "L8"}, {"caller_nid": "page_server_load", "callee": "getDescendantIds", "source_file": "src\\routes\\(app)\\properties\\[id]\\assets\\+page.server.ts", "source_location": "L10"}, {"caller_nid": "page_server_load", "callee": "listAssets", "source_file": "src\\routes\\(app)\\properties\\[id]\\assets\\+page.server.ts", "source_location": "L12"}]}
@@ -0,0 +1 @@
{"nodes": [{"id": "src_routes_app_projects_id_decisions_page_svelte", "label": "+page.svelte", "file_type": "code", "source_file": "src\\routes\\(app)\\projects\\[id]\\decisions\\+page.svelte", "source_location": "L1"}], "edges": [], "raw_calls": []}
@@ -0,0 +1 @@
{"nodes": [{"id": "src_routes_app_properties_id_page_server_ts", "label": "+page.server.ts", "file_type": "code", "source_file": "src\\routes\\(app)\\properties\\[id]\\+page.server.ts", "source_location": "L1"}, {"id": "page_server_e2n", "label": "e2n()", "file_type": "code", "source_file": "src\\routes\\(app)\\properties\\[id]\\+page.server.ts", "source_location": "L26"}, {"id": "page_server_load", "label": "load()", "file_type": "code", "source_file": "src\\routes\\(app)\\properties\\[id]\\+page.server.ts", "source_location": "L28"}], "edges": [{"source": "src_routes_app_properties_id_page_server_ts", "target": "kit", "relation": "imports_from", "confidence": "EXTRACTED", "source_file": "src\\routes\\(app)\\properties\\[id]\\+page.server.ts", "source_location": "L1", "weight": 1.0}, {"source": "src_routes_app_properties_id_page_server_ts", "target": "drizzle_orm", "relation": "imports_from", "confidence": "EXTRACTED", "source_file": "src\\routes\\(app)\\properties\\[id]\\+page.server.ts", "source_location": "L2", "weight": 1.0}, {"source": "src_routes_app_properties_id_page_server_ts", "target": "zod", "relation": "imports_from", "confidence": "EXTRACTED", "source_file": "src\\routes\\(app)\\properties\\[id]\\+page.server.ts", "source_location": "L3", "weight": 1.0}, {"source": "src_routes_app_properties_id_page_server_ts", "target": "client", "relation": "imports_from", "confidence": "EXTRACTED", "source_file": "src\\routes\\(app)\\properties\\[id]\\+page.server.ts", "source_location": "L4", "weight": 1.0}, {"source": "src_routes_app_properties_id_page_server_ts", "target": "properties", "relation": "imports_from", "confidence": "EXTRACTED", "source_file": "src\\routes\\(app)\\properties\\[id]\\+page.server.ts", "source_location": "L5", "weight": 1.0}, {"source": "src_routes_app_properties_id_page_server_ts", "target": "properties", "relation": "imports_from", "confidence": "EXTRACTED", "source_file": "src\\routes\\(app)\\properties\\[id]\\+page.server.ts", "source_location": "L6", "weight": 1.0}, {"source": "src_routes_app_properties_id_page_server_ts", "target": "src_routes_app_properties_id_types", "relation": "imports_from", "confidence": "EXTRACTED", "source_file": "src\\routes\\(app)\\properties\\[id]\\+page.server.ts", "source_location": "L11", "weight": 1.0}, {"source": "src_routes_app_properties_id_page_server_ts", "target": "page_server_e2n", "relation": "contains", "confidence": "EXTRACTED", "source_file": "src\\routes\\(app)\\properties\\[id]\\+page.server.ts", "source_location": "L26", "weight": 1.0}, {"source": "src_routes_app_properties_id_page_server_ts", "target": "page_server_load", "relation": "contains", "confidence": "EXTRACTED", "source_file": "src\\routes\\(app)\\properties\\[id]\\+page.server.ts", "source_location": "L28", "weight": 1.0}], "raw_calls": [{"caller_nid": "page_server_load", "callee": "error", "source_file": "src\\routes\\(app)\\properties\\[id]\\+page.server.ts", "source_location": "L29"}, {"caller_nid": "page_server_load", "callee": "getDescendantIds", "source_file": "src\\routes\\(app)\\properties\\[id]\\+page.server.ts", "source_location": "L34"}, {"caller_nid": "page_server_load", "callee": "orderBy", "source_file": "src\\routes\\(app)\\properties\\[id]\\+page.server.ts", "source_location": "L35"}, {"caller_nid": "page_server_load", "callee": "where", "source_file": "src\\routes\\(app)\\properties\\[id]\\+page.server.ts", "source_location": "L35"}, {"caller_nid": "page_server_load", "callee": "from", "source_file": "src\\routes\\(app)\\properties\\[id]\\+page.server.ts", "source_location": "L35"}, {"caller_nid": "page_server_load", "callee": "select", "source_file": "src\\routes\\(app)\\properties\\[id]\\+page.server.ts", "source_location": "L35"}, {"caller_nid": "page_server_load", "callee": "and", "source_file": "src\\routes\\(app)\\properties\\[id]\\+page.server.ts", "source_location": "L39"}, {"caller_nid": "page_server_load", "callee": "eq", "source_file": "src\\routes\\(app)\\properties\\[id]\\+page.server.ts", "source_location": "L40"}, {"caller_nid": "page_server_load", "callee": "isNull", "source_file": "src\\routes\\(app)\\properties\\[id]\\+page.server.ts", "source_location": "L41"}, {"caller_nid": "page_server_load", "callee": "ne", "source_file": "src\\routes\\(app)\\properties\\[id]\\+page.server.ts", "source_location": "L42"}, {"caller_nid": "page_server_load", "callee": "notInArray", "source_file": "src\\routes\\(app)\\properties\\[id]\\+page.server.ts", "source_location": "L43"}]}
@@ -0,0 +1 @@
{"nodes": [{"id": "src_routes_app_properties_id_expenses_import_template_csv_server_ts", "label": "+server.ts", "file_type": "code", "source_file": "src\\routes\\(app)\\properties\\[id]\\expenses\\import\\template.csv\\+server.ts", "source_location": "L1"}, {"id": "server_get", "label": "GET()", "file_type": "code", "source_file": "src\\routes\\(app)\\properties\\[id]\\expenses\\import\\template.csv\\+server.ts", "source_location": "L7"}], "edges": [{"source": "src_routes_app_properties_id_expenses_import_template_csv_server_ts", "target": "guards", "relation": "imports_from", "confidence": "EXTRACTED", "source_file": "src\\routes\\(app)\\properties\\[id]\\expenses\\import\\template.csv\\+server.ts", "source_location": "L1", "weight": 1.0}, {"source": "src_routes_app_properties_id_expenses_import_template_csv_server_ts", "target": "csv", "relation": "imports_from", "confidence": "EXTRACTED", "source_file": "src\\routes\\(app)\\properties\\[id]\\expenses\\import\\template.csv\\+server.ts", "source_location": "L2", "weight": 1.0}, {"source": "src_routes_app_properties_id_expenses_import_template_csv_server_ts", "target": "src_routes_app_properties_id_expenses_import_template_csv_types", "relation": "imports_from", "confidence": "EXTRACTED", "source_file": "src\\routes\\(app)\\properties\\[id]\\expenses\\import\\template.csv\\+server.ts", "source_location": "L3", "weight": 1.0}, {"source": "src_routes_app_properties_id_expenses_import_template_csv_server_ts", "target": "server_get", "relation": "contains", "confidence": "EXTRACTED", "source_file": "src\\routes\\(app)\\properties\\[id]\\expenses\\import\\template.csv\\+server.ts", "source_location": "L7", "weight": 1.0}], "raw_calls": [{"caller_nid": "server_get", "callee": "requireCompany", "source_file": "src\\routes\\(app)\\properties\\[id]\\expenses\\import\\template.csv\\+server.ts", "source_location": "L8"}, {"caller_nid": "server_get", "callee": "slice", "source_file": "src\\routes\\(app)\\properties\\[id]\\expenses\\import\\template.csv\\+server.ts", "source_location": "L9"}, {"caller_nid": "server_get", "callee": "toISOString", "source_file": "src\\routes\\(app)\\properties\\[id]\\expenses\\import\\template.csv\\+server.ts", "source_location": "L9"}, {"caller_nid": "server_get", "callee": "toCsv", "source_file": "src\\routes\\(app)\\properties\\[id]\\expenses\\import\\template.csv\\+server.ts", "source_location": "L10"}, {"caller_nid": "server_get", "callee": "csvResponse", "source_file": "src\\routes\\(app)\\properties\\[id]\\expenses\\import\\template.csv\\+server.ts", "source_location": "L47"}]}
@@ -0,0 +1 @@
{"nodes": [{"id": "src_routes_app_assets_new_page_svelte", "label": "+page.svelte", "file_type": "code", "source_file": "src\\routes\\(app)\\assets\\new\\+page.svelte", "source_location": "L1"}], "edges": [], "raw_calls": []}
@@ -0,0 +1 @@
{"nodes": [{"id": "src_lib_server_db_schema_expenses_ts", "label": "expenses.ts", "file_type": "code", "source_file": "src\\lib\\server\\db\\schema\\expenses.ts", "source_location": "L1"}], "edges": [{"source": "src_lib_server_db_schema_expenses_ts", "target": "pg_core", "relation": "imports_from", "confidence": "EXTRACTED", "source_file": "src\\lib\\server\\db\\schema\\expenses.ts", "source_location": "L1", "weight": 1.0}, {"source": "src_lib_server_db_schema_expenses_ts", "target": "src_lib_server_db_schema_properties", "relation": "imports_from", "confidence": "EXTRACTED", "source_file": "src\\lib\\server\\db\\schema\\expenses.ts", "source_location": "L2", "weight": 1.0}, {"source": "src_lib_server_db_schema_expenses_ts", "target": "src_lib_server_db_schema_accounts", "relation": "imports_from", "confidence": "EXTRACTED", "source_file": "src\\lib\\server\\db\\schema\\expenses.ts", "source_location": "L3", "weight": 1.0}, {"source": "src_lib_server_db_schema_expenses_ts", "target": "src_lib_server_db_schema_tenancy", "relation": "imports_from", "confidence": "EXTRACTED", "source_file": "src\\lib\\server\\db\\schema\\expenses.ts", "source_location": "L4", "weight": 1.0}, {"source": "src_lib_server_db_schema_expenses_ts", "target": "src_lib_server_db_schema_shared", "relation": "imports_from", "confidence": "EXTRACTED", "source_file": "src\\lib\\server\\db\\schema\\expenses.ts", "source_location": "L5", "weight": 1.0}], "raw_calls": []}
@@ -0,0 +1 @@
{"nodes": [{"id": "src_routes_app_properties_id_todos_page_server_ts", "label": "+page.server.ts", "file_type": "code", "source_file": "src\\routes\\(app)\\properties\\[id]\\todos\\+page.server.ts", "source_location": "L1"}, {"id": "page_server_load", "label": "load()", "file_type": "code", "source_file": "src\\routes\\(app)\\properties\\[id]\\todos\\+page.server.ts", "source_location": "L6"}], "edges": [{"source": "src_routes_app_properties_id_todos_page_server_ts", "target": "kit", "relation": "imports_from", "confidence": "EXTRACTED", "source_file": "src\\routes\\(app)\\properties\\[id]\\todos\\+page.server.ts", "source_location": "L1", "weight": 1.0}, {"source": "src_routes_app_properties_id_todos_page_server_ts", "target": "checklists", "relation": "imports_from", "confidence": "EXTRACTED", "source_file": "src\\routes\\(app)\\properties\\[id]\\todos\\+page.server.ts", "source_location": "L2", "weight": 1.0}, {"source": "src_routes_app_properties_id_todos_page_server_ts", "target": "properties", "relation": "imports_from", "confidence": "EXTRACTED", "source_file": "src\\routes\\(app)\\properties\\[id]\\todos\\+page.server.ts", "source_location": "L3", "weight": 1.0}, {"source": "src_routes_app_properties_id_todos_page_server_ts", "target": "src_routes_app_properties_id_todos_types", "relation": "imports_from", "confidence": "EXTRACTED", "source_file": "src\\routes\\(app)\\properties\\[id]\\todos\\+page.server.ts", "source_location": "L4", "weight": 1.0}, {"source": "src_routes_app_properties_id_todos_page_server_ts", "target": "page_server_load", "relation": "contains", "confidence": "EXTRACTED", "source_file": "src\\routes\\(app)\\properties\\[id]\\todos\\+page.server.ts", "source_location": "L6", "weight": 1.0}], "raw_calls": [{"caller_nid": "page_server_load", "callee": "error", "source_file": "src\\routes\\(app)\\properties\\[id]\\todos\\+page.server.ts", "source_location": "L7"}, {"caller_nid": "page_server_load", "callee": "get", "source_file": "src\\routes\\(app)\\properties\\[id]\\todos\\+page.server.ts", "source_location": "L8"}, {"caller_nid": "page_server_load", "callee": "getDescendantIds", "source_file": "src\\routes\\(app)\\properties\\[id]\\todos\\+page.server.ts", "source_location": "L10"}, {"caller_nid": "page_server_load", "callee": "listInstancesForProperties", "source_file": "src\\routes\\(app)\\properties\\[id]\\todos\\+page.server.ts", "source_location": "L12"}]}
@@ -0,0 +1 @@
{"nodes": [{"id": "src_lib_server_db_schema_shared_ts", "label": "_shared.ts", "file_type": "code", "source_file": "src\\lib\\server\\db\\schema\\_shared.ts", "source_location": "L1"}, {"id": "shared_pk", "label": "pk()", "file_type": "code", "source_file": "src\\lib\\server\\db\\schema\\_shared.ts", "source_location": "L98"}, {"id": "shared_fk", "label": "fk()", "file_type": "code", "source_file": "src\\lib\\server\\db\\schema\\_shared.ts", "source_location": "L99"}, {"id": "shared_createdat", "label": "createdAt()", "file_type": "code", "source_file": "src\\lib\\server\\db\\schema\\_shared.ts", "source_location": "L100"}, {"id": "shared_updatedat", "label": "updatedAt()", "file_type": "code", "source_file": "src\\lib\\server\\db\\schema\\_shared.ts", "source_location": "L102"}, {"id": "shared_deletedat", "label": "deletedAt()", "file_type": "code", "source_file": "src\\lib\\server\\db\\schema\\_shared.ts", "source_location": "L104"}, {"id": "shared_slugcol", "label": "slugCol()", "file_type": "code", "source_file": "src\\lib\\server\\db\\schema\\_shared.ts", "source_location": "L105"}], "edges": [{"source": "src_lib_server_db_schema_shared_ts", "target": "drizzle_orm", "relation": "imports_from", "confidence": "EXTRACTED", "source_file": "src\\lib\\server\\db\\schema\\_shared.ts", "source_location": "L1", "weight": 1.0}, {"source": "src_lib_server_db_schema_shared_ts", "target": "pg_core", "relation": "imports_from", "confidence": "EXTRACTED", "source_file": "src\\lib\\server\\db\\schema\\_shared.ts", "source_location": "L2", "weight": 1.0}, {"source": "src_lib_server_db_schema_shared_ts", "target": "shared_pk", "relation": "contains", "confidence": "EXTRACTED", "source_file": "src\\lib\\server\\db\\schema\\_shared.ts", "source_location": "L98", "weight": 1.0}, {"source": "src_lib_server_db_schema_shared_ts", "target": "shared_fk", "relation": "contains", "confidence": "EXTRACTED", "source_file": "src\\lib\\server\\db\\schema\\_shared.ts", "source_location": "L99", "weight": 1.0}, {"source": "src_lib_server_db_schema_shared_ts", "target": "shared_createdat", "relation": "contains", "confidence": "EXTRACTED", "source_file": "src\\lib\\server\\db\\schema\\_shared.ts", "source_location": "L100", "weight": 1.0}, {"source": "src_lib_server_db_schema_shared_ts", "target": "shared_updatedat", "relation": "contains", "confidence": "EXTRACTED", "source_file": "src\\lib\\server\\db\\schema\\_shared.ts", "source_location": "L102", "weight": 1.0}, {"source": "src_lib_server_db_schema_shared_ts", "target": "shared_deletedat", "relation": "contains", "confidence": "EXTRACTED", "source_file": "src\\lib\\server\\db\\schema\\_shared.ts", "source_location": "L104", "weight": 1.0}, {"source": "src_lib_server_db_schema_shared_ts", "target": "shared_slugcol", "relation": "contains", "confidence": "EXTRACTED", "source_file": "src\\lib\\server\\db\\schema\\_shared.ts", "source_location": "L105", "weight": 1.0}], "raw_calls": [{"caller_nid": "shared_pk", "callee": "default", "source_file": "src\\lib\\server\\db\\schema\\_shared.ts", "source_location": "L98"}, {"caller_nid": "shared_pk", "callee": "primaryKey", "source_file": "src\\lib\\server\\db\\schema\\_shared.ts", "source_location": "L98"}, {"caller_nid": "shared_pk", "callee": "uuid", "source_file": "src\\lib\\server\\db\\schema\\_shared.ts", "source_location": "L98"}, {"caller_nid": "shared_pk", "callee": "sql", "source_file": "src\\lib\\server\\db\\schema\\_shared.ts", "source_location": "L98"}, {"caller_nid": "shared_fk", "callee": "uuid", "source_file": "src\\lib\\server\\db\\schema\\_shared.ts", "source_location": "L99"}, {"caller_nid": "shared_createdat", "callee": "defaultNow", "source_file": "src\\lib\\server\\db\\schema\\_shared.ts", "source_location": "L101"}, {"caller_nid": "shared_createdat", "callee": "notNull", "source_file": "src\\lib\\server\\db\\schema\\_shared.ts", "source_location": "L101"}, {"caller_nid": "shared_createdat", "callee": "timestamp", "source_file": "src\\lib\\server\\db\\schema\\_shared.ts", "source_location": "L101"}, {"caller_nid": "shared_updatedat", "callee": "defaultNow", "source_file": "src\\lib\\server\\db\\schema\\_shared.ts", "source_location": "L103"}, {"caller_nid": "shared_updatedat", "callee": "notNull", "source_file": "src\\lib\\server\\db\\schema\\_shared.ts", "source_location": "L103"}, {"caller_nid": "shared_updatedat", "callee": "timestamp", "source_file": "src\\lib\\server\\db\\schema\\_shared.ts", "source_location": "L103"}, {"caller_nid": "shared_deletedat", "callee": "timestamp", "source_file": "src\\lib\\server\\db\\schema\\_shared.ts", "source_location": "L104"}, {"caller_nid": "shared_slugcol", "callee": "notNull", "source_file": "src\\lib\\server\\db\\schema\\_shared.ts", "source_location": "L105"}, {"caller_nid": "shared_slugcol", "callee": "varchar", "source_file": "src\\lib\\server\\db\\schema\\_shared.ts", "source_location": "L105"}]}
@@ -0,0 +1 @@
{"nodes": [{"id": "src_routes_app_projects_id_page_svelte", "label": "+page.svelte", "file_type": "code", "source_file": "src\\routes\\(app)\\projects\\[id]\\+page.svelte", "source_location": "L1"}], "edges": [], "raw_calls": []}
@@ -0,0 +1 @@
{"nodes": [{"id": "src_routes_app_properties_id_page_svelte", "label": "+page.svelte", "file_type": "code", "source_file": "src\\routes\\(app)\\properties\\[id]\\+page.svelte", "source_location": "L1"}], "edges": [], "raw_calls": []}
@@ -0,0 +1 @@
{"nodes": [{"id": "src_lib_server_db_schema_properties_ts", "label": "properties.ts", "file_type": "code", "source_file": "src\\lib\\server\\db\\schema\\properties.ts", "source_location": "L1"}], "edges": [{"source": "src_lib_server_db_schema_properties_ts", "target": "pg_core", "relation": "imports_from", "confidence": "EXTRACTED", "source_file": "src\\lib\\server\\db\\schema\\properties.ts", "source_location": "L1", "weight": 1.0}, {"source": "src_lib_server_db_schema_properties_ts", "target": "pg_core", "relation": "imports_from", "confidence": "EXTRACTED", "source_file": "src\\lib\\server\\db\\schema\\properties.ts", "source_location": "L2", "weight": 1.0}, {"source": "src_lib_server_db_schema_properties_ts", "target": "src_lib_server_db_schema_tenancy", "relation": "imports_from", "confidence": "EXTRACTED", "source_file": "src\\lib\\server\\db\\schema\\properties.ts", "source_location": "L3", "weight": 1.0}, {"source": "src_lib_server_db_schema_properties_ts", "target": "src_lib_server_db_schema_shared", "relation": "imports_from", "confidence": "EXTRACTED", "source_file": "src\\lib\\server\\db\\schema\\properties.ts", "source_location": "L4", "weight": 1.0}], "raw_calls": []}
@@ -0,0 +1 @@
{"nodes": [{"id": "src_routes_app_properties_id_maintenance_page_server_ts", "label": "+page.server.ts", "file_type": "code", "source_file": "src\\routes\\(app)\\properties\\[id]\\maintenance\\+page.server.ts", "source_location": "L1"}, {"id": "page_server_load", "label": "load()", "file_type": "code", "source_file": "src\\routes\\(app)\\properties\\[id]\\maintenance\\+page.server.ts", "source_location": "L9"}], "edges": [{"source": "src_routes_app_properties_id_maintenance_page_server_ts", "target": "kit", "relation": "imports_from", "confidence": "EXTRACTED", "source_file": "src\\routes\\(app)\\properties\\[id]\\maintenance\\+page.server.ts", "source_location": "L1", "weight": 1.0}, {"source": "src_routes_app_properties_id_maintenance_page_server_ts", "target": "maintenance", "relation": "imports_from", "confidence": "EXTRACTED", "source_file": "src\\routes\\(app)\\properties\\[id]\\maintenance\\+page.server.ts", "source_location": "L2", "weight": 1.0}, {"source": "src_routes_app_properties_id_maintenance_page_server_ts", "target": "properties", "relation": "imports_from", "confidence": "EXTRACTED", "source_file": "src\\routes\\(app)\\properties\\[id]\\maintenance\\+page.server.ts", "source_location": "L6", "weight": 1.0}, {"source": "src_routes_app_properties_id_maintenance_page_server_ts", "target": "src_routes_app_properties_id_maintenance_types", "relation": "imports_from", "confidence": "EXTRACTED", "source_file": "src\\routes\\(app)\\properties\\[id]\\maintenance\\+page.server.ts", "source_location": "L7", "weight": 1.0}, {"source": "src_routes_app_properties_id_maintenance_page_server_ts", "target": "page_server_load", "relation": "contains", "confidence": "EXTRACTED", "source_file": "src\\routes\\(app)\\properties\\[id]\\maintenance\\+page.server.ts", "source_location": "L9", "weight": 1.0}], "raw_calls": [{"caller_nid": "page_server_load", "callee": "error", "source_file": "src\\routes\\(app)\\properties\\[id]\\maintenance\\+page.server.ts", "source_location": "L10"}, {"caller_nid": "page_server_load", "callee": "get", "source_file": "src\\routes\\(app)\\properties\\[id]\\maintenance\\+page.server.ts", "source_location": "L11"}, {"caller_nid": "page_server_load", "callee": "getDescendantIds", "source_file": "src\\routes\\(app)\\properties\\[id]\\maintenance\\+page.server.ts", "source_location": "L13"}, {"caller_nid": "page_server_load", "callee": "all", "source_file": "src\\routes\\(app)\\properties\\[id]\\maintenance\\+page.server.ts", "source_location": "L15"}, {"caller_nid": "page_server_load", "callee": "listSchedulesForProperties", "source_file": "src\\routes\\(app)\\properties\\[id]\\maintenance\\+page.server.ts", "source_location": "L16"}, {"caller_nid": "page_server_load", "callee": "listEventsForProperties", "source_file": "src\\routes\\(app)\\properties\\[id]\\maintenance\\+page.server.ts", "source_location": "L17"}]}
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
{"nodes": [{"id": "src_routes_app_properties_id_assets_page_svelte", "label": "+page.svelte", "file_type": "code", "source_file": "src\\routes\\(app)\\properties\\[id]\\assets\\+page.svelte", "source_location": "L1"}], "edges": [], "raw_calls": []}
@@ -0,0 +1 @@
{"nodes": [{"id": "src_routes_app_properties_new_page_server_ts", "label": "+page.server.ts", "file_type": "code", "source_file": "src\\routes\\(app)\\properties\\new\\+page.server.ts", "source_location": "L1"}, {"id": "page_server_emptytonull", "label": "emptyToNull()", "file_type": "code", "source_file": "src\\routes\\(app)\\properties\\new\\+page.server.ts", "source_location": "L22"}, {"id": "page_server_load", "label": "load()", "file_type": "code", "source_file": "src\\routes\\(app)\\properties\\new\\+page.server.ts", "source_location": "L26"}], "edges": [{"source": "src_routes_app_properties_new_page_server_ts", "target": "kit", "relation": "imports_from", "confidence": "EXTRACTED", "source_file": "src\\routes\\(app)\\properties\\new\\+page.server.ts", "source_location": "L1", "weight": 1.0}, {"source": "src_routes_app_properties_new_page_server_ts", "target": "drizzle_orm", "relation": "imports_from", "confidence": "EXTRACTED", "source_file": "src\\routes\\(app)\\properties\\new\\+page.server.ts", "source_location": "L2", "weight": 1.0}, {"source": "src_routes_app_properties_new_page_server_ts", "target": "zod", "relation": "imports_from", "confidence": "EXTRACTED", "source_file": "src\\routes\\(app)\\properties\\new\\+page.server.ts", "source_location": "L3", "weight": 1.0}, {"source": "src_routes_app_properties_new_page_server_ts", "target": "client", "relation": "imports_from", "confidence": "EXTRACTED", "source_file": "src\\routes\\(app)\\properties\\new\\+page.server.ts", "source_location": "L4", "weight": 1.0}, {"source": "src_routes_app_properties_new_page_server_ts", "target": "properties", "relation": "imports_from", "confidence": "EXTRACTED", "source_file": "src\\routes\\(app)\\properties\\new\\+page.server.ts", "source_location": "L5", "weight": 1.0}, {"source": "src_routes_app_properties_new_page_server_ts", "target": "properties", "relation": "imports_from", "confidence": "EXTRACTED", "source_file": "src\\routes\\(app)\\properties\\new\\+page.server.ts", "source_location": "L6", "weight": 1.0}, {"source": "src_routes_app_properties_new_page_server_ts", "target": "src_routes_app_properties_new_types", "relation": "imports_from", "confidence": "EXTRACTED", "source_file": "src\\routes\\(app)\\properties\\new\\+page.server.ts", "source_location": "L7", "weight": 1.0}, {"source": "src_routes_app_properties_new_page_server_ts", "target": "page_server_emptytonull", "relation": "contains", "confidence": "EXTRACTED", "source_file": "src\\routes\\(app)\\properties\\new\\+page.server.ts", "source_location": "L22", "weight": 1.0}, {"source": "src_routes_app_properties_new_page_server_ts", "target": "page_server_load", "relation": "contains", "confidence": "EXTRACTED", "source_file": "src\\routes\\(app)\\properties\\new\\+page.server.ts", "source_location": "L26", "weight": 1.0}], "raw_calls": [{"caller_nid": "page_server_load", "callee": "error", "source_file": "src\\routes\\(app)\\properties\\new\\+page.server.ts", "source_location": "L27"}, {"caller_nid": "page_server_load", "callee": "orderBy", "source_file": "src\\routes\\(app)\\properties\\new\\+page.server.ts", "source_location": "L28"}, {"caller_nid": "page_server_load", "callee": "where", "source_file": "src\\routes\\(app)\\properties\\new\\+page.server.ts", "source_location": "L28"}, {"caller_nid": "page_server_load", "callee": "from", "source_file": "src\\routes\\(app)\\properties\\new\\+page.server.ts", "source_location": "L28"}, {"caller_nid": "page_server_load", "callee": "select", "source_file": "src\\routes\\(app)\\properties\\new\\+page.server.ts", "source_location": "L28"}, {"caller_nid": "page_server_load", "callee": "and", "source_file": "src\\routes\\(app)\\properties\\new\\+page.server.ts", "source_location": "L31"}, {"caller_nid": "page_server_load", "callee": "eq", "source_file": "src\\routes\\(app)\\properties\\new\\+page.server.ts", "source_location": "L31"}, {"caller_nid": "page_server_load", "callee": "isNull", "source_file": "src\\routes\\(app)\\properties\\new\\+page.server.ts", "source_location": "L31"}, {"caller_nid": "page_server_load", "callee": "get", "source_file": "src\\routes\\(app)\\properties\\new\\+page.server.ts", "source_location": "L33"}]}
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
{"nodes": [{"id": "src_lib_expenses_ts", "label": "expenses.ts", "file_type": "code", "source_file": "src\\lib\\expenses.ts", "source_location": "L1"}], "edges": [], "raw_calls": []}
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
{"nodes": [{"id": "src_lib_server_csv_parse_ts", "label": "csv-parse.ts", "file_type": "code", "source_file": "src\\lib\\server\\csv-parse.ts", "source_location": "L1"}, {"id": "csv_parse_parsecsv", "label": "parseCsv()", "file_type": "code", "source_file": "src\\lib\\server\\csv-parse.ts", "source_location": "L17"}, {"id": "csv_parse_parsecsvdict", "label": "parseCsvDict()", "file_type": "code", "source_file": "src\\lib\\server\\csv-parse.ts", "source_location": "L88"}], "edges": [{"source": "src_lib_server_csv_parse_ts", "target": "csv_parse_parsecsv", "relation": "contains", "confidence": "EXTRACTED", "source_file": "src\\lib\\server\\csv-parse.ts", "source_location": "L17", "weight": 1.0}, {"source": "src_lib_server_csv_parse_ts", "target": "csv_parse_parsecsvdict", "relation": "contains", "confidence": "EXTRACTED", "source_file": "src\\lib\\server\\csv-parse.ts", "source_location": "L88", "weight": 1.0}, {"source": "csv_parse_parsecsvdict", "target": "csv_parse_parsecsv", "relation": "calls", "confidence": "EXTRACTED", "source_file": "src\\lib\\server\\csv-parse.ts", "source_location": "L89", "weight": 1.0}], "raw_calls": [{"caller_nid": "csv_parse_parsecsv", "callee": "charCodeAt", "source_file": "src\\lib\\server\\csv-parse.ts", "source_location": "L19"}, {"caller_nid": "csv_parse_parsecsv", "callee": "slice", "source_file": "src\\lib\\server\\csv-parse.ts", "source_location": "L19"}, {"caller_nid": "csv_parse_parsecsv", "callee": "push", "source_file": "src\\lib\\server\\csv-parse.ts", "source_location": "L56"}, {"caller_nid": "csv_parse_parsecsv", "callee": "push", "source_file": "src\\lib\\server\\csv-parse.ts", "source_location": "L62"}, {"caller_nid": "csv_parse_parsecsv", "callee": "push", "source_file": "src\\lib\\server\\csv-parse.ts", "source_location": "L68"}, {"caller_nid": "csv_parse_parsecsv", "callee": "push", "source_file": "src\\lib\\server\\csv-parse.ts", "source_location": "L77"}, {"caller_nid": "csv_parse_parsecsv", "callee": "push", "source_file": "src\\lib\\server\\csv-parse.ts", "source_location": "L78"}, {"caller_nid": "csv_parse_parsecsvdict", "callee": "map", "source_file": "src\\lib\\server\\csv-parse.ts", "source_location": "L91"}, {"caller_nid": "csv_parse_parsecsvdict", "callee": "push", "source_file": "src\\lib\\server\\csv-parse.ts", "source_location": "L102"}]}
@@ -0,0 +1 @@
{"nodes": [{"id": "src_routes_app_properties_id_expenses_page_svelte", "label": "+page.svelte", "file_type": "code", "source_file": "src\\routes\\(app)\\properties\\[id]\\expenses\\+page.svelte", "source_location": "L1"}], "edges": [], "raw_calls": []}
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
{"nodes": [{"id": "src_routes_app_properties_id_sub_properties_page_server_ts", "label": "+page.server.ts", "file_type": "code", "source_file": "src\\routes\\(app)\\properties\\[id]\\sub-properties\\+page.server.ts", "source_location": "L1"}, {"id": "page_server_load", "label": "load()", "file_type": "code", "source_file": "src\\routes\\(app)\\properties\\[id]\\sub-properties\\+page.server.ts", "source_location": "L7"}], "edges": [{"source": "src_routes_app_properties_id_sub_properties_page_server_ts", "target": "kit", "relation": "imports_from", "confidence": "EXTRACTED", "source_file": "src\\routes\\(app)\\properties\\[id]\\sub-properties\\+page.server.ts", "source_location": "L1", "weight": 1.0}, {"source": "src_routes_app_properties_id_sub_properties_page_server_ts", "target": "drizzle_orm", "relation": "imports_from", "confidence": "EXTRACTED", "source_file": "src\\routes\\(app)\\properties\\[id]\\sub-properties\\+page.server.ts", "source_location": "L2", "weight": 1.0}, {"source": "src_routes_app_properties_id_sub_properties_page_server_ts", "target": "client", "relation": "imports_from", "confidence": "EXTRACTED", "source_file": "src\\routes\\(app)\\properties\\[id]\\sub-properties\\+page.server.ts", "source_location": "L3", "weight": 1.0}, {"source": "src_routes_app_properties_id_sub_properties_page_server_ts", "target": "properties", "relation": "imports_from", "confidence": "EXTRACTED", "source_file": "src\\routes\\(app)\\properties\\[id]\\sub-properties\\+page.server.ts", "source_location": "L4", "weight": 1.0}, {"source": "src_routes_app_properties_id_sub_properties_page_server_ts", "target": "src_routes_app_properties_id_sub_properties_types", "relation": "imports_from", "confidence": "EXTRACTED", "source_file": "src\\routes\\(app)\\properties\\[id]\\sub-properties\\+page.server.ts", "source_location": "L5", "weight": 1.0}, {"source": "src_routes_app_properties_id_sub_properties_page_server_ts", "target": "page_server_load", "relation": "contains", "confidence": "EXTRACTED", "source_file": "src\\routes\\(app)\\properties\\[id]\\sub-properties\\+page.server.ts", "source_location": "L7", "weight": 1.0}], "raw_calls": [{"caller_nid": "page_server_load", "callee": "error", "source_file": "src\\routes\\(app)\\properties\\[id]\\sub-properties\\+page.server.ts", "source_location": "L8"}, {"caller_nid": "page_server_load", "callee": "orderBy", "source_file": "src\\routes\\(app)\\properties\\[id]\\sub-properties\\+page.server.ts", "source_location": "L9"}, {"caller_nid": "page_server_load", "callee": "where", "source_file": "src\\routes\\(app)\\properties\\[id]\\sub-properties\\+page.server.ts", "source_location": "L9"}, {"caller_nid": "page_server_load", "callee": "from", "source_file": "src\\routes\\(app)\\properties\\[id]\\sub-properties\\+page.server.ts", "source_location": "L9"}, {"caller_nid": "page_server_load", "callee": "select", "source_file": "src\\routes\\(app)\\properties\\[id]\\sub-properties\\+page.server.ts", "source_location": "L9"}, {"caller_nid": "page_server_load", "callee": "and", "source_file": "src\\routes\\(app)\\properties\\[id]\\sub-properties\\+page.server.ts", "source_location": "L19"}, {"caller_nid": "page_server_load", "callee": "eq", "source_file": "src\\routes\\(app)\\properties\\[id]\\sub-properties\\+page.server.ts", "source_location": "L20"}, {"caller_nid": "page_server_load", "callee": "eq", "source_file": "src\\routes\\(app)\\properties\\[id]\\sub-properties\\+page.server.ts", "source_location": "L21"}, {"caller_nid": "page_server_load", "callee": "isNull", "source_file": "src\\routes\\(app)\\properties\\[id]\\sub-properties\\+page.server.ts", "source_location": "L22"}, {"caller_nid": "page_server_load", "callee": "asc", "source_file": "src\\routes\\(app)\\properties\\[id]\\sub-properties\\+page.server.ts", "source_location": "L25"}]}
@@ -0,0 +1 @@
{"nodes": [{"id": "src_lib_server_db_schema_index_ts", "label": "index.ts", "file_type": "code", "source_file": "src\\lib\\server\\db\\schema\\index.ts", "source_location": "L1"}], "edges": [], "raw_calls": []}
@@ -0,0 +1 @@
{"nodes": [{"id": "src_routes_app_projects_id_work_wpid_taskid_page_svelte", "label": "+page.svelte", "file_type": "code", "source_file": "src\\routes\\(app)\\projects\\[id]\\work\\[wpId]\\[taskId]\\+page.svelte", "source_location": "L1"}], "edges": [], "raw_calls": []}
@@ -0,0 +1 @@
{"nodes": [{"id": "src_routes_app_projects_new_page_svelte", "label": "+page.svelte", "file_type": "code", "source_file": "src\\routes\\(app)\\projects\\new\\+page.svelte", "source_location": "L1"}], "edges": [], "raw_calls": []}
@@ -0,0 +1 @@
{"nodes": [{"id": "src_routes_app_properties_id_maintenance_page_svelte", "label": "+page.svelte", "file_type": "code", "source_file": "src\\routes\\(app)\\properties\\[id]\\maintenance\\+page.svelte", "source_location": "L1"}], "edges": [], "raw_calls": []}
@@ -0,0 +1 @@
{"nodes": [{"id": "src_routes_app_properties_page_svelte", "label": "+page.svelte", "file_type": "code", "source_file": "src\\routes\\(app)\\properties\\+page.svelte", "source_location": "L1"}], "edges": [], "raw_calls": []}
@@ -0,0 +1 @@
{"nodes": [{"id": "src_routes_app_properties_new_page_svelte", "label": "+page.svelte", "file_type": "code", "source_file": "src\\routes\\(app)\\properties\\new\\+page.svelte", "source_location": "L1"}], "edges": [], "raw_calls": []}
@@ -0,0 +1 @@
{"nodes": [{"id": "src_lib_components_expensechart_svelte", "label": "ExpenseChart.svelte", "file_type": "code", "source_file": "src\\lib\\components\\ExpenseChart.svelte", "source_location": "L1"}], "edges": [], "raw_calls": []}
@@ -0,0 +1 @@
{"nodes": [{"id": "src_routes_app_properties_id_expenses_import_page_svelte", "label": "+page.svelte", "file_type": "code", "source_file": "src\\routes\\(app)\\properties\\[id]\\expenses\\import\\+page.svelte", "source_location": "L1"}], "edges": [], "raw_calls": []}
@@ -0,0 +1 @@
{"nodes": [{"id": "src_routes_app_properties_id_layout_svelte", "label": "+layout.svelte", "file_type": "code", "source_file": "src\\routes\\(app)\\properties\\[id]\\+layout.svelte", "source_location": "L1"}], "edges": [], "raw_calls": []}
@@ -0,0 +1 @@
{"nodes": [{"id": "src_routes_app_properties_page_server_ts", "label": "+page.server.ts", "file_type": "code", "source_file": "src\\routes\\(app)\\properties\\+page.server.ts", "source_location": "L1"}, {"id": "page_server_flattentree", "label": "flattenTree()", "file_type": "code", "source_file": "src\\routes\\(app)\\properties\\+page.server.ts", "source_location": "L15"}, {"id": "page_server_load", "label": "load()", "file_type": "code", "source_file": "src\\routes\\(app)\\properties\\+page.server.ts", "source_location": "L49"}], "edges": [{"source": "src_routes_app_properties_page_server_ts", "target": "kit", "relation": "imports_from", "confidence": "EXTRACTED", "source_file": "src\\routes\\(app)\\properties\\+page.server.ts", "source_location": "L1", "weight": 1.0}, {"source": "src_routes_app_properties_page_server_ts", "target": "properties", "relation": "imports_from", "confidence": "EXTRACTED", "source_file": "src\\routes\\(app)\\properties\\+page.server.ts", "source_location": "L2", "weight": 1.0}, {"source": "src_routes_app_properties_page_server_ts", "target": "properties", "relation": "imports_from", "confidence": "EXTRACTED", "source_file": "src\\routes\\(app)\\properties\\+page.server.ts", "source_location": "L3", "weight": 1.0}, {"source": "src_routes_app_properties_page_server_ts", "target": "src_routes_app_properties_types", "relation": "imports_from", "confidence": "EXTRACTED", "source_file": "src\\routes\\(app)\\properties\\+page.server.ts", "source_location": "L4", "weight": 1.0}, {"source": "src_routes_app_properties_page_server_ts", "target": "page_server_flattentree", "relation": "contains", "confidence": "EXTRACTED", "source_file": "src\\routes\\(app)\\properties\\+page.server.ts", "source_location": "L15", "weight": 1.0}, {"source": "src_routes_app_properties_page_server_ts", "target": "page_server_load", "relation": "contains", "confidence": "EXTRACTED", "source_file": "src\\routes\\(app)\\properties\\+page.server.ts", "source_location": "L49", "weight": 1.0}, {"source": "page_server_load", "target": "page_server_flattentree", "relation": "calls", "confidence": "EXTRACTED", "source_file": "src\\routes\\(app)\\properties\\+page.server.ts", "source_location": "L52", "weight": 1.0}], "raw_calls": [{"caller_nid": "page_server_flattentree", "callee": "get", "source_file": "src\\routes\\(app)\\properties\\+page.server.ts", "source_location": "L19"}, {"caller_nid": "page_server_flattentree", "callee": "push", "source_file": "src\\routes\\(app)\\properties\\+page.server.ts", "source_location": "L20"}, {"caller_nid": "page_server_flattentree", "callee": "set", "source_file": "src\\routes\\(app)\\properties\\+page.server.ts", "source_location": "L21"}, {"caller_nid": "page_server_flattentree", "callee": "values", "source_file": "src\\routes\\(app)\\properties\\+page.server.ts", "source_location": "L23"}, {"caller_nid": "page_server_flattentree", "callee": "sort", "source_file": "src\\routes\\(app)\\properties\\+page.server.ts", "source_location": "L24"}, {"caller_nid": "page_server_flattentree", "callee": "map", "source_file": "src\\routes\\(app)\\properties\\+page.server.ts", "source_location": "L28"}, {"caller_nid": "page_server_flattentree", "callee": "walk", "source_file": "src\\routes\\(app)\\properties\\+page.server.ts", "source_location": "L36"}, {"caller_nid": "page_server_flattentree", "callee": "map", "source_file": "src\\routes\\(app)\\properties\\+page.server.ts", "source_location": "L39"}, {"caller_nid": "page_server_flattentree", "callee": "has", "source_file": "src\\routes\\(app)\\properties\\+page.server.ts", "source_location": "L41"}, {"caller_nid": "page_server_flattentree", "callee": "has", "source_file": "src\\routes\\(app)\\properties\\+page.server.ts", "source_location": "L41"}, {"caller_nid": "page_server_flattentree", "callee": "push", "source_file": "src\\routes\\(app)\\properties\\+page.server.ts", "source_location": "L42"}, {"caller_nid": "page_server_load", "callee": "error", "source_file": "src\\routes\\(app)\\properties\\+page.server.ts", "source_location": "L50"}, {"caller_nid": "page_server_load", "callee": "listProperties", "source_file": "src\\routes\\(app)\\properties\\+page.server.ts", "source_location": "L51"}]}
@@ -0,0 +1 @@
{"nodes": [{"id": "src_routes_app_properties_id_sub_properties_page_svelte", "label": "+page.svelte", "file_type": "code", "source_file": "src\\routes\\(app)\\properties\\[id]\\sub-properties\\+page.svelte", "source_location": "L1"}], "edges": [], "raw_calls": []}
@@ -0,0 +1 @@
{"nodes": [{"id": "src_routes_app_properties_id_todos_page_svelte", "label": "+page.svelte", "file_type": "code", "source_file": "src\\routes\\(app)\\properties\\[id]\\todos\\+page.svelte", "source_location": "L1"}], "edges": [], "raw_calls": []}
@@ -0,0 +1 @@
{"nodes": [{"id": "src_routes_app_properties_id_expenses_import_page_server_ts", "label": "+page.server.ts", "file_type": "code", "source_file": "src\\routes\\(app)\\properties\\[id]\\expenses\\import\\+page.server.ts", "source_location": "L1"}, {"id": "page_server_parsesettings", "label": "parseSettings()", "file_type": "code", "source_file": "src\\routes\\(app)\\properties\\[id]\\expenses\\import\\+page.server.ts", "source_location": "L15"}, {"id": "page_server_load", "label": "load()", "file_type": "code", "source_file": "src\\routes\\(app)\\properties\\[id]\\expenses\\import\\+page.server.ts", "source_location": "L24"}], "edges": [{"source": "src_routes_app_properties_id_expenses_import_page_server_ts", "target": "kit", "relation": "imports_from", "confidence": "EXTRACTED", "source_file": "src\\routes\\(app)\\properties\\[id]\\expenses\\import\\+page.server.ts", "source_location": "L1", "weight": 1.0}, {"source": "src_routes_app_properties_id_expenses_import_page_server_ts", "target": "drizzle_orm", "relation": "imports_from", "confidence": "EXTRACTED", "source_file": "src\\routes\\(app)\\properties\\[id]\\expenses\\import\\+page.server.ts", "source_location": "L2", "weight": 1.0}, {"source": "src_routes_app_properties_id_expenses_import_page_server_ts", "target": "guards", "relation": "imports_from", "confidence": "EXTRACTED", "source_file": "src\\routes\\(app)\\properties\\[id]\\expenses\\import\\+page.server.ts", "source_location": "L3", "weight": 1.0}, {"source": "src_routes_app_properties_id_expenses_import_page_server_ts", "target": "client", "relation": "imports_from", "confidence": "EXTRACTED", "source_file": "src\\routes\\(app)\\properties\\[id]\\expenses\\import\\+page.server.ts", "source_location": "L4", "weight": 1.0}, {"source": "src_routes_app_properties_id_expenses_import_page_server_ts", "target": "tenancy", "relation": "imports_from", "confidence": "EXTRACTED", "source_file": "src\\routes\\(app)\\properties\\[id]\\expenses\\import\\+page.server.ts", "source_location": "L5", "weight": 1.0}, {"source": "src_routes_app_properties_id_expenses_import_page_server_ts", "target": "csv_parse", "relation": "imports_from", "confidence": "EXTRACTED", "source_file": "src\\routes\\(app)\\properties\\[id]\\expenses\\import\\+page.server.ts", "source_location": "L6", "weight": 1.0}, {"source": "src_routes_app_properties_id_expenses_import_page_server_ts", "target": "expenses", "relation": "imports_from", "confidence": "EXTRACTED", "source_file": "src\\routes\\(app)\\properties\\[id]\\expenses\\import\\+page.server.ts", "source_location": "L7", "weight": 1.0}, {"source": "src_routes_app_properties_id_expenses_import_page_server_ts", "target": "src_routes_app_properties_id_expenses_import_types", "relation": "imports_from", "confidence": "EXTRACTED", "source_file": "src\\routes\\(app)\\properties\\[id]\\expenses\\import\\+page.server.ts", "source_location": "L8", "weight": 1.0}, {"source": "src_routes_app_properties_id_expenses_import_page_server_ts", "target": "page_server_parsesettings", "relation": "contains", "confidence": "EXTRACTED", "source_file": "src\\routes\\(app)\\properties\\[id]\\expenses\\import\\+page.server.ts", "source_location": "L15", "weight": 1.0}, {"source": "src_routes_app_properties_id_expenses_import_page_server_ts", "target": "page_server_load", "relation": "contains", "confidence": "EXTRACTED", "source_file": "src\\routes\\(app)\\properties\\[id]\\expenses\\import\\+page.server.ts", "source_location": "L24", "weight": 1.0}, {"source": "page_server_load", "target": "page_server_parsesettings", "relation": "calls", "confidence": "EXTRACTED", "source_file": "src\\routes\\(app)\\properties\\[id]\\expenses\\import\\+page.server.ts", "source_location": "L32", "weight": 1.0}], "raw_calls": [{"caller_nid": "page_server_parsesettings", "callee": "parse", "source_file": "src\\routes\\(app)\\properties\\[id]\\expenses\\import\\+page.server.ts", "source_location": "L18"}, {"caller_nid": "page_server_load", "callee": "requireCompany", "source_file": "src\\routes\\(app)\\properties\\[id]\\expenses\\import\\+page.server.ts", "source_location": "L25"}, {"caller_nid": "page_server_load", "callee": "limit", "source_file": "src\\routes\\(app)\\properties\\[id]\\expenses\\import\\+page.server.ts", "source_location": "L26"}, {"caller_nid": "page_server_load", "callee": "where", "source_file": "src\\routes\\(app)\\properties\\[id]\\expenses\\import\\+page.server.ts", "source_location": "L26"}, {"caller_nid": "page_server_load", "callee": "from", "source_file": "src\\routes\\(app)\\properties\\[id]\\expenses\\import\\+page.server.ts", "source_location": "L26"}, {"caller_nid": "page_server_load", "callee": "select", "source_file": "src\\routes\\(app)\\properties\\[id]\\expenses\\import\\+page.server.ts", "source_location": "L26"}, {"caller_nid": "page_server_load", "callee": "eq", "source_file": "src\\routes\\(app)\\properties\\[id]\\expenses\\import\\+page.server.ts", "source_location": "L29"}]}
@@ -0,0 +1 @@
{"nodes": [{"id": "src_routes_app_projects_id_work_wpid_page_svelte", "label": "+page.svelte", "file_type": "code", "source_file": "src\\routes\\(app)\\projects\\[id]\\work\\[wpId]\\+page.svelte", "source_location": "L1"}], "edges": [], "raw_calls": []}
+6
View File
@@ -5,6 +5,12 @@
"input_tokens": 0,
"output_tokens": 0,
"files": 189
},
{
"date": "2026-04-27T08:46:41.997113+00:00",
"input_tokens": 0,
"output_tokens": 0,
"files": 47
}
],
"total_input_tokens": 0,
File diff suppressed because one or more lines are too long
+5000 -2074
View File
File diff suppressed because it is too large Load Diff
+47 -28
View File
@@ -2,20 +2,23 @@
"drizzle.config.ts": 1776759714.513955,
"svelte.config.js": 1776759709.852201,
"vite.config.ts": 1776759710.9294431,
"scripts\\create-user.ts": 1776764431.9558957,
"scripts\\create-user.ts": 1777018112.65223,
"scripts\\diag-user.ts": 1776764326.9098525,
"scripts\\seed\\system-asset-types.ts": 1776912939.322366,
"src\\app.d.ts": 1776759729.3791924,
"src\\hooks.server.ts": 1776759818.2697544,
"src\\lib\\accounts.ts": 1776920290.934606,
"src\\lib\\expenses.ts": 1776932953.1379492,
"src\\lib\\field-types.ts": 1776920825.6922204,
"src\\lib\\notifications.ts": 1776931229.9228654,
"src\\lib\\roles.ts": 1776926943.3422728,
"src\\lib\\components\\CustomFieldsForm.svelte": 1776913252.4056394,
"src\\lib\\components\\ExpenseChart.svelte": 1776933040.9983582,
"src\\lib\\components\\Sidebar.svelte": 1776927136.3547218,
"src\\lib\\components\\TabNav.svelte": 1776913159.6860654,
"src\\lib\\components\\ThemeToggle.svelte": 1776759845.5468612,
"src\\lib\\components\\TopBar.svelte": 1776931220.6103387,
"src\\lib\\server\\csv-parse.ts": 1776934140.7087996,
"src\\lib\\server\\csv.ts": 1776917156.6442757,
"src\\lib\\server\\custom-fields-form.ts": 1776913256.9887655,
"src\\lib\\server\\env.ts": 1776931062.8713133,
@@ -29,28 +32,30 @@
"src\\lib\\server\\db\\schema\\checklists.ts": 1776913879.7155764,
"src\\lib\\server\\db\\schema\\decisions.ts": 1776915253.4674976,
"src\\lib\\server\\db\\schema\\documents.ts": 1776912778.9190943,
"src\\lib\\server\\db\\schema\\index.ts": 1776930971.0629141,
"src\\lib\\server\\db\\schema\\expenses.ts": 1776932803.6511443,
"src\\lib\\server\\db\\schema\\index.ts": 1776932816.3969572,
"src\\lib\\server\\db\\schema\\maintenance.ts": 1776913892.3164668,
"src\\lib\\server\\db\\schema\\notifications.ts": 1776930892.1852467,
"src\\lib\\server\\db\\schema\\projects.ts": 1776915246.2953787,
"src\\lib\\server\\db\\schema\\properties.ts": 1776912743.139987,
"src\\lib\\server\\db\\schema\\properties.ts": 1777268843.2618184,
"src\\lib\\server\\db\\schema\\rooms.ts": 1776918599.0392416,
"src\\lib\\server\\db\\schema\\tenancy.ts": 1776930946.3660817,
"src\\lib\\server\\db\\schema\\wiki.ts": 1776916193.8209262,
"src\\lib\\server\\db\\schema\\_shared.ts": 1776930905.6758077,
"src\\lib\\server\\db\\schema\\_shared.ts": 1777268980.6436405,
"src\\lib\\server\\notifications\\email.ts": 1776931016.4405794,
"src\\lib\\server\\notifications\\matrix.ts": 1776931029.1186867,
"src\\lib\\server\\services\\accounts.ts": 1776920323.303991,
"src\\lib\\server\\services\\asset-types.ts": 1776920794.8900447,
"src\\lib\\server\\services\\assets.ts": 1776918741.5526845,
"src\\lib\\server\\services\\checklists.ts": 1776914015.1864648,
"src\\lib\\server\\services\\assets.ts": 1777269430.577926,
"src\\lib\\server\\services\\checklists.ts": 1777269092.1071846,
"src\\lib\\server\\services\\companies.ts": 1776926919.6478693,
"src\\lib\\server\\services\\decisions.ts": 1776931161.1573675,
"src\\lib\\server\\services\\documents.ts": 1776913042.152006,
"src\\lib\\server\\services\\maintenance.ts": 1776914056.7123244,
"src\\lib\\server\\services\\expenses.ts": 1777269052.5917537,
"src\\lib\\server\\services\\maintenance.ts": 1777269075.0133095,
"src\\lib\\server\\services\\notifications.ts": 1776931111.3630683,
"src\\lib\\server\\services\\projects.ts": 1776915354.1029918,
"src\\lib\\server\\services\\properties.ts": 1776913017.5585654,
"src\\lib\\server\\services\\properties.ts": 1777278959.9801009,
"src\\lib\\server\\services\\rooms.ts": 1776918691.0627687,
"src\\lib\\server\\services\\tasks.ts": 1776931148.6006575,
"src\\lib\\server\\services\\users.ts": 1776926913.1441553,
@@ -86,11 +91,11 @@
"src\\routes\\(app)\\assets\\+page.svelte": 1776917178.3600802,
"src\\routes\\(app)\\assets\\export.csv\\+server.ts": 1776917162.3927114,
"src\\routes\\(app)\\assets\\new\\+page.server.ts": 1776918891.8736053,
"src\\routes\\(app)\\assets\\new\\+page.svelte": 1776918929.98384,
"src\\routes\\(app)\\assets\\new\\+page.svelte": 1776933848.5311816,
"src\\routes\\(app)\\assets\\[id]\\+layout.server.ts": 1776918845.937803,
"src\\routes\\(app)\\assets\\[id]\\+layout.svelte": 1776918861.8613749,
"src\\routes\\(app)\\assets\\[id]\\+page.server.ts": 1776918966.9773984,
"src\\routes\\(app)\\assets\\[id]\\+page.svelte": 1776918977.7803142,
"src\\routes\\(app)\\assets\\[id]\\+page.svelte": 1776933888.6220212,
"src\\routes\\(app)\\assets\\[id]\\documents\\+page.server.ts": 1776913388.3625875,
"src\\routes\\(app)\\assets\\[id]\\documents\\+page.svelte": 1776913399.9952705,
"src\\routes\\(app)\\assets\\[id]\\history\\+page.server.ts": 1776913363.7884815,
@@ -100,7 +105,7 @@
"src\\routes\\(app)\\assets\\[id]\\logs\\+page.server.ts": 1776913374.1819277,
"src\\routes\\(app)\\assets\\[id]\\logs\\+page.svelte": 1776913381.2650573,
"src\\routes\\(app)\\assets\\[id]\\maintenance\\+page.server.ts": 1776918070.6355166,
"src\\routes\\(app)\\assets\\[id]\\maintenance\\+page.svelte": 1776914405.4364533,
"src\\routes\\(app)\\assets\\[id]\\maintenance\\+page.svelte": 1776933855.15931,
"src\\routes\\(app)\\assets\\[id]\\maintenance\\events\\[eventId]\\+page.server.ts": 1776914228.1214633,
"src\\routes\\(app)\\assets\\[id]\\maintenance\\events\\[eventId]\\+page.svelte": 1776914244.2005274,
"src\\routes\\(app)\\assets\\[id]\\move\\+page.server.ts": 1776919009.9313874,
@@ -117,15 +122,15 @@
"src\\routes\\(app)\\projects\\+page.server.ts": 1776915423.3344169,
"src\\routes\\(app)\\projects\\+page.svelte": 1776915434.5388634,
"src\\routes\\(app)\\projects\\new\\+page.server.ts": 1776918064.9852977,
"src\\routes\\(app)\\projects\\new\\+page.svelte": 1776915457.2821925,
"src\\routes\\(app)\\projects\\new\\+page.svelte": 1776933886.386777,
"src\\routes\\(app)\\projects\\[id]\\+layout.server.ts": 1776915464.0169995,
"src\\routes\\(app)\\projects\\[id]\\+layout.svelte": 1776916623.3352191,
"src\\routes\\(app)\\projects\\[id]\\+page.server.ts": 1776915476.949084,
"src\\routes\\(app)\\projects\\[id]\\+page.svelte": 1776915496.4709525,
"src\\routes\\(app)\\projects\\[id]\\+page.svelte": 1776933887.4594047,
"src\\routes\\(app)\\projects\\[id]\\assets\\+page.server.ts": 1776915627.8771396,
"src\\routes\\(app)\\projects\\[id]\\assets\\+page.svelte": 1776915636.780885,
"src\\routes\\(app)\\projects\\[id]\\decisions\\+page.server.ts": 1776915599.6791656,
"src\\routes\\(app)\\projects\\[id]\\decisions\\+page.svelte": 1776917192.7825646,
"src\\routes\\(app)\\projects\\[id]\\decisions\\+page.svelte": 1776933891.6662703,
"src\\routes\\(app)\\projects\\[id]\\decisions\\export.csv\\+server.ts": 1776917170.5648644,
"src\\routes\\(app)\\projects\\[id]\\documents\\+page.server.ts": 1776915643.2300684,
"src\\routes\\(app)\\projects\\[id]\\documents\\+page.svelte": 1776915654.9358807,
@@ -144,25 +149,36 @@
"src\\routes\\(app)\\projects\\[id]\\work\\+page.server.ts": 1776915505.82269,
"src\\routes\\(app)\\projects\\[id]\\work\\+page.svelte": 1776915517.9951062,
"src\\routes\\(app)\\projects\\[id]\\work\\[wpId]\\+page.server.ts": 1776915525.877059,
"src\\routes\\(app)\\projects\\[id]\\work\\[wpId]\\+page.svelte": 1776919721.2087197,
"src\\routes\\(app)\\projects\\[id]\\work\\[wpId]\\+page.svelte": 1776933889.7585225,
"src\\routes\\(app)\\projects\\[id]\\work\\[wpId]\\[taskId]\\+page.server.ts": 1776915557.2259672,
"src\\routes\\(app)\\projects\\[id]\\work\\[wpId]\\[taskId]\\+page.svelte": 1776919694.229038,
"src\\routes\\(app)\\properties\\+page.server.ts": 1776913103.3089087,
"src\\routes\\(app)\\properties\\+page.svelte": 1776913114.269045,
"src\\routes\\(app)\\properties\\new\\+page.server.ts": 1776913120.8220265,
"src\\routes\\(app)\\properties\\new\\+page.svelte": 1776913139.3366928,
"src\\routes\\(app)\\properties\\[id]\\+layout.server.ts": 1776913161.8158467,
"src\\routes\\(app)\\properties\\[id]\\+layout.svelte": 1776919919.0561438,
"src\\routes\\(app)\\properties\\[id]\\+page.server.ts": 1776913174.1071742,
"src\\routes\\(app)\\properties\\[id]\\+page.svelte": 1776913195.5002894,
"src\\routes\\(app)\\projects\\[id]\\work\\[wpId]\\[taskId]\\+page.svelte": 1776933890.5722253,
"src\\routes\\(app)\\properties\\+page.server.ts": 1777276929.1625655,
"src\\routes\\(app)\\properties\\+page.svelte": 1777276938.2817636,
"src\\routes\\(app)\\properties\\new\\+page.server.ts": 1777269213.3164072,
"src\\routes\\(app)\\properties\\new\\+page.svelte": 1777269235.010823,
"src\\routes\\(app)\\properties\\[id]\\+layout.server.ts": 1777269158.567569,
"src\\routes\\(app)\\properties\\[id]\\+layout.svelte": 1777269527.4078188,
"src\\routes\\(app)\\properties\\[id]\\+page.server.ts": 1777269189.052704,
"src\\routes\\(app)\\properties\\[id]\\+page.svelte": 1777269198.207656,
"src\\routes\\(app)\\properties\\[id]\\accounts\\+page.server.ts": 1776919929.527811,
"src\\routes\\(app)\\properties\\[id]\\accounts\\+page.svelte": 1776920324.8604333,
"src\\routes\\(app)\\properties\\[id]\\assets\\+page.server.ts": 1776913196.9792116,
"src\\routes\\(app)\\properties\\[id]\\assets\\+page.svelte": 1776919046.6825135,
"src\\routes\\(app)\\properties\\[id]\\assets\\+page.server.ts": 1777269436.916948,
"src\\routes\\(app)\\properties\\[id]\\assets\\+page.svelte": 1777269466.5592797,
"src\\routes\\(app)\\properties\\[id]\\documents\\+page.server.ts": 1776913212.7782526,
"src\\routes\\(app)\\properties\\[id]\\documents\\+page.svelte": 1776913224.460486,
"src\\routes\\(app)\\properties\\[id]\\expenses\\+page.server.ts": 1777269312.9716668,
"src\\routes\\(app)\\properties\\[id]\\expenses\\+page.svelte": 1777269370.880504,
"src\\routes\\(app)\\properties\\[id]\\expenses\\import\\+page.server.ts": 1776934262.3544624,
"src\\routes\\(app)\\properties\\[id]\\expenses\\import\\+page.svelte": 1776934221.4350948,
"src\\routes\\(app)\\properties\\[id]\\expenses\\import\\template.csv\\+server.ts": 1776934226.9037814,
"src\\routes\\(app)\\properties\\[id]\\maintenance\\+page.server.ts": 1777269483.5094693,
"src\\routes\\(app)\\properties\\[id]\\maintenance\\+page.svelte": 1777269504.9545746,
"src\\routes\\(app)\\properties\\[id]\\rooms\\+page.server.ts": 1776918787.5071964,
"src\\routes\\(app)\\properties\\[id]\\rooms\\+page.svelte": 1776919689.9999452,
"src\\routes\\(app)\\properties\\[id]\\sub-properties\\+page.server.ts": 1777269241.9622283,
"src\\routes\\(app)\\properties\\[id]\\sub-properties\\+page.svelte": 1777269248.938543,
"src\\routes\\(app)\\properties\\[id]\\todos\\+page.server.ts": 1777269507.4355745,
"src\\routes\\(app)\\properties\\[id]\\todos\\+page.svelte": 1777269519.2786589,
"src\\routes\\(app)\\settings\\notifications\\+page.server.ts": 1776931253.4051654,
"src\\routes\\(app)\\settings\\notifications\\+page.svelte": 1776931270.4663363,
"src\\routes\\(app)\\wiki\\+page.server.ts": 1776916429.1431959,
@@ -185,7 +201,10 @@
"src\\routes\\api\\qr\\+server.ts": 1776917032.1335907,
"src\\routes\\logout\\+server.ts": 1776760388.366003,
"src\\routes\\switch-company\\+server.ts": 1776914979.4505768,
"README.md": 1776761445.7901409,
"drizzle\\README.md": 1776759950.7471619,
"DEPLOYMENT.md": 1777262556.380254,
"README.md": 1777018156.956704,
"drizzle\\README.md": 1777018117.6299796,
"graphify-out\\graph.html": 1777279589.8841953,
"graphify-out\\GRAPH_REPORT.md": 1777279513.4607954,
"src\\app.html": 1776759722.8929892
}
+1
View File
@@ -16,6 +16,7 @@
"db:studio": "drizzle-kit studio",
"db:seed": "tsx scripts/seed/system-asset-types.ts",
"create-user": "tsx scripts/create-user.ts",
"reminders:check": "tsx scripts/maintenance-reminders.ts",
"prepare": "husky || true"
},
"dependencies": {
+71
View File
@@ -0,0 +1,71 @@
import 'dotenv/config';
import { pool } from '../src/lib/server/db/client';
import {
runRemindersOnce,
type RunResult
} from '../src/lib/server/services/maintenance-reminders';
function stripSurroundingQuotes(s: string | undefined): string | undefined {
if (!s || s.length < 2) return s;
const first = s[0];
const last = s[s.length - 1];
if ((first === "'" && last === "'") || (first === '"' && last === '"')) {
return s.slice(1, -1);
}
return s;
}
function readArg(flag: string, fallback?: string): string | undefined {
const i = process.argv.indexOf(flag);
return stripSurroundingQuotes(i >= 0 ? process.argv[i + 1] : fallback);
}
function hasFlag(flag: string): boolean {
return process.argv.includes(flag);
}
async function main(): Promise<void> {
const soonRaw = readArg('--soon-days', '7') ?? '7';
const soonDays = Number.parseInt(soonRaw, 10);
if (!Number.isFinite(soonDays) || soonDays < 0 || soonDays > 365) {
console.error('--soon-days must be an integer in [0, 365]');
process.exit(2);
}
const companyId = readArg('--company');
const dryRun = hasFlag('--dry-run');
const backfill = hasFlag('--backfill');
if (dryRun && backfill) {
console.error('--dry-run and --backfill are mutually exclusive');
process.exit(2);
}
const startedAt = new Date();
let result: RunResult;
try {
result = await runRemindersOnce({ companyId, soonDays, dryRun, backfill });
} catch (e) {
console.error(JSON.stringify({ ok: false, error: (e as Error).message }));
process.exit(1);
}
const out = {
ok: true,
startedAt: startedAt.toISOString(),
durationMs: Date.now() - startedAt.getTime(),
soonDays,
companyId: companyId ?? null,
...result
};
// Single-line JSON so journald/grep handle it cleanly.
console.log(JSON.stringify(out));
}
main()
.catch((e) => {
console.error(JSON.stringify({ ok: false, error: (e as Error).message }));
process.exitCode = 1;
})
.finally(async () => {
await pool.end().catch(() => {});
});
+9 -1
View File
@@ -33,7 +33,8 @@ export const checklistScopeEnum = pgEnum('checklist_scope', [
'task',
'subtask',
'maintenance_event',
'ad_hoc'
'ad_hoc',
'property'
]);
export const docScopeEnum = pgEnum('doc_scope', [
'project',
@@ -75,8 +76,15 @@ export const notificationKindEnum = pgEnum('notification_kind', [
'asset_moved',
'decision_created',
'maintenance_event_recorded',
'maintenance_due_soon',
'maintenance_overdue',
'generic'
]);
export const maintenanceReminderKindEnum = pgEnum('maintenance_reminder_kind', [
'due_soon',
'overdue'
]);
export const expenseKindEnum = pgEnum('expense_kind', [
'water',
'electricity',
+35 -2
View File
@@ -1,8 +1,16 @@
import { pgTable, varchar, text, integer, numeric, timestamp, boolean, index } from 'drizzle-orm/pg-core';
import { pgTable, varchar, text, integer, numeric, timestamp, boolean, index, uniqueIndex } from 'drizzle-orm/pg-core';
import { users } from './tenancy';
import { assets } from './assets';
import { checklistTemplates, checklistInstances } from './checklists';
import { scheduleKindEnum, intervalUnitEnum, pk, fk, createdAt, updatedAt } from './_shared';
import {
scheduleKindEnum,
intervalUnitEnum,
maintenanceReminderKindEnum,
pk,
fk,
createdAt,
updatedAt
} from './_shared';
export const maintenanceSchedules = pgTable(
'maintenance_schedules',
@@ -80,7 +88,32 @@ export const maintenanceEvents = pgTable(
})
);
// Dedup log for the reminder cron. We insert (schedule_id, kind, due_at)
// before firing notify(); the unique index makes the insert atomic, so
// concurrent or repeated runs cannot double-fire for the same window.
// After service is recorded, schedules.next_due_at advances → new tuple →
// reminder fires fresh. Rows are kept indefinitely for audit; a separate
// cleanup task can prune > 90 days old.
export const maintenanceRemindersSent = pgTable(
'maintenance_reminders_sent',
{
id: pk(),
scheduleId: fk('schedule_id')
.notNull()
.references(() => maintenanceSchedules.id, { onDelete: 'cascade' }),
kind: maintenanceReminderKindEnum('kind').notNull(),
dueAt: timestamp('due_at', { withTimezone: true }).notNull(),
firedAt: timestamp('fired_at', { withTimezone: true }).notNull().defaultNow()
},
(t) => ({
// The dedup key. INSERT … ON CONFLICT DO NOTHING relies on this.
dedupUq: uniqueIndex('mrs_schedule_kind_due_uq').on(t.scheduleId, t.kind, t.dueAt),
bySchedule: index('mrs_by_schedule').on(t.scheduleId, t.kind)
})
);
export type MaintenanceSchedule = typeof maintenanceSchedules.$inferSelect;
export type NewMaintenanceSchedule = typeof maintenanceSchedules.$inferInsert;
export type UsageReading = typeof usageReadings.$inferSelect;
export type MaintenanceEvent = typeof maintenanceEvents.$inferSelect;
export type MaintenanceReminderSent = typeof maintenanceRemindersSent.$inferSelect;
+10 -1
View File
@@ -1,3 +1,4 @@
import type { AnyPgColumn } from 'drizzle-orm/pg-core';
import { pgTable, varchar, text, numeric, index } from 'drizzle-orm/pg-core';
import { companies, users } from './tenancy';
import { pk, fk, createdAt, updatedAt, deletedAt } from './_shared';
@@ -9,6 +10,13 @@ export const properties = pgTable(
companyId: fk('company_id')
.notNull()
.references(() => companies.id, { onDelete: 'cascade' }),
// NULL = root. References another property in the same company. Use cases:
// a building owns its apartments, a campus owns its sub-buildings.
// Cross-company references and cycles are blocked at the service layer
// (see assertNoCycle / setParent in services/properties.ts).
parentId: fk('parent_id').references((): AnyPgColumn => properties.id, {
onDelete: 'restrict'
}),
name: varchar('name', { length: 255 }).notNull(),
kind: varchar('kind', { length: 64 }), // warehouse, office, datacenter, ...
addressLine1: varchar('address_line1', { length: 255 }),
@@ -26,7 +34,8 @@ export const properties = pgTable(
deletedAt: deletedAt()
},
(t) => ({
byCompany: index('properties_by_company').on(t.companyId)
byCompany: index('properties_by_company').on(t.companyId),
byParent: index('properties_by_parent').on(t.companyId, t.parentId)
})
);
+9 -2
View File
@@ -1,4 +1,4 @@
import { and, asc, desc, eq, isNull, or, sql } from 'drizzle-orm';
import { and, asc, desc, eq, inArray, isNull, or, sql } from 'drizzle-orm';
import { db } from '$lib/server/db/client';
import {
assets,
@@ -292,7 +292,10 @@ export async function appendAssetLog(
export interface AssetListOptions {
companyId: string;
typeSlug?: string;
/** Restrict to a single property. Mutually exclusive with `propertyIds`. */
propertyId?: string;
/** Restrict to assets at any of these properties (e.g. a property tree). */
propertyIds?: string[];
projectId?: string;
roomId?: string;
q?: string;
@@ -302,7 +305,11 @@ export interface AssetListOptions {
export async function listAssets(opts: AssetListOptions) {
const where = [eq(assets.companyId, opts.companyId), isNull(assets.deletedAt)];
if (opts.propertyId) where.push(eq(assets.currentPropertyId, opts.propertyId));
if (opts.propertyIds && opts.propertyIds.length > 0) {
where.push(inArray(assets.currentPropertyId, opts.propertyIds));
} else if (opts.propertyId) {
where.push(eq(assets.currentPropertyId, opts.propertyId));
}
if (opts.projectId) where.push(eq(assets.currentProjectId, opts.projectId));
if (opts.roomId) where.push(eq(assets.currentRoomId, opts.roomId));
if (opts.typeSlug) {
+29 -2
View File
@@ -1,4 +1,4 @@
import { and, asc, desc, eq, isNull, sql } from 'drizzle-orm';
import { and, asc, desc, eq, inArray, isNull, sql } from 'drizzle-orm';
import { db } from '$lib/server/db/client';
import {
checklistInstances,
@@ -9,7 +9,12 @@ import {
type ChecklistTemplateItem
} from '$lib/server/db/schema/checklists';
export type ChecklistScope = 'task' | 'subtask' | 'maintenance_event' | 'ad_hoc';
export type ChecklistScope =
| 'task'
| 'subtask'
| 'maintenance_event'
| 'ad_hoc'
| 'property';
// --- templates ---------------------------------------------------------------
@@ -252,3 +257,25 @@ export async function listInstancesForScope(
)
.orderBy(desc(checklistInstances.createdAt));
}
/**
* Checklist instances scoped to any of the given property ids. Used by the
* property roll-up tab when "include sub-properties" is on.
*/
export async function listInstancesForProperties(
companyId: string,
propertyIds: string[]
) {
if (propertyIds.length === 0) return [];
return db
.select()
.from(checklistInstances)
.where(
and(
eq(checklistInstances.companyId, companyId),
eq(checklistInstances.scopeType, 'property'),
inArray(checklistInstances.scopeId, propertyIds)
)
)
.orderBy(desc(checklistInstances.createdAt));
}
+49 -5
View File
@@ -165,7 +165,21 @@ export async function listExpensesForProperty(
opts: { kinds?: ExpenseKind[]; limit?: number } = {}
): Promise<PropertyExpense[]> {
await assertProperty(companyId, propertyId);
const where = [eq(propertyExpenses.propertyId, propertyId)];
return listExpensesForProperties([propertyId], opts);
}
/**
* Tree-aware list. Caller resolves descendants via getDescendantIds (in
* services/properties) and passes the full id array. No per-property auth
* check — caller is responsible for ensuring every id belongs to the active
* company. Empty array returns [].
*/
export async function listExpensesForProperties(
propertyIds: string[],
opts: { kinds?: ExpenseKind[]; limit?: number } = {}
): Promise<PropertyExpense[]> {
if (propertyIds.length === 0) return [];
const where = [inArray(propertyExpenses.propertyId, propertyIds)];
if (opts.kinds && opts.kinds.length > 0) {
where.push(inArray(propertyExpenses.kind, opts.kinds));
}
@@ -199,7 +213,18 @@ export async function monthlySeriesForProperty(
months: ChartRange = 12
): Promise<MonthlySeriesPoint[]> {
await assertProperty(companyId, propertyId);
if (kinds.length === 0) return [];
return monthlySeriesForProperties([propertyId], kinds, months);
}
/**
* Tree-aware monthly series. Caller passes the resolved id array.
*/
export async function monthlySeriesForProperties(
propertyIds: string[],
kinds: ExpenseKind[],
months: ChartRange = 12
): Promise<MonthlySeriesPoint[]> {
if (propertyIds.length === 0 || kinds.length === 0) return [];
const now = new Date();
const thisMonthStart = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), 1));
@@ -214,7 +239,7 @@ export async function monthlySeriesForProperty(
.from(propertyExpenses)
.where(
and(
eq(propertyExpenses.propertyId, propertyId),
inArray(propertyExpenses.propertyId, propertyIds),
inArray(propertyExpenses.kind, kinds)
)
);
@@ -241,7 +266,7 @@ export async function monthlySeriesForProperty(
.from(propertyExpenses)
.where(
and(
eq(propertyExpenses.propertyId, propertyId),
inArray(propertyExpenses.propertyId, propertyIds),
inArray(propertyExpenses.kind, kinds),
gte(propertyExpenses.incurredAt, start),
lt(propertyExpenses.incurredAt, new Date(Date.UTC(thisMonthStart.getUTCFullYear(), thisMonthStart.getUTCMonth() + 1, 1)))
@@ -279,6 +304,25 @@ export async function summaryForProperty(
currency: string | null;
}> {
await assertProperty(companyId, propertyId);
return summaryForProperties([propertyId], days);
}
/**
* Tree-aware summary. Caller passes resolved id array. When children carry
* different currencies, `currency` is null — the UI groups by currency in
* that case rather than auto-converting.
*/
export async function summaryForProperties(
propertyIds: string[],
days = 365
): Promise<{
byKind: Partial<Record<ExpenseKind, number>>;
grandTotal: number;
currency: string | null;
}> {
if (propertyIds.length === 0) {
return { byKind: {}, grandTotal: 0, currency: null };
}
const since = new Date(Date.now() - days * 86_400_000);
const rows = await db
.select({
@@ -289,7 +333,7 @@ export async function summaryForProperty(
.from(propertyExpenses)
.where(
and(
eq(propertyExpenses.propertyId, propertyId),
inArray(propertyExpenses.propertyId, propertyIds),
gte(propertyExpenses.incurredAt, since)
)
)
@@ -0,0 +1,226 @@
import { and, eq, inArray, isNotNull, lte } from 'drizzle-orm';
import { db } from '$lib/server/db/client';
import { assets } from '$lib/server/db/schema/assets';
import { properties } from '$lib/server/db/schema/properties';
import {
maintenanceRemindersSent,
maintenanceSchedules
} from '$lib/server/db/schema/maintenance';
import { companyUsers } from '$lib/server/db/schema/tenancy';
import { notify } from './notifications';
export type ReminderKind = 'due_soon' | 'overdue';
export interface DueSchedule {
scheduleId: string;
scheduleName: string;
nextDueAt: Date;
kind: ReminderKind;
assetId: string;
assetName: string;
companyId: string;
propertyId: string | null;
propertyName: string | null;
}
/**
* Time-based active schedules whose next_due_at is overdue or within the
* `soonDays` window. Joined to asset → property so the notification body can
* cite the location. Usage-based schedules don't have next_due_at and are
* intentionally excluded — they need a different trigger (usage-reading
* crossover) which isn't part of this iteration.
*/
export async function findDueSchedules(opts: {
companyId?: string;
soonDays: number;
now?: Date;
}): Promise<DueSchedule[]> {
const now = opts.now ?? new Date();
const horizon = new Date(now.getTime() + opts.soonDays * 86_400_000);
const where = [
eq(maintenanceSchedules.active, true),
eq(maintenanceSchedules.kind, 'time'),
isNotNull(maintenanceSchedules.nextDueAt),
lte(maintenanceSchedules.nextDueAt, horizon)
];
if (opts.companyId) where.push(eq(assets.companyId, opts.companyId));
const rows = await db
.select({
scheduleId: maintenanceSchedules.id,
scheduleName: maintenanceSchedules.name,
nextDueAt: maintenanceSchedules.nextDueAt,
assetId: maintenanceSchedules.assetId,
assetName: assets.name,
companyId: assets.companyId,
propertyId: assets.currentPropertyId,
propertyName: properties.name
})
.from(maintenanceSchedules)
.innerJoin(assets, eq(assets.id, maintenanceSchedules.assetId))
.leftJoin(properties, eq(properties.id, assets.currentPropertyId))
.where(and(...where));
return rows
.filter((r): r is typeof r & { nextDueAt: Date } => r.nextDueAt !== null)
.map((r) => ({
scheduleId: r.scheduleId,
scheduleName: r.scheduleName,
nextDueAt: r.nextDueAt,
kind: r.nextDueAt < now ? ('overdue' as const) : ('due_soon' as const),
assetId: r.assetId,
assetName: r.assetName,
companyId: r.companyId,
propertyId: r.propertyId,
propertyName: r.propertyName
}));
}
/** admin + manager user_ids for a company. */
async function recipientsFor(companyId: string): Promise<string[]> {
const rows = await db
.select({ userId: companyUsers.userId })
.from(companyUsers)
.where(
and(
eq(companyUsers.companyId, companyId),
inArray(companyUsers.role, ['admin', 'manager'])
)
);
return Array.from(new Set(rows.map((r) => r.userId)));
}
/**
* Try to record that a reminder is being fired. Returns true if this is the
* first time for (schedule, kind, due_at), false if it was already logged.
* The unique index makes this atomic across concurrent runs.
*/
async function tryRecordSent(
scheduleId: string,
kind: ReminderKind,
dueAt: Date
): Promise<boolean> {
const result = await db
.insert(maintenanceRemindersSent)
.values({ scheduleId, kind, dueAt })
.onConflictDoNothing({
target: [
maintenanceRemindersSent.scheduleId,
maintenanceRemindersSent.kind,
maintenanceRemindersSent.dueAt
]
})
.returning({ id: maintenanceRemindersSent.id });
return result.length > 0;
}
function buildBody(s: DueSchedule, now: Date): string {
const due = s.nextDueAt;
const days = Math.round((due.getTime() - now.getTime()) / 86_400_000);
const where = s.propertyName ? ` at ${s.propertyName}` : '';
if (s.kind === 'overdue') {
const overdueDays = Math.max(0, -days);
return `${s.scheduleName} on ${s.assetName}${where} is overdue by ${overdueDays} day${overdueDays === 1 ? '' : 's'} (was due ${due.toISOString().slice(0, 10)}).`;
}
return `${s.scheduleName} on ${s.assetName}${where} is due in ${days} day${days === 1 ? '' : 's'} (${due.toISOString().slice(0, 10)}).`;
}
export interface RunOpts {
companyId?: string;
soonDays: number;
now?: Date;
dryRun?: boolean;
/**
* On first deploy, mark every currently-due/overdue schedule as already
* notified so day-one isn't a deluge of stale alerts. Returns count in
* `backfilled`. Existing dedup rows are not touched (still ON CONFLICT
* DO NOTHING).
*/
backfill?: boolean;
}
export interface RunResult {
scanned: number;
fired: number;
skippedDedup: number;
noRecipients: number;
backfilled: number;
dryRun: boolean;
}
export async function runRemindersOnce(opts: RunOpts): Promise<RunResult> {
const now = opts.now ?? new Date();
const due = await findDueSchedules({
companyId: opts.companyId,
soonDays: opts.soonDays,
now
});
const result: RunResult = {
scanned: due.length,
fired: 0,
skippedDedup: 0,
noRecipients: 0,
backfilled: 0,
dryRun: !!opts.dryRun
};
// Backfill: log everything as already-sent without notifying. Useful on
// first deploy. Doesn't depend on dryRun because backfill is the entire
// effect when set.
if (opts.backfill) {
for (const s of due) {
const inserted = await tryRecordSent(s.scheduleId, s.kind, s.nextDueAt);
if (inserted) result.backfilled += 1;
else result.skippedDedup += 1;
}
return result;
}
// Cache recipients per company within this run.
const recipientsCache = new Map<string, string[]>();
for (const s of due) {
let userIds = recipientsCache.get(s.companyId);
if (!userIds) {
userIds = await recipientsFor(s.companyId);
recipientsCache.set(s.companyId, userIds);
}
if (userIds.length === 0) {
result.noRecipients += 1;
continue;
}
if (opts.dryRun) {
result.fired += 1;
continue;
}
// Atomically claim this (schedule, kind, due_at). Only proceed to notify
// if we won the insert.
const claimed = await tryRecordSent(s.scheduleId, s.kind, s.nextDueAt);
if (!claimed) {
result.skippedDedup += 1;
continue;
}
const title =
s.kind === 'overdue'
? `Maintenance overdue: ${s.scheduleName}`
: `Maintenance due soon: ${s.scheduleName}`;
const notificationKind =
s.kind === 'overdue' ? 'maintenance_overdue' : 'maintenance_due_soon';
await notify({
companyId: s.companyId,
userIds,
kind: notificationKind,
title,
body: buildBody(s, now),
link: `/assets/${s.assetId}/maintenance`
});
result.fired += 1;
}
return result;
}
+72 -1
View File
@@ -1,5 +1,5 @@
import { addDays, addHours, addMonths, addYears } from 'date-fns';
import { and, asc, desc, eq, isNotNull, lte, sql } from 'drizzle-orm';
import { and, asc, desc, eq, inArray, isNotNull, lte, sql } from 'drizzle-orm';
import { db } from '$lib/server/db/client';
import { assets } from '$lib/server/db/schema/assets';
import {
@@ -349,6 +349,77 @@ export async function listDueAndOverdue(opts: OverdueOpts) {
.limit(opts.limit ?? 50);
}
// --- property-tree readers --------------------------------------------------
/**
* Maintenance schedules for assets currently located at any of the given
* properties. Caller passes a property id array (e.g. expanded via
* getDescendantIds).
*/
export async function listSchedulesForProperties(companyId: string, propertyIds: string[]) {
if (propertyIds.length === 0) return [];
return db
.select({
id: maintenanceSchedules.id,
name: maintenanceSchedules.name,
kind: maintenanceSchedules.kind,
active: maintenanceSchedules.active,
intervalValue: maintenanceSchedules.intervalValue,
intervalUnit: maintenanceSchedules.intervalUnit,
lastServicedAt: maintenanceSchedules.lastServicedAt,
nextDueAt: maintenanceSchedules.nextDueAt,
nextDueUsage: maintenanceSchedules.nextDueUsage,
assetId: maintenanceSchedules.assetId,
assetName: assets.name,
propertyId: assets.currentPropertyId
})
.from(maintenanceSchedules)
.innerJoin(assets, eq(assets.id, maintenanceSchedules.assetId))
.where(
and(
eq(assets.companyId, companyId),
inArray(assets.currentPropertyId, propertyIds)
)
)
.orderBy(desc(maintenanceSchedules.active), asc(maintenanceSchedules.nextDueAt));
}
/**
* Maintenance events for assets at any of the given properties. Recent first.
*/
export async function listEventsForProperties(
companyId: string,
propertyIds: string[],
limit = 200
) {
if (propertyIds.length === 0) return [];
return db
.select({
id: maintenanceEvents.id,
scheduleId: maintenanceEvents.scheduleId,
scheduleName: maintenanceSchedules.name,
performedAt: maintenanceEvents.performedAt,
performedBy: maintenanceEvents.performedBy,
notes: maintenanceEvents.notes,
usageReading: maintenanceEvents.usageReading,
checklistInstanceId: maintenanceEvents.checklistInstanceId,
assetId: maintenanceEvents.assetId,
assetName: assets.name,
propertyId: assets.currentPropertyId
})
.from(maintenanceEvents)
.innerJoin(assets, eq(assets.id, maintenanceEvents.assetId))
.leftJoin(maintenanceSchedules, eq(maintenanceSchedules.id, maintenanceEvents.scheduleId))
.where(
and(
eq(assets.companyId, companyId),
inArray(assets.currentPropertyId, propertyIds)
)
)
.orderBy(desc(maintenanceEvents.performedAt))
.limit(limit);
}
export async function countOverdueForCompany(companyId: string): Promise<number> {
const [row] = await db
.select({ n: sql<number>`count(*)::int` })
+2
View File
@@ -12,6 +12,8 @@ export type NotificationKind =
| 'asset_moved'
| 'decision_created'
| 'maintenance_event_recorded'
| 'maintenance_due_soon'
| 'maintenance_overdue'
| 'generic';
export interface NotifyInput {
+138
View File
@@ -7,6 +7,7 @@ export interface PropertyCreateInput {
createdBy: string;
name: string;
kind?: string | null;
parentId?: string | null;
addressLine1?: string | null;
addressLine2?: string | null;
city?: string | null;
@@ -16,9 +17,51 @@ export interface PropertyCreateInput {
notes?: string | null;
}
// Soft cap. Properties past this depth still save, but get logged so a sudden
// spike in journal entries draws attention to a tree growing pathologically.
// Exists because getDescendantIds is an unbounded recursive CTE and deep
// hierarchies are also painful to navigate in the UI.
const MAX_RECOMMENDED_DEPTH = 5;
async function warnIfDeep(
companyId: string,
propertyId: string,
candidateParentId: string
): Promise<void> {
const parentAncestors = await getAncestorIds(companyId, candidateParentId);
const newChildDepth = parentAncestors.length + 1;
if (newChildDepth > MAX_RECOMMENDED_DEPTH) {
console.warn(
`[properties] depth cap exceeded: parenting ${propertyId} under ${candidateParentId} ` +
`places it at depth ${newChildDepth} (recommended max: ${MAX_RECOMMENDED_DEPTH}). ` +
`Allowed but flagged.`
);
}
}
async function assertParentInCompany(companyId: string, parentId: string): Promise<void> {
const [row] = await db
.select({ id: properties.id })
.from(properties)
.where(
and(
eq(properties.id, parentId),
eq(properties.companyId, companyId),
isNull(properties.deletedAt)
)
)
.limit(1);
if (!row) throw new Error('parent property not found in this company');
}
export async function createProperty(input: PropertyCreateInput): Promise<{ id: string }> {
if (input.parentId) {
await assertParentInCompany(input.companyId, input.parentId);
await warnIfDeep(input.companyId, '<new>', input.parentId);
}
const values: NewProperty = {
companyId: input.companyId,
parentId: input.parentId ?? null,
name: input.name.trim(),
kind: input.kind ?? null,
addressLine1: input.addressLine1 ?? null,
@@ -62,11 +105,18 @@ export async function updateProperty(
id: string,
patch: Partial<PropertyCreateInput>
): Promise<void> {
if (patch.parentId !== undefined && patch.parentId !== null) {
if (patch.parentId === id) throw new Error('a property cannot be its own parent');
await assertParentInCompany(companyId, patch.parentId);
await assertNoCycle(companyId, id, patch.parentId);
await warnIfDeep(companyId, id, patch.parentId);
}
await db
.update(properties)
.set({
...(patch.name !== undefined && { name: patch.name.trim() }),
...(patch.kind !== undefined && { kind: patch.kind ?? null }),
...(patch.parentId !== undefined && { parentId: patch.parentId ?? null }),
...(patch.addressLine1 !== undefined && { addressLine1: patch.addressLine1 ?? null }),
...(patch.addressLine2 !== undefined && { addressLine2: patch.addressLine2 ?? null }),
...(patch.city !== undefined && { city: patch.city ?? null }),
@@ -81,8 +131,96 @@ export async function updateProperty(
}
export async function softDeleteProperty(companyId: string, id: string): Promise<void> {
// Block delete when live children exist — orphaning to root would silently
// disconnect roll-up reports. Caller should detach children first if that's
// the intent.
const [child] = await db
.select({ id: properties.id })
.from(properties)
.where(
and(
eq(properties.parentId, id),
eq(properties.companyId, companyId),
isNull(properties.deletedAt)
)
)
.limit(1);
if (child) throw new Error('detach or delete sub-properties first');
await db
.update(properties)
.set({ deletedAt: sql`now()` })
.where(and(eq(properties.id, id), eq(properties.companyId, companyId)));
}
// --- Hierarchy helpers -------------------------------------------------------
/**
* Returns [propertyId, ...all descendants] using a recursive CTE. The result
* always includes the root id so callers can use it directly with
* `WHERE property_id = ANY($ids)` for roll-up queries.
*
* Bounded by `deleted_at IS NULL` and `company_id = $companyId` to avoid
* leaking cross-company rows even if a service caller goofs.
*/
export async function getDescendantIds(
companyId: string,
propertyId: string
): Promise<string[]> {
const rows = await db.execute<{ id: string }>(sql`
WITH RECURSIVE tree AS (
SELECT id FROM properties
WHERE id = ${propertyId}
AND company_id = ${companyId}
AND deleted_at IS NULL
UNION ALL
SELECT p.id FROM properties p
INNER JOIN tree t ON p.parent_id = t.id
WHERE p.company_id = ${companyId}
AND p.deleted_at IS NULL
)
SELECT id FROM tree
`);
return rows.rows.map((r) => r.id);
}
/**
* Walk parent_id from `propertyId` up to the root. Used by assertNoCycle
* to detect attempts at moving a property under one of its own descendants.
*/
export async function getAncestorIds(
companyId: string,
propertyId: string
): Promise<string[]> {
const rows = await db.execute<{ id: string }>(sql`
WITH RECURSIVE chain AS (
SELECT id, parent_id FROM properties
WHERE id = ${propertyId} AND company_id = ${companyId}
UNION ALL
SELECT p.id, p.parent_id FROM properties p
INNER JOIN chain c ON p.id = c.parent_id
WHERE p.company_id = ${companyId}
)
SELECT id FROM chain WHERE id <> ${propertyId}
`);
return rows.rows.map((r) => r.id);
}
/**
* Throws if assigning `candidateParentId` as the parent of `propertyId` would
* create a cycle (i.e. candidateParentId is propertyId itself or one of its
* descendants).
*/
export async function assertNoCycle(
companyId: string,
propertyId: string,
candidateParentId: string
): Promise<void> {
if (candidateParentId === propertyId) {
throw new Error('a property cannot be its own parent');
}
const descendants = await getDescendantIds(companyId, propertyId);
if (descendants.includes(candidateParentId)) {
throw new Error('cannot set parent: that property is already a descendant');
}
}
+46 -2
View File
@@ -1,9 +1,53 @@
import { error } from '@sveltejs/kit';
import { listProperties } from '$lib/server/services/properties';
import type { Property } from '$lib/server/db/schema/properties';
import type { PageServerLoad } from './$types';
export type PropertyRow = Property & { depth: number };
/**
* Reorder the flat company-scoped property list into a depth-first traversal
* so parents render immediately above their children. Within each level we
* sort by name. Orphan rows (parent_id points outside the visible set —
* shouldn't happen with the current restrict-on-delete policy, but defended
* here so the UI never silently drops a row) are appended at the end as roots.
*/
function flattenTree(rows: Property[]): PropertyRow[] {
const byParent = new Map<string | null, Property[]>();
for (const r of rows) {
const key = r.parentId;
const list = byParent.get(key);
if (list) list.push(r);
else byParent.set(key, [r]);
}
for (const list of byParent.values()) {
list.sort((a, b) => a.name.localeCompare(b.name));
}
const out: PropertyRow[] = [];
const visible = new Set(rows.map((r) => r.id));
function walk(parentId: string | null, depth: number): void {
const list = byParent.get(parentId) ?? [];
for (const r of list) {
out.push({ ...r, depth });
walk(r.id, depth + 1);
}
}
walk(null, 0);
if (out.length < rows.length) {
const seen = new Set(out.map((r) => r.id));
for (const r of rows) {
if (!seen.has(r.id) && (!r.parentId || !visible.has(r.parentId))) {
out.push({ ...r, depth: 0 });
}
}
}
return out;
}
export const load: PageServerLoad = async ({ locals }) => {
if (!locals.company) throw error(400, 'No active company. Pick one from the sidebar.');
const rows = await listProperties(locals.company.id);
return { properties: rows };
const flat = await listProperties(locals.company.id);
return { properties: flattenTree(flat) };
};
+3 -1
View File
@@ -42,7 +42,9 @@
<tbody class="divide-y divide-gray-200 dark:divide-gray-700">
{#each data.properties as p}
<tr class="hover:bg-gray-50 dark:hover:bg-gray-700/30">
<td class="px-4 py-2 text-sm font-medium text-gray-900 dark:text-gray-100">
<td class="py-2 pr-4 text-sm font-medium text-gray-900 dark:text-gray-100"
style:padding-left="{1 + p.depth * 1.5}rem">
{#if p.depth > 0}<span class="mr-1 select-none text-gray-400 dark:text-gray-500"></span>{/if}
<a href="/properties/{p.id}" class="hover:text-primary-600 dark:hover:text-primary-400">{p.name}</a>
</td>
<td class="px-4 py-2 text-sm text-gray-500 dark:text-gray-400">{p.kind ?? '—'}</td>
@@ -1,4 +1,7 @@
import { error } from '@sveltejs/kit';
import { and, eq, isNull, sql } from 'drizzle-orm';
import { db } from '$lib/server/db/client';
import { properties } from '$lib/server/db/schema/properties';
import { getProperty } from '$lib/server/services/properties';
import type { LayoutServerLoad } from './$types';
@@ -6,5 +9,40 @@ export const load: LayoutServerLoad = async ({ locals, params }) => {
if (!locals.company) throw error(400, 'No active company');
const property = await getProperty(locals.company.id, params.id);
if (!property) throw error(404, 'Property not found');
return { property };
// Fetch parent (for the breadcrumb) and the child count (for the tab badge)
// in a single round trip each — both are tiny single-row queries.
const [parent, childCountRow] = await Promise.all([
property.parentId
? db
.select({ id: properties.id, name: properties.name })
.from(properties)
.where(
and(
eq(properties.id, property.parentId),
eq(properties.companyId, locals.company.id),
isNull(properties.deletedAt)
)
)
.limit(1)
.then((r) => r[0] ?? null)
: Promise.resolve(null),
db
.select({ n: sql<number>`count(*)::int` })
.from(properties)
.where(
and(
eq(properties.parentId, property.id),
eq(properties.companyId, locals.company.id),
isNull(properties.deletedAt)
)
)
.then((r) => r[0])
]);
return {
property,
parent,
childCount: childCountRow?.n ?? 0
};
};
@@ -7,10 +7,16 @@
const tabs = $derived([
{ href: `/properties/${data.property.id}`, label: 'Overview' },
{
href: `/properties/${data.property.id}/sub-properties`,
label: data.childCount > 0 ? `Sub-properties (${data.childCount})` : 'Sub-properties'
},
{ href: `/properties/${data.property.id}/rooms`, label: 'Rooms' },
{ href: `/properties/${data.property.id}/assets`, label: 'Assets' },
{ href: `/properties/${data.property.id}/accounts`, label: 'Accounts' },
{ href: `/properties/${data.property.id}/expenses`, label: 'Expenses' },
{ href: `/properties/${data.property.id}/maintenance`, label: 'Maintenance' },
{ href: `/properties/${data.property.id}/todos`, label: 'Todos' },
{ href: `/properties/${data.property.id}/documents`, label: 'Documents' }
]);
</script>
@@ -18,9 +24,19 @@
<div class="space-y-6">
<div class="flex items-start justify-between gap-4">
<div class="min-w-0">
{#if data.parent}
<nav aria-label="Breadcrumb" class="text-xs text-gray-500 dark:text-gray-400">
<a href="/properties/{data.parent.id}" class="hover:text-gray-700 dark:hover:text-gray-200">
{data.parent.name}
</a>
<span class="mx-1.5"></span>
<span class="text-gray-700 dark:text-gray-300">{data.property.name}</span>
</nav>
{:else}
<div class="text-xs uppercase tracking-wider text-gray-400 dark:text-gray-500">
{data.property.kind ?? 'Property'}
</div>
{/if}
<h1 class="truncate text-2xl font-semibold text-gray-900 dark:text-gray-100">
{data.property.name}
</h1>
@@ -1,12 +1,19 @@
import { error, fail } from '@sveltejs/kit';
import { error, fail, redirect } from '@sveltejs/kit';
import { and, eq, isNull, ne, notInArray } from 'drizzle-orm';
import { z } from 'zod';
import { updateProperty, softDeleteProperty } from '$lib/server/services/properties';
import { redirect } from '@sveltejs/kit';
import type { Actions } from './$types';
import { db } from '$lib/server/db/client';
import { properties } from '$lib/server/db/schema/properties';
import {
getDescendantIds,
softDeleteProperty,
updateProperty
} from '$lib/server/services/properties';
import type { Actions, PageServerLoad } from './$types';
const PatchSchema = z.object({
name: z.string().trim().min(1).max(255),
kind: z.string().trim().max(64).optional().or(z.literal('')),
parentId: z.string().uuid().optional().or(z.literal('')),
addressLine1: z.string().trim().max(255).optional().or(z.literal('')),
addressLine2: z.string().trim().max(255).optional().or(z.literal('')),
city: z.string().trim().max(128).optional().or(z.literal('')),
@@ -18,6 +25,28 @@ const PatchSchema = z.object({
const e2n = (s: string | undefined) => (!s ? null : s);
export const load: PageServerLoad = async ({ locals, params }) => {
if (!locals.company) throw error(400, 'No active company');
// Eligible parents = all live properties in the company minus this one
// and its descendants. Excluding descendants is what blocks the user from
// creating a cycle through the picker; the service layer's assertNoCycle
// is the belt-and-braces guard.
const exclude = await getDescendantIds(locals.company.id, params.id);
const eligibleParents = await db
.select({ id: properties.id, name: properties.name })
.from(properties)
.where(
and(
eq(properties.companyId, locals.company.id),
isNull(properties.deletedAt),
ne(properties.id, params.id),
exclude.length > 0 ? notInArray(properties.id, exclude) : undefined
)
)
.orderBy(properties.name);
return { eligibleParents };
};
export const actions: Actions = {
save: async ({ request, locals, params }) => {
if (!locals.company) throw error(401);
@@ -28,9 +57,11 @@ export const actions: Actions = {
return fail(400, { error: parsed.error.errors[0]?.message ?? 'Invalid input' });
}
const v = parsed.data;
try {
await updateProperty(locals.company.id, params.id, {
name: v.name,
kind: e2n(v.kind),
parentId: v.parentId ? v.parentId : null,
addressLine1: e2n(v.addressLine1),
addressLine2: e2n(v.addressLine2),
city: e2n(v.city),
@@ -39,11 +70,18 @@ export const actions: Actions = {
countryCode: e2n(v.countryCode),
notes: e2n(v.notes)
});
} catch (e) {
return fail(400, { error: (e as Error).message });
}
return { ok: true };
},
delete: async ({ locals, params }) => {
if (!locals.company) throw error(401);
try {
await softDeleteProperty(locals.company.id, params.id);
} catch (e) {
return fail(400, { error: (e as Error).message });
}
throw redirect(303, '/properties');
}
};
@@ -34,6 +34,19 @@
<input id="kind" name="kind" value={p.kind ?? ''}
class="mt-1 block w-full rounded-md border border-gray-300 bg-white px-3 py-2 text-sm shadow-sm focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500 dark:border-gray-600 dark:bg-gray-900 dark:text-gray-100" />
</div>
<div class="sm:col-span-2">
<label for="parentId" class="block text-sm font-medium text-gray-700 dark:text-gray-300">Parent property</label>
<select id="parentId" name="parentId"
class="mt-1 block w-full rounded-md border border-gray-300 bg-white px-3 py-2 text-sm shadow-sm focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500 dark:border-gray-600 dark:bg-gray-900 dark:text-gray-100">
<option value="">— Top level (no parent) —</option>
{#each data.eligibleParents as opt (opt.id)}
<option value={opt.id} selected={opt.id === p.parentId}>{opt.name}</option>
{/each}
</select>
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
Sub-properties (e.g. apartments inside a building) inherit roll-up totals from their parent.
</p>
</div>
<div>
<label for="countryCode" class="block text-sm font-medium text-gray-700 dark:text-gray-300">Country (ISO 2)</label>
<input id="countryCode" name="countryCode" maxlength="2" value={p.countryCode ?? ''}
@@ -1,9 +1,14 @@
import { error } from '@sveltejs/kit';
import { listAssets } from '$lib/server/services/assets';
import { getDescendantIds } from '$lib/server/services/properties';
import type { PageServerLoad } from './$types';
export const load: PageServerLoad = async ({ locals, params }) => {
export const load: PageServerLoad = async ({ locals, params, url }) => {
if (!locals.company) throw error(401);
const rows = await listAssets({ companyId: locals.company.id, propertyId: params.id });
return { assets: rows };
const includeDescendants = url.searchParams.get('descendants') === '1';
const propertyIds = includeDescendants
? await getDescendantIds(locals.company.id, params.id)
: [params.id];
const rows = await listAssets({ companyId: locals.company.id, propertyIds });
return { assets: rows, includeDescendants, descendantCount: propertyIds.length };
};
@@ -1,6 +1,7 @@
<script lang="ts">
import type { PageData } from './$types';
let { data }: { data: PageData } = $props();
import type { LayoutData } from '../$types';
let { data }: { data: PageData & LayoutData } = $props();
const propId = $derived(data.property.id);
// Group assets by their room (including an "Unassigned" bucket).
@@ -31,8 +32,37 @@
</script>
<div class="space-y-4">
{#if data.childCount > 0}
<div class="flex flex-wrap items-center justify-between gap-3 rounded-lg border border-gray-200 bg-white px-4 py-2.5 text-sm dark:border-gray-700 dark:bg-gray-800">
<div class="text-gray-700 dark:text-gray-300">
{#if data.includeDescendants}
<span class="font-medium">{data.property.name}</span> + sub-properties
<span class="ml-1 text-xs text-gray-500 dark:text-gray-400">({data.descendantCount} total)</span>
{:else}
<span class="font-medium">{data.property.name}</span>
<span class="ml-1 text-xs text-gray-500 dark:text-gray-400">({data.childCount} sub-properties hidden)</span>
{/if}
</div>
<div role="tablist" class="inline-flex rounded-md border border-gray-200 bg-gray-50 p-0.5 text-xs font-medium dark:border-gray-700 dark:bg-gray-900">
{#each [{ v: '', l: 'This property' }, { v: '1', l: 'Include sub-properties' }] as opt}
{@const active = (data.includeDescendants ? '1' : '') === opt.v}
<a
role="tab"
href={opt.v ? '?descendants=1' : '?'}
aria-selected={active}
class="rounded px-2 py-1 transition {active
? 'bg-white text-gray-900 shadow-sm dark:bg-gray-700 dark:text-gray-100'
: 'text-gray-600 hover:text-gray-900 dark:text-gray-400 dark:hover:text-gray-100'}"
>
{opt.l}
</a>
{/each}
</div>
</div>
{/if}
<div class="flex items-center justify-between">
<p class="text-sm text-gray-500 dark:text-gray-400">{data.assets.length} asset{data.assets.length === 1 ? '' : 's'} at this property.</p>
<p class="text-sm text-gray-500 dark:text-gray-400">{data.assets.length} asset{data.assets.length === 1 ? '' : 's'}{data.includeDescendants ? ' across the property tree' : ' at this property'}.</p>
<a href="/assets/new?property={propId}" class="rounded-md bg-primary-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-primary-700">+ Add asset</a>
</div>
@@ -8,13 +8,14 @@ import { companies } from '$lib/server/db/schema/tenancy';
import {
createExpense,
deleteExpense,
listExpensesForProperty,
monthlySeriesForProperty,
summaryForProperty,
listExpensesForProperties,
monthlySeriesForProperties,
summaryForProperties,
updateExpense,
type ChartRange,
type ExpenseKind
} from '$lib/server/services/expenses';
import { getDescendantIds } from '$lib/server/services/properties';
import type { Actions, PageServerLoad } from './$types';
const KINDS = [
@@ -70,8 +71,17 @@ function parseRange(raw: string | null): ChartRange {
export const load: PageServerLoad = async ({ locals, params, url }) => {
const { company } = requireCompany(locals);
const range = parseRange(url.searchParams.get('range'));
const includeDescendants = url.searchParams.get('descendants') === '1';
// Single-property reads still go through `[params.id]`; tree reads expand via
// the recursive CTE in services/properties. The query already validates the
// company scope on every descendant.
const propertyIds = includeDescendants
? await getDescendantIds(company.id, params.id)
: [params.id];
const [expenses, accounts, series, summary, [companyRow]] = await Promise.all([
listExpensesForProperty(company.id, params.id),
listExpensesForProperties(propertyIds),
db
.select({
id: propertyAccounts.id,
@@ -82,8 +92,8 @@ export const load: PageServerLoad = async ({ locals, params, url }) => {
})
.from(propertyAccounts)
.where(eq(propertyAccounts.propertyId, params.id)),
monthlySeriesForProperty(company.id, params.id, ['electricity', 'water'], range),
summaryForProperty(company.id, params.id, 365),
monthlySeriesForProperties(propertyIds, ['electricity', 'water'], range),
summaryForProperties(propertyIds, 365),
db.select({ settings: companies.settings }).from(companies).where(eq(companies.id, company.id)).limit(1)
]);
const defaultCurrency = parseSettings(companyRow?.settings ?? null).default_currency ?? 'USD';
@@ -93,7 +103,9 @@ export const load: PageServerLoad = async ({ locals, params, url }) => {
chartSeries: series,
chartRange: range,
summary,
defaultCurrency
defaultCurrency,
includeDescendants,
descendantCount: propertyIds.length
};
};
@@ -3,8 +3,23 @@
import ExpenseChart from '$lib/components/ExpenseChart.svelte';
import { EXPENSE_KINDS, EXPENSE_KIND_LABEL, type ExpenseKind } from '$lib/expenses';
import type { PageData, ActionData } from './$types';
import type { LayoutData } from '../$types';
let { data, form }: { data: PageData; form: ActionData } = $props();
let { data, form }: { data: PageData & LayoutData; form: ActionData } = $props();
function withParams(extra: Record<string, string | undefined>): string {
const params = new URLSearchParams();
const base = {
range: String(data.chartRange),
descendants: data.includeDescendants ? '1' : ''
} as Record<string, string>;
const merged = { ...base, ...extra };
for (const [k, v] of Object.entries(merged)) {
if (v) params.set(k, v);
}
const qs = params.toString();
return qs ? `?${qs}` : '?';
}
let showForm = $state(false);
let editingId = $state<string | null>(null);
@@ -39,6 +54,35 @@
</script>
<div class="space-y-6">
{#if data.childCount > 0}
<div class="flex flex-wrap items-center justify-between gap-3 rounded-lg border border-gray-200 bg-white px-4 py-2.5 text-sm dark:border-gray-700 dark:bg-gray-800">
<div class="text-gray-700 dark:text-gray-300">
{#if data.includeDescendants}
<span class="font-medium">{data.property.name}</span> + sub-properties
<span class="ml-1 text-xs text-gray-500 dark:text-gray-400">({data.descendantCount} total)</span>
{:else}
<span class="font-medium">{data.property.name}</span>
<span class="ml-1 text-xs text-gray-500 dark:text-gray-400">({data.childCount} sub-properties hidden)</span>
{/if}
</div>
<div role="tablist" class="inline-flex rounded-md border border-gray-200 bg-gray-50 p-0.5 text-xs font-medium dark:border-gray-700 dark:bg-gray-900">
{#each [{ v: '', l: 'This property' }, { v: '1', l: 'Include sub-properties' }] as opt}
{@const active = (data.includeDescendants ? '1' : '') === opt.v}
<a
role="tab"
href={withParams({ descendants: opt.v || undefined })}
aria-selected={active}
class="rounded px-2 py-1 transition {active
? 'bg-white text-gray-900 shadow-sm dark:bg-gray-700 dark:text-gray-100'
: 'text-gray-600 hover:text-gray-900 dark:text-gray-400 dark:hover:text-gray-100'}"
>
{opt.l}
</a>
{/each}
</div>
</div>
{/if}
<!-- Chart: electricity + water -->
<section class="rounded-lg border border-gray-200 bg-white p-4 dark:border-gray-700 dark:bg-gray-800">
<div class="mb-3 flex flex-wrap items-baseline justify-between gap-3">
@@ -52,7 +96,7 @@
{@const active = String(data.chartRange) === String(opt.v)}
<a
role="tab"
href="?range={opt.v}"
href={withParams({ range: String(opt.v) })}
aria-selected={active}
class="rounded px-2 py-1 transition {active
? 'bg-white text-gray-900 shadow-sm dark:bg-gray-700 dark:text-gray-100'
@@ -0,0 +1,25 @@
import { error } from '@sveltejs/kit';
import {
listEventsForProperties,
listSchedulesForProperties
} from '$lib/server/services/maintenance';
import { getDescendantIds } from '$lib/server/services/properties';
import type { PageServerLoad } from './$types';
export const load: PageServerLoad = async ({ locals, params, url }) => {
if (!locals.company) throw error(401);
const includeDescendants = url.searchParams.get('descendants') === '1';
const propertyIds = includeDescendants
? await getDescendantIds(locals.company.id, params.id)
: [params.id];
const [schedules, events] = await Promise.all([
listSchedulesForProperties(locals.company.id, propertyIds),
listEventsForProperties(locals.company.id, propertyIds)
]);
return {
schedules,
events,
includeDescendants,
descendantCount: propertyIds.length
};
};
@@ -0,0 +1,127 @@
<script lang="ts">
import type { PageData } from './$types';
import type { LayoutData } from '../$types';
let { data }: { data: PageData & LayoutData } = $props();
function fmtDate(d: Date | string | null | undefined): string {
if (!d) return '—';
const dt = typeof d === 'string' ? new Date(d) : d;
return Number.isNaN(dt.getTime()) ? '—' : dt.toISOString().slice(0, 10);
}
const overdue = $derived(
data.schedules.filter((s) => s.active && s.nextDueAt && new Date(s.nextDueAt) < new Date())
);
const upcoming = $derived(
data.schedules.filter((s) => s.active && !overdue.includes(s))
);
</script>
<div class="space-y-4">
{#if data.childCount > 0}
<div class="flex flex-wrap items-center justify-between gap-3 rounded-lg border border-gray-200 bg-white px-4 py-2.5 text-sm dark:border-gray-700 dark:bg-gray-800">
<div class="text-gray-700 dark:text-gray-300">
{#if data.includeDescendants}
<span class="font-medium">{data.property.name}</span> + sub-properties
<span class="ml-1 text-xs text-gray-500 dark:text-gray-400">({data.descendantCount} total)</span>
{:else}
<span class="font-medium">{data.property.name}</span>
<span class="ml-1 text-xs text-gray-500 dark:text-gray-400">({data.childCount} sub-properties hidden)</span>
{/if}
</div>
<div role="tablist" class="inline-flex rounded-md border border-gray-200 bg-gray-50 p-0.5 text-xs font-medium dark:border-gray-700 dark:bg-gray-900">
{#each [{ v: '', l: 'This property' }, { v: '1', l: 'Include sub-properties' }] as opt}
{@const active = (data.includeDescendants ? '1' : '') === opt.v}
<a
role="tab"
href={opt.v ? '?descendants=1' : '?'}
aria-selected={active}
class="rounded px-2 py-1 transition {active
? 'bg-white text-gray-900 shadow-sm dark:bg-gray-700 dark:text-gray-100'
: 'text-gray-600 hover:text-gray-900 dark:text-gray-400 dark:hover:text-gray-100'}"
>
{opt.l}
</a>
{/each}
</div>
</div>
{/if}
<section>
<h2 class="mb-2 text-sm font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400">
Schedules
</h2>
{#if data.schedules.length === 0}
<div class="rounded-lg border border-dashed border-gray-300 bg-white p-6 text-center text-sm text-gray-500 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-400">
No maintenance schedules on assets here. Set them up from each asset's page.
</div>
{:else}
<div class="overflow-hidden rounded-lg border border-gray-200 bg-white dark:border-gray-700 dark:bg-gray-800">
<table class="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
<thead class="bg-gray-50 dark:bg-gray-700/40">
<tr>
<th class="px-4 py-2 text-left text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400">Schedule</th>
<th class="px-4 py-2 text-left text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400">Asset</th>
<th class="px-4 py-2 text-left text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400">Interval</th>
<th class="px-4 py-2 text-left text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400">Next due</th>
<th class="px-4 py-2 text-left text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400">Status</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-200 dark:divide-gray-700">
{#each [...overdue, ...upcoming, ...data.schedules.filter((s) => !s.active)] as s (s.id)}
{@const isOverdue = overdue.includes(s)}
<tr class="hover:bg-gray-50 dark:hover:bg-gray-700/30">
<td class="px-4 py-2 text-sm font-medium text-gray-900 dark:text-gray-100">{s.name}</td>
<td class="px-4 py-2 text-sm text-gray-500 dark:text-gray-400">
<a href="/assets/{s.assetId}" class="hover:text-primary-600 dark:hover:text-primary-400">{s.assetName}</a>
</td>
<td class="px-4 py-2 text-sm text-gray-500 dark:text-gray-400">every {s.intervalValue} {s.intervalUnit}</td>
<td class="px-4 py-2 text-sm text-gray-500 dark:text-gray-400">
{s.kind === 'time' ? fmtDate(s.nextDueAt) : `${s.nextDueUsage ?? '—'} ${s.intervalUnit}`}
</td>
<td class="px-4 py-2 text-xs">
{#if !s.active}
<span class="rounded bg-gray-100 px-1.5 py-0.5 text-gray-600 dark:bg-gray-700 dark:text-gray-300">paused</span>
{:else if isOverdue}
<span class="rounded bg-red-100 px-1.5 py-0.5 font-medium text-red-700 dark:bg-red-900/30 dark:text-red-300">overdue</span>
{:else}
<span class="rounded bg-emerald-100 px-1.5 py-0.5 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-300">active</span>
{/if}
</td>
</tr>
{/each}
</tbody>
</table>
</div>
{/if}
</section>
<section>
<h2 class="mb-2 text-sm font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400">
Recent events
</h2>
{#if data.events.length === 0}
<div class="rounded-lg border border-dashed border-gray-300 bg-white p-6 text-center text-sm text-gray-500 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-400">
No maintenance events recorded yet.
</div>
{:else}
<ul class="divide-y divide-gray-200 overflow-hidden rounded-lg border border-gray-200 bg-white dark:divide-gray-700 dark:border-gray-700 dark:bg-gray-800">
{#each data.events as e (e.id)}
<li class="flex items-start justify-between gap-3 px-4 py-3 text-sm">
<div>
<div class="font-medium text-gray-900 dark:text-gray-100">
{e.scheduleName ?? 'Ad-hoc service'} ·
<a href="/assets/{e.assetId}" class="text-primary-600 hover:underline dark:text-primary-400">{e.assetName}</a>
</div>
{#if e.notes}
<p class="mt-0.5 text-gray-500 dark:text-gray-400">{e.notes}</p>
{/if}
</div>
<div class="shrink-0 text-xs text-gray-500 dark:text-gray-400">{fmtDate(e.performedAt)}</div>
</li>
{/each}
</ul>
{/if}
</section>
</div>
@@ -0,0 +1,27 @@
import { error } from '@sveltejs/kit';
import { and, asc, eq, isNull } from 'drizzle-orm';
import { db } from '$lib/server/db/client';
import { properties } from '$lib/server/db/schema/properties';
import type { PageServerLoad } from './$types';
export const load: PageServerLoad = async ({ locals, params }) => {
if (!locals.company) throw error(400, 'No active company');
const children = await db
.select({
id: properties.id,
name: properties.name,
kind: properties.kind,
city: properties.city,
countryCode: properties.countryCode
})
.from(properties)
.where(
and(
eq(properties.companyId, locals.company.id),
eq(properties.parentId, params.id),
isNull(properties.deletedAt)
)
)
.orderBy(asc(properties.name));
return { children };
};
@@ -0,0 +1,46 @@
<script lang="ts">
import type { PageData } from './$types';
import type { LayoutData } from '../$types';
let { data }: { data: PageData & LayoutData } = $props();
</script>
<div class="space-y-4">
<div class="flex items-center justify-between">
<div>
<h2 class="text-lg font-semibold text-gray-900 dark:text-gray-100">Sub-properties</h2>
<p class="text-sm text-gray-500 dark:text-gray-400">
Properties whose parent is <strong>{data.property.name}</strong>. Use these for apartments
inside a building, sub-buildings on a campus, and so on.
</p>
</div>
<a
href="/properties/new?parent={data.property.id}"
class="rounded-md bg-primary-600 px-3 py-1.5 text-sm font-medium text-white shadow-sm hover:bg-primary-700"
>
+ New sub-property
</a>
</div>
{#if data.children.length === 0}
<div class="rounded-lg border border-dashed border-gray-300 bg-white p-8 text-center text-sm text-gray-500 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-400">
No sub-properties yet.
</div>
{:else}
<ul class="divide-y divide-gray-200 overflow-hidden rounded-lg border border-gray-200 bg-white dark:divide-gray-700 dark:border-gray-700 dark:bg-gray-800">
{#each data.children as c (c.id)}
<li>
<a href="/properties/{c.id}" class="flex items-center justify-between px-4 py-3 hover:bg-gray-50 dark:hover:bg-gray-700/40">
<div class="min-w-0">
<p class="truncate text-sm font-medium text-gray-900 dark:text-gray-100">{c.name}</p>
<p class="truncate text-xs text-gray-500 dark:text-gray-400">
{[c.kind, c.city, c.countryCode].filter(Boolean).join(' · ') || '—'}
</p>
</div>
<span class="text-xs text-gray-400 dark:text-gray-500"></span>
</a>
</li>
{/each}
</ul>
{/if}
</div>
@@ -0,0 +1,14 @@
import { error } from '@sveltejs/kit';
import { listInstancesForProperties } from '$lib/server/services/checklists';
import { getDescendantIds } from '$lib/server/services/properties';
import type { PageServerLoad } from './$types';
export const load: PageServerLoad = async ({ locals, params, url }) => {
if (!locals.company) throw error(401);
const includeDescendants = url.searchParams.get('descendants') === '1';
const propertyIds = includeDescendants
? await getDescendantIds(locals.company.id, params.id)
: [params.id];
const instances = await listInstancesForProperties(locals.company.id, propertyIds);
return { instances, includeDescendants, descendantCount: propertyIds.length };
};
@@ -0,0 +1,86 @@
<script lang="ts">
import type { PageData } from './$types';
import type { LayoutData } from '../$types';
let { data }: { data: PageData & LayoutData } = $props();
function fmtDate(d: Date | string | null | undefined): string {
if (!d) return '—';
const dt = typeof d === 'string' ? new Date(d) : d;
return Number.isNaN(dt.getTime()) ? '—' : dt.toISOString().slice(0, 10);
}
const open = $derived(data.instances.filter((i) => !i.completedAt));
const completed = $derived(data.instances.filter((i) => i.completedAt));
</script>
<div class="space-y-4">
{#if data.childCount > 0}
<div class="flex flex-wrap items-center justify-between gap-3 rounded-lg border border-gray-200 bg-white px-4 py-2.5 text-sm dark:border-gray-700 dark:bg-gray-800">
<div class="text-gray-700 dark:text-gray-300">
{#if data.includeDescendants}
<span class="font-medium">{data.property.name}</span> + sub-properties
<span class="ml-1 text-xs text-gray-500 dark:text-gray-400">({data.descendantCount} total)</span>
{:else}
<span class="font-medium">{data.property.name}</span>
<span class="ml-1 text-xs text-gray-500 dark:text-gray-400">({data.childCount} sub-properties hidden)</span>
{/if}
</div>
<div role="tablist" class="inline-flex rounded-md border border-gray-200 bg-gray-50 p-0.5 text-xs font-medium dark:border-gray-700 dark:bg-gray-900">
{#each [{ v: '', l: 'This property' }, { v: '1', l: 'Include sub-properties' }] as opt}
{@const active = (data.includeDescendants ? '1' : '') === opt.v}
<a
role="tab"
href={opt.v ? '?descendants=1' : '?'}
aria-selected={active}
class="rounded px-2 py-1 transition {active
? 'bg-white text-gray-900 shadow-sm dark:bg-gray-700 dark:text-gray-100'
: 'text-gray-600 hover:text-gray-900 dark:text-gray-400 dark:hover:text-gray-100'}"
>
{opt.l}
</a>
{/each}
</div>
</div>
{/if}
<section>
<h2 class="mb-2 text-sm font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400">
Open ({open.length})
</h2>
{#if open.length === 0}
<div class="rounded-lg border border-dashed border-gray-300 bg-white p-6 text-center text-sm text-gray-500 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-400">
No open checklists for this property.
</div>
{:else}
<ul class="divide-y divide-gray-200 overflow-hidden rounded-lg border border-gray-200 bg-white dark:divide-gray-700 dark:border-gray-700 dark:bg-gray-800">
{#each open as i (i.id)}
<li class="px-4 py-3 text-sm">
<a href="/checklists/{i.id}" class="font-medium text-gray-900 hover:text-primary-600 dark:text-gray-100 dark:hover:text-primary-400">
{i.title ?? 'Checklist'}
</a>
<div class="mt-0.5 text-xs text-gray-500 dark:text-gray-400">created {fmtDate(i.createdAt)}</div>
</li>
{/each}
</ul>
{/if}
</section>
{#if completed.length > 0}
<section>
<h2 class="mb-2 text-sm font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400">
Completed ({completed.length})
</h2>
<ul class="divide-y divide-gray-200 overflow-hidden rounded-lg border border-gray-200 bg-white dark:divide-gray-700 dark:border-gray-700 dark:bg-gray-800">
{#each completed as i (i.id)}
<li class="px-4 py-3 text-sm">
<a href="/checklists/{i.id}" class="text-gray-700 hover:text-primary-600 dark:text-gray-300 dark:hover:text-primary-400">
{i.title ?? 'Checklist'}
</a>
<div class="mt-0.5 text-xs text-gray-500 dark:text-gray-400">completed {fmtDate(i.completedAt)}</div>
</li>
{/each}
</ul>
</section>
{/if}
</div>
@@ -1,11 +1,15 @@
import { fail, redirect, error } from '@sveltejs/kit';
import { error, fail, redirect } from '@sveltejs/kit';
import { and, eq, isNull } from 'drizzle-orm';
import { z } from 'zod';
import { db } from '$lib/server/db/client';
import { properties } from '$lib/server/db/schema/properties';
import { createProperty } from '$lib/server/services/properties';
import type { Actions } from './$types';
import type { Actions, PageServerLoad } from './$types';
const PropertySchema = z.object({
name: z.string().trim().min(1, 'Name is required').max(255),
kind: z.string().trim().max(64).optional().or(z.literal('')),
parentId: z.string().uuid().optional().or(z.literal('')),
addressLine1: z.string().trim().max(255).optional().or(z.literal('')),
addressLine2: z.string().trim().max(255).optional().or(z.literal('')),
city: z.string().trim().max(128).optional().or(z.literal('')),
@@ -19,6 +23,17 @@ function emptyToNull(s: string | undefined): string | null {
return !s ? null : s;
}
export const load: PageServerLoad = async ({ locals, url }) => {
if (!locals.company) throw error(400, 'No active company');
const eligibleParents = await db
.select({ id: properties.id, name: properties.name })
.from(properties)
.where(and(eq(properties.companyId, locals.company.id), isNull(properties.deletedAt)))
.orderBy(properties.name);
const preselectParentId = url.searchParams.get('parent') ?? '';
return { eligibleParents, preselectParentId };
};
export const actions: Actions = {
default: async ({ request, locals }) => {
if (!locals.user || !locals.company) throw error(401, 'Not authenticated');
@@ -33,11 +48,13 @@ export const actions: Actions = {
});
}
const v = parsed.data;
try {
const { id } = await createProperty({
companyId: locals.company.id,
createdBy: locals.user.id,
name: v.name,
kind: emptyToNull(v.kind),
parentId: v.parentId ? v.parentId : null,
addressLine1: emptyToNull(v.addressLine1),
addressLine2: emptyToNull(v.addressLine2),
city: emptyToNull(v.city),
@@ -47,5 +64,11 @@ export const actions: Actions = {
notes: emptyToNull(v.notes)
});
throw redirect(303, `/properties/${id}`);
} catch (e) {
// SvelteKit redirects throw; let them bubble.
const { isRedirect, isHttpError } = await import('@sveltejs/kit');
if (isRedirect(e) || isHttpError(e)) throw e;
return fail(400, { error: (e as Error).message, values: raw });
}
}
};
+22 -2
View File
@@ -1,11 +1,15 @@
<script lang="ts">
import { enhance } from '$app/forms';
import type { ActionData } from './$types';
import type { ActionData, PageData } from './$types';
let { form }: { form: ActionData } = $props();
let { data, form }: { data: PageData; form: ActionData } = $props();
let submitting = $state(false);
const v = $derived((form?.values ?? {}) as Record<string, string>);
const selectedParent = $derived(v.parentId ?? data.preselectParentId ?? '');
const parentName = $derived(
data.eligibleParents.find((p) => p.id === selectedParent)?.name ?? null
);
</script>
<div class="mx-auto max-w-2xl space-y-6">
@@ -13,6 +17,11 @@
<h1 class="text-2xl font-semibold text-gray-900 dark:text-gray-100">New property</h1>
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
A property is a place where assets live (a warehouse, an office, a datacenter, a site).
{#if parentName}
<span class="ml-1 text-gray-700 dark:text-gray-300">
Creating as a sub-property under <strong>{parentName}</strong>.
</span>
{/if}
</p>
</div>
@@ -42,6 +51,17 @@
class="mt-1 block w-full rounded-md border border-gray-300 bg-white px-3 py-2 text-sm shadow-sm placeholder:text-gray-400 focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500 dark:border-gray-600 dark:bg-gray-900 dark:text-gray-100" />
</div>
<div>
<label for="parentId" class="block text-sm font-medium text-gray-700 dark:text-gray-300">Parent property</label>
<select id="parentId" name="parentId"
class="mt-1 block w-full rounded-md border border-gray-300 bg-white px-3 py-2 text-sm shadow-sm focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500 dark:border-gray-600 dark:bg-gray-900 dark:text-gray-100">
<option value="">— Top level (no parent) —</option>
{#each data.eligibleParents as opt (opt.id)}
<option value={opt.id} selected={opt.id === selectedParent}>{opt.name}</option>
{/each}
</select>
</div>
<div class="grid gap-4 sm:grid-cols-2">
<div class="sm:col-span-2">
<label for="addressLine1" class="block text-sm font-medium text-gray-700 dark:text-gray-300">Address line 1</label>