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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions Zeta.sln
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@ Project("{F2A71F9B-5D33-465A-A702-920D77279786}") = "Bayesian.Tests", "tests\Bay
EndProject
Project("{F2A71F9B-5D33-465A-A702-920D77279786}") = "Feldera.Bench", "bench\Feldera.Bench\Feldera.Bench.fsproj", "{AAAAAAAA-AAAA-AAAA-AAAA-AAAAAAAAAAAA}"
EndProject
Project("{F2A71F9B-5D33-465A-A702-920D77279786}") = "FactoryDemo.Api.FSharp", "samples\FactoryDemo.Api.FSharp\FactoryDemo.Api.FSharp.fsproj", "{40534D09-439E-4E5F-9A69-A73844DB674D}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
Expand Down Expand Up @@ -66,5 +68,9 @@ Global
{AAAAAAAA-AAAA-AAAA-AAAA-AAAAAAAAAAAA}.Debug|Any CPU.Build.0 = Debug|Any CPU
{AAAAAAAA-AAAA-AAAA-AAAA-AAAAAAAAAAAA}.Release|Any CPU.ActiveCfg = Release|Any CPU
{AAAAAAAA-AAAA-AAAA-AAAA-AAAAAAAAAAAA}.Release|Any CPU.Build.0 = Release|Any CPU
{40534D09-439E-4E5F-9A69-A73844DB674D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{40534D09-439E-4E5F-9A69-A73844DB674D}.Debug|Any CPU.Build.0 = Debug|Any CPU
{40534D09-439E-4E5F-9A69-A73844DB674D}.Release|Any CPU.ActiveCfg = Release|Any CPU
{40534D09-439E-4E5F-9A69-A73844DB674D}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
EndGlobal
16 changes: 16 additions & 0 deletions samples/FactoryDemo.Api.FSharp/FactoryDemo.Api.FSharp.fsproj
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<OutputType>Exe</OutputType>
<RootNamespace>Zeta.Samples.FactoryDemo.Api</RootNamespace>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
</PropertyGroup>

<ItemGroup>
<Compile Include="Seed.fs" />
<Compile Include="Program.fs" />
</ItemGroup>

<ItemGroup>
<PackageReference Include="FSharp.Core" />
</ItemGroup>
</Project>
90 changes: 90 additions & 0 deletions samples/FactoryDemo.Api.FSharp/Program.fs
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
module Zeta.Samples.FactoryDemo.Api.Program

open System
open Microsoft.AspNetCore.Builder
open Microsoft.AspNetCore.Http
open Microsoft.Extensions.DependencyInjection

// Minimal F# ASP.NET Core Web API serving the seed data for the
// factory-demo. Stack-independent — any frontend choice (Blazor /
// React / Vue / Svelte / curl) consumes the same JSON.
//
// V0 scope: in-memory seed only, no DB wiring. A Postgres backing
// with schema.sql + seed-data.sql is a planned follow-up PR; until
// then this API carries its own in-memory seed so the sample runs
// with zero external dependencies.

let private pipelineFunnel () =
Seed.opportunities
|> List.groupBy (fun o -> o.Stage)
|> List.map (fun (stage, opps) ->
{| Stage = stage
Count = opps |> List.length
TotalCents = opps |> List.sumBy (fun o -> o.AmountCents) |})

let private duplicateEmails () =
Seed.customers
|> List.groupBy (fun c -> c.Email)
|> List.filter (fun (_, xs) -> xs |> List.length > 1)
|> List.map (fun (email, xs) ->
{| Email = email
CustomerIds = xs |> List.map (fun c -> c.Id) |})

[<EntryPoint>]
let main args =
let builder = WebApplication.CreateBuilder(args)
// Emit consistent JSON for an external consumer — default
// System.Text.Json options are fine; documented for future tuning.
builder.Services.AddEndpointsApiExplorer() |> ignore
let app = builder.Build()

// Trivial root — lets a browser confirm the API is up. The
// `endpoints` list is the full contract surface (all 9 paths
// listed, including parameterised routes).
app.MapGet("/", Func<_>(fun () ->
{| name = "Factory-demo API (F#)"
version = "0.0.1"
endpoints =
[ "/"
"/api/customers"
"/api/customers/{id}"
"/api/customers/{id}/activities"
"/api/opportunities"
"/api/opportunities/{id}"
"/api/activities"
"/api/pipeline/funnel"
"/api/pipeline/duplicates" ] |} :> obj))
Comment on lines +44 to +56
Copy link

Copilot AI Apr 24, 2026

Choose a reason for hiding this comment

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

P1: JSON property casing is inconsistent across endpoints: the root endpoint returns lowercase fields (name/version/endpoints) while the record/anonymous-record responses from other endpoints will serialize as PascalCase by default. This creates a confusing contract for API consumers. Consider either configuring System.Text.Json to enforce a single naming policy (e.g., camelCase) for all responses or aligning the root payload field names with the rest of the API.

Copilot uses AI. Check for mistakes.
|> ignore

app.MapGet("/api/customers", Func<_>(fun () -> Seed.customers :> obj)) |> ignore
app.MapGet("/api/customers/{id:long}", Func<int64, IResult>(fun id ->
Seed.customers
|> List.tryFind (fun c -> c.Id = id)
|> function
| Some c -> Results.Ok c
| None -> Results.NotFound()))
|> ignore

app.MapGet("/api/opportunities", Func<_>(fun () -> Seed.opportunities :> obj)) |> ignore
app.MapGet("/api/opportunities/{id:long}", Func<int64, IResult>(fun id ->
Seed.opportunities
|> List.tryFind (fun o -> o.Id = id)
|> function
| Some o -> Results.Ok o
| None -> Results.NotFound()))
|> ignore

app.MapGet("/api/activities", Func<_>(fun () -> Seed.activities :> obj)) |> ignore
app.MapGet("/api/customers/{id:long}/activities", Func<int64, obj>(fun id ->
Seed.activities
|> List.filter (fun a -> a.CustomerId = id)
:> obj))
|> ignore

// Derived views that a frontend would otherwise compute client-
// side. Landing them server-side keeps the API contract tight.
app.MapGet("/api/pipeline/funnel", Func<_>(fun () -> pipelineFunnel () :> obj)) |> ignore
app.MapGet("/api/pipeline/duplicates", Func<_>(fun () -> duplicateEmails () :> obj)) |> ignore

app.Run()
0
101 changes: 101 additions & 0 deletions samples/FactoryDemo.Api.FSharp/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
# Factory-demo — JSON API (F#)

**What this is:** A minimal F# ASP.NET Core Web API that serves
the demo's seed data as JSON. Stack-independent — any frontend
(Blazor / React / Vue / curl) consumes the same endpoints.

**What this is NOT:** A pitch for Zeta as a data store. The
demo sells the **software factory**, not the database layer.
Backend is in-memory (v0) and will be swapped to Postgres (v1)
without changing the public API contract.

## Why this sample exists

The factory-demo needs a JSON API that any frontend choice can
consume. This API ships now so the backend is not on the
critical path when the frontend stack is chosen.

This is the F# reference implementation. A C# companion sample
(`samples/FactoryDemo.Api.CSharp/`, sibling PR #147) is planned
with the same 9 endpoints, matching JSON shapes, and identical
seed. C# is the more popular .NET language, so it is the natural
primary demo path; F# stays the reference because F# looks
closer to math, which makes theorems over the algebra easier
to express.
Comment on lines +19 to +24
Copy link

Copilot AI Apr 24, 2026

Choose a reason for hiding this comment

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

P2: The README hard-codes a sibling PR number ("PR #147") for the planned C# companion sample. PR numbers are repo-state specific and will become stale over time; consider referencing only the planned directory/path (or an issue/ADR) without embedding a PR number in long-lived docs.

Suggested change
(`samples/FactoryDemo.Api.CSharp/`, sibling PR #147) is planned
with the same 9 endpoints, matching JSON shapes, and identical
seed. C# is the more popular .NET language, so it is the natural
primary demo path; F# stays the reference because F# looks
closer to math, which makes theorems over the algebra easier
to express.
at `samples/FactoryDemo.Api.CSharp/` is planned with the same 9
endpoints, matching JSON shapes, and identical seed. C# is the
more popular .NET language, so it is the natural primary demo
path; F# stays the reference because F# looks closer to math,
which makes theorems over the algebra easier to express.

Copilot uses AI. Check for mistakes.

## How to run

```bash
dotnet run --project samples/FactoryDemo.Api.FSharp/FactoryDemo.Api.FSharp.fsproj
# API is up on http://localhost:5000 (or whatever ASP.NET picks).
# curl it:
curl http://localhost:5000/api/customers
curl http://localhost:5000/api/pipeline/funnel
curl http://localhost:5000/api/pipeline/duplicates
```

## Endpoints (v0)

| Method | Path | Returns |
|---|---|---|
| GET | `/` | API metadata + endpoint list |
| GET | `/api/customers` | All customers |
| GET | `/api/customers/{id}` | Single customer, 404 if missing |
| GET | `/api/customers/{id}/activities` | Activities for one customer |
| GET | `/api/opportunities` | All opportunities |
| GET | `/api/opportunities/{id}` | Single opportunity, 404 if missing |
| GET | `/api/activities` | All activities |
| GET | `/api/pipeline/funnel` | Per-stage count + $ total |
| GET | `/api/pipeline/duplicates` | Customers sharing an email |

All responses are JSON.

## V0 seed data

`Seed.fs` carries the in-memory seed (v1 will mirror a Postgres
seed-data.sql under `samples/FactoryDemo.Db/`, not yet in repo):

- 20 customers (trades contractors — plumbing, HVAC, electric, roofing, etc.)
- 30 opportunities across 5 stages (Lead, Qualified, Proposal, Won, Lost)
- 33 activities (calls, emails, SMS, notes)
- 2 intentional email collisions to drive the duplicate-review scenario

Seed is deterministic — restarting the server replays the same data.

## What v1 adds

- Postgres backing (Npgsql) wired against a planned
`samples/FactoryDemo.Db/` schema + seed (not yet in repo)
- CRUD endpoints (POST / PUT / DELETE) — v0 is read-only
Comment thread
AceHack marked this conversation as resolved.
- docker-compose for one-command Postgres + API
- Environment-variable configuration for connection string

Each of those is a follow-up PR.

## Design notes

- **`Microsoft.NET.Sdk.Web` SDK.** Pulls in ASP.NET Core via
framework reference — no package version edit in
`Directory.Packages.props` needed. Only `FSharp.Core` is an
explicit package reference.
- **Minimal APIs over MVC.** F# + minimal APIs is a clean
combination: one file, no controllers, no heavy routing ceremony.
- **Anonymous records for derived views.** `/api/pipeline/funnel`
returns an anonymous record with `Stage`, `Count`, and
`TotalCents` fields using F#'s `{| Stage = ...; Count = ...;
TotalCents = ... |}` syntax. Keeps the output shape local to
the endpoint handler.
- **`System.Text.Json` defaults.** No converter customisation in v0.
If a frontend needs camelCase / different date shapes, add it as
a targeted follow-up rather than reshape everything.
- **No OpenAPI / Swagger yet.** v0 intentionally minimal; Swagger
UI lands when endpoint count grows or frontend needs it.

## What this does NOT do

- Does not persist writes — v0 is read-only
- Does not authenticate or authorise — no auth for the demo v0
- Does not wire to Postgres — in-memory for v0
- Does not expose algebraic-delta / retraction-native internals
to the frontend (that's the internal kernel sample's job; the
factory-demo audience gets standard CRUD shape)
151 changes: 151 additions & 0 deletions samples/FactoryDemo.Api.FSharp/Seed.fs
Original file line number Diff line number Diff line change
@@ -0,0 +1,151 @@
module Zeta.Samples.FactoryDemo.Api.Seed

open System

// Deterministic in-memory seed data for the API sample. Keep these
// values stable so the same demo scenarios work whether the backing
// store is Postgres (production-shape, planned follow-up) or this
// in-memory fallback (zero-dependency, current).

type Customer =
{ Id: int64
Name: string
Email: string
Phone: string
Address: string
CreatedAt: DateTimeOffset
UpdatedAt: DateTimeOffset }

type Opportunity =
{ Id: int64
CustomerId: int64
Stage: string
AmountCents: int64
CreatedAt: DateTimeOffset
UpdatedAt: DateTimeOffset }

type Activity =
{ Id: int64
CustomerId: int64
OpportunityId: int64 option
Kind: string
Notes: string
OccurredAt: DateTimeOffset }

// Fixed clock so the seed is deterministic. Any timestamp computed
// from this is reproducible across restarts.
let private now = DateTimeOffset(2026, 4, 23, 0, 0, 0, TimeSpan.Zero)
let private ago (days: int) = now.AddDays(float -days)

let customers : Customer list =
let mk id name email phone address =
{ Id = id
Name = name
Email = email
Phone = phone
Address = address
CreatedAt = ago 120
UpdatedAt = ago 1 }
// Email collision #1: Alice Plumbing (1) and Aaron Smith (13) share alice@acme.example.
// Email collision #2: Bob HVAC (5) and Quincy Assistant (19) share bob@trades.example.
[ mk 1L "Alice Plumbing LLC" "alice@acme.example" "555-0101" "123 Elm St, Portland OR"
mk 2L "Benson Roofing" "benson@roof.example" "555-0102" "45 Oak Ave, Seattle WA"
mk 3L "Crystal Electric" "crystal@sparks.example" "555-0103" "9 Pine Rd, Boise ID"
mk 4L "Delta HVAC & Mechanical" "delta@hvac.example" "555-0104" "700 Main St, Spokane WA"
mk 5L "Bob HVAC Services" "bob@trades.example" "555-0105" "12 Bay Blvd, Tacoma WA"
mk 6L "Evergreen Landscaping" "info@evergreen.example" "555-0106" "88 Forest Ln, Eugene OR"
mk 7L "Fairbanks Plumbing" "contact@fairbanks.example" "555-0107" "5 River Rd, Anchorage AK"
mk 8L "Granite Pest Control" "hello@granite.example" "555-0108" "301 Stone Way, Boise ID"
mk 9L "Highland Roofing Co" "highland@roof.example" "555-0109" "22 Hill Dr, Bend OR"
mk 10L "Iron Tree Electric" "iron@tree.example" "555-0110" "17 Spruce St, Salem OR"
mk 11L "Jackson Pool Services" "jackson@pools.example" "555-0111" "600 Lake Rd, Reno NV"
mk 12L "Klein Garage Doors" "klein@doors.example" "555-0112" "44 4th Ave, Medford OR"
mk 13L "Aaron Smith (new contact)" "alice@acme.example" "555-0113" "123 Elm St, Portland OR"
mk 14L "Lakeview Solar" "lakeview@solar.example" "555-0114" "250 Shore Dr, Bellevue WA"
mk 15L "Mountain Well Drilling" "mountain@wells.example" "555-0115" "12 Ridge Rd, Coeur dAlene ID"
mk 16L "Nightingale Security" "ngale@secure.example" "555-0116" "88 Watch Way, Vancouver WA"
mk 17L "Oak Hill Septic" "oak@septic.example" "555-0117" "14 Rural Rt 3, Gresham OR"
mk 18L "Prairie Window Cleaning" "prairie@windows.example" "555-0118" "66 Glass Rd, Kennewick WA"
mk 19L "Quincy Assistant (Bob HVAC)" "bob@trades.example" "555-0119" "12 Bay Blvd, Tacoma WA"
mk 20L "Redwood Tree Service" "redwood@trees.example" "555-0120" "3 Canopy Ct, Hillsboro OR" ]
Comment on lines +49 to +70
Copy link

Copilot AI Apr 24, 2026

Choose a reason for hiding this comment

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

P0: This seed data includes a specific person name ("Aaron Smith") in both comments and customer display name. Repo convention forbids direct contributor names in code/docs (docs/AGENT-BEST-PRACTICES.md:284-292); even if intended as fictional, it is indistinguishable from maintainer attribution. Please rename this customer/comment to a role-based or clearly fictional/non-contributor identifier and keep the duplicate-email scenario intact.

Suggested change
// Email collision #1: Alice Plumbing (1) and Aaron Smith (13) share alice@acme.example.
// Email collision #2: Bob HVAC (5) and Quincy Assistant (19) share bob@trades.example.
[ mk 1L "Alice Plumbing LLC" "alice@acme.example" "555-0101" "123 Elm St, Portland OR"
mk 2L "Benson Roofing" "benson@roof.example" "555-0102" "45 Oak Ave, Seattle WA"
mk 3L "Crystal Electric" "crystal@sparks.example" "555-0103" "9 Pine Rd, Boise ID"
mk 4L "Delta HVAC & Mechanical" "delta@hvac.example" "555-0104" "700 Main St, Spokane WA"
mk 5L "Bob HVAC Services" "bob@trades.example" "555-0105" "12 Bay Blvd, Tacoma WA"
mk 6L "Evergreen Landscaping" "info@evergreen.example" "555-0106" "88 Forest Ln, Eugene OR"
mk 7L "Fairbanks Plumbing" "contact@fairbanks.example" "555-0107" "5 River Rd, Anchorage AK"
mk 8L "Granite Pest Control" "hello@granite.example" "555-0108" "301 Stone Way, Boise ID"
mk 9L "Highland Roofing Co" "highland@roof.example" "555-0109" "22 Hill Dr, Bend OR"
mk 10L "Iron Tree Electric" "iron@tree.example" "555-0110" "17 Spruce St, Salem OR"
mk 11L "Jackson Pool Services" "jackson@pools.example" "555-0111" "600 Lake Rd, Reno NV"
mk 12L "Klein Garage Doors" "klein@doors.example" "555-0112" "44 4th Ave, Medford OR"
mk 13L "Aaron Smith (new contact)" "alice@acme.example" "555-0113" "123 Elm St, Portland OR"
mk 14L "Lakeview Solar" "lakeview@solar.example" "555-0114" "250 Shore Dr, Bellevue WA"
mk 15L "Mountain Well Drilling" "mountain@wells.example" "555-0115" "12 Ridge Rd, Coeur dAlene ID"
mk 16L "Nightingale Security" "ngale@secure.example" "555-0116" "88 Watch Way, Vancouver WA"
mk 17L "Oak Hill Septic" "oak@septic.example" "555-0117" "14 Rural Rt 3, Gresham OR"
mk 18L "Prairie Window Cleaning" "prairie@windows.example" "555-0118" "66 Glass Rd, Kennewick WA"
mk 19L "Quincy Assistant (Bob HVAC)" "bob@trades.example" "555-0119" "12 Bay Blvd, Tacoma WA"
mk 20L "Redwood Tree Service" "redwood@trees.example" "555-0120" "3 Canopy Ct, Hillsboro OR" ]
// Email collision #1: Alice Plumbing (1) and Alice Plumbing Intake Contact (13) share alice@acme.example.
// Email collision #2: Bob HVAC (5) and Quincy Assistant (19) share bob@trades.example.
[ mk 1L "Alice Plumbing LLC" "alice@acme.example" "555-0101" "123 Elm St, Portland OR"
mk 2L "Benson Roofing" "benson@roof.example" "555-0102" "45 Oak Ave, Seattle WA"
mk 3L "Crystal Electric" "crystal@sparks.example" "555-0103" "9 Pine Rd, Boise ID"
mk 4L "Delta HVAC & Mechanical" "delta@hvac.example" "555-0104" "700 Main St, Spokane WA"
mk 5L "Bob HVAC Services" "bob@trades.example" "555-0105" "12 Bay Blvd, Tacoma WA"
mk 6L "Evergreen Landscaping" "info@evergreen.example" "555-0106" "88 Forest Ln, Eugene OR"
mk 7L "Fairbanks Plumbing" "contact@fairbanks.example" "555-0107" "5 River Rd, Anchorage AK"
mk 8L "Granite Pest Control" "hello@granite.example" "555-0108" "301 Stone Way, Boise ID"
mk 9L "Highland Roofing Co" "highland@roof.example" "555-0109" "22 Hill Dr, Bend OR"
mk 10L "Iron Tree Electric" "iron@tree.example" "555-0110" "17 Spruce St, Salem OR"
mk 11L "Jackson Pool Services" "jackson@pools.example" "555-0111" "600 Lake Rd, Reno NV"
mk 12L "Klein Garage Doors" "klein@doors.example" "555-0112" "44 4th Ave, Medford OR"
mk 13L "Alice Plumbing Intake Contact" "alice@acme.example" "555-0113" "123 Elm St, Portland OR"
mk 14L "Lakeview Solar" "lakeview@solar.example" "555-0114" "250 Shore Dr, Bellevue WA"
mk 15L "Mountain Well Drilling" "mountain@wells.example" "555-0115" "12 Ridge Rd, Coeur dAlene ID"
mk 16L "Nightingale Security" "ngale@secure.example" "555-0116" "88 Watch Way, Vancouver WA"
mk 17L "Oak Hill Septic" "oak@septic.example" "555-0117" "14 Rural Rt 3, Gresham OR"
mk 18L "Prairie Window Cleaning" "prairie@windows.example" "555-0118" "66 Glass Rd, Kennewick WA"
mk 19L "Quincy Assistant (Bob HVAC)" "bob@trades.example" "555-0119" "12 Bay Blvd, Tacoma WA"
mk 20L "Redwood Tree Service" "redwood@trees.example" "555-0120" "3 Canopy Ct, Hillsboro OR" ]

Copilot uses AI. Check for mistakes.

let opportunities : Opportunity list =
let mk id custId stage amount =
{ Id = id
CustomerId = custId
Stage = stage
AmountCents = amount
CreatedAt = ago 30
UpdatedAt = ago 2 }
[ mk 1L 1L "Lead" 250000L
mk 2L 1L "Qualified" 800000L
mk 3L 2L "Lead" 180000L
mk 4L 3L "Proposal" 450000L
mk 5L 3L "Won" 120000L
mk 6L 4L "Lead" 2200000L
mk 7L 4L "Qualified" 600000L
mk 8L 5L "Proposal" 350000L
mk 9L 5L "Won" 900000L
mk 10L 6L "Lead" 150000L
mk 11L 7L "Qualified" 500000L
mk 12L 7L "Proposal" 700000L
mk 13L 8L "Won" 220000L
mk 14L 9L "Lead" 300000L
mk 15L 9L "Lead" 1800000L
mk 16L 10L "Qualified" 950000L
mk 17L 11L "Proposal" 1400000L
mk 18L 12L "Won" 380000L
mk 19L 13L "Lead" 50000L
mk 20L 14L "Proposal" 2500000L
mk 21L 14L "Qualified" 1100000L
mk 22L 15L "Won" 600000L
mk 23L 16L "Lead" 180000L
mk 24L 17L "Qualified" 270000L
mk 25L 18L "Lead" 80000L
mk 26L 19L "Proposal" 320000L
mk 27L 20L "Won" 450000L
mk 28L 20L "Lead" 210000L
mk 29L 2L "Lost" 90000L
mk 30L 6L "Lost" 400000L ]

let activities : Activity list =
let mk id custId oppId kind notes daysAgo =
{ Id = id
CustomerId = custId
OpportunityId = oppId
Kind = kind
Notes = notes
OccurredAt = ago daysAgo }
[ mk 1L 1L (Some 1L) "Call" "Initial intake call — 3 units, basement finish" 14
mk 2L 1L (Some 1L) "Email" "Sent follow-up with rough estimate" 13
mk 3L 1L (Some 2L) "Call" "Scope expanded to full house repipe" 6
mk 4L 2L (Some 3L) "Email" "Insurance paperwork sent for roof claim" 10
mk 5L 3L (Some 4L) "Call" "Walkthrough scheduled for Tuesday" 8
mk 6L 3L (Some 5L) "Note" "Payment received — closed won" 3
mk 7L 4L (Some 6L) "Call" "Commercial HVAC replacement — 6 rooftop units" 20
mk 8L 4L (Some 6L) "Email" "Technical specs and load calcs sent" 18
mk 9L 4L (Some 7L) "Call" "Second opportunity — server-room cooling" 5
mk 10L 5L (Some 8L) "SMS" "Confirmed 10am arrival window" 2
mk 11L 5L (Some 9L) "Note" "Deposit received; scheduled for next week" 7
mk 12L 6L (Some 10L) "Email" "Initial inquiry from website" 4
mk 13L 7L (Some 11L) "Call" "Alaska project — remote site, flew tools in" 30
mk 14L 7L (Some 12L) "Email" "Proposal sent with permitting schedule" 15
mk 15L 8L (Some 13L) "Note" "Quarterly service contract signed" 45
mk 16L 9L (Some 14L) "Call" "Storm damage — needs quick turnaround" 1
mk 17L 9L (Some 15L) "Email" "Large hotel roof — sent credentials package" 2
mk 18L 10L (Some 16L) "Call" "Panel upgrade consult" 11
mk 19L 11L (Some 17L) "SMS" "Pool opening scheduled for May 1" 5
mk 20L 12L (Some 18L) "Note" "Installed — 3yr warranty registered" 60
mk 21L 13L (Some 19L) "Email" "Intro call tomorrow 2pm" 1
mk 22L 14L (Some 20L) "Call" "Roof assessment + solar compatibility check" 12
mk 23L 14L (Some 21L) "Email" "Federal tax credit paperwork sent" 9
mk 24L 15L (Some 22L) "Note" "Test-well results clean; contract signed" 25
mk 25L 16L (Some 23L) "Call" "Camera system walkthrough" 6
mk 26L 17L (Some 24L) "SMS" "Septic pump appointment confirmed" 3
mk 27L 18L (Some 25L) "Email" "Storefront window quote" 7
mk 28L 19L (Some 26L) "Call" "Coordinating with Bob HVAC on combined job" 4
mk 29L 20L (Some 27L) "Note" "Repeat customer — 2nd tree removal this year" 40
mk 30L 20L (Some 28L) "Email" "Quarterly pruning proposal" 2
mk 31L 2L (Some 29L) "Note" "Customer went with competitor on price" 22
mk 32L 6L (Some 30L) "Note" "Lost deal — decided to self-install" 18
mk 33L 1L None "Email" "General follow-up — hope repipe went well" 90 ]
Loading
Loading