{
  "openapi": "3.1.0",
  "info": {
    "title": "Cykelkarta Stockholm API",
    "version": "1.0.0",
    "description": "API for cycling-related civic issues reported through Stockholm's \"Tyck till\" citizen feedback system (2023\u20132025). Contains 25,000+ issues covering bike lanes, abandoned bicycles, bike parking, and winter maintenance.\n\n**Important caveats:**\n- These are *reported issues*, not confirmed incidents. A report reflects a citizen's observation, not a verified problem.\n- Winter maintenance issues (snow clearing, snow piles, obstructions, salt spreading) account for roughly 60% of all issues. When analyzing cycling infrastructure problems, filter with `winter=0` to exclude them.\n- All personal information (emails, phone numbers, names) has been redacted from the data.",
    "contact": {
      "name": "Cykelkarta Stockholm",
      "url": "https://cykelkarta-stockholm.pages.dev"
    },
    "license": {
      "name": "Public civic data from Stockholm municipality"
    }
  },
  "servers": [
    {
      "url": "https://cykelkarta-stockholm.pages.dev",
      "description": "Production (Cloudflare Pages)"
    }
  ],
  "x-agent-tools": [
    {
      "type": "mcp",
      "name": "cykelkarta-mcp-server",
      "transport": "stdio",
      "install": "npm install cykelkarta-mcp-server",
      "run": "npx cykelkarta-mcp-server",
      "description": "MCP server for querying Stockholm cycling issue data from AI assistants"
    },
    {
      "type": "cli",
      "name": "cykelkarta-stockholm",
      "install": "npm install -g cykelkarta-stockholm",
      "run": "cykelkarta",
      "description": "Command-line tool for querying Stockholm cycling issue data"
    }
  ],
  "tags": [
    {
      "name": "Issues",
      "description": "Citizen-reported cycling issues from Stockholm's Tyck till system"
    },
    {
      "name": "Search",
      "description": "Full-text search across issues"
    },
    {
      "name": "Stats",
      "description": "Aggregate statistics"
    },
    {
      "name": "Edits",
      "description": "Community coordinate corrections and visibility overrides"
    },
    {
      "name": "Static Data",
      "description": "Pre-built static JSON files served from CDN"
    }
  ],
  "paths": {
    "/api/issues": {
      "get": {
        "operationId": "listIssues",
        "summary": "List issues (paginated)",
        "description": "Returns a paginated list of citizen-reported cycling issues. Does not include full_text (use the single-issue endpoint for that). Supports filtering by type, year, month, area, placement status, and winter/non-winter classification.\n\nSet `compact=1` to get a minimal payload (id, date, type, winter, placed) for all matching issues without pagination \u2014 useful for map filtering.",
        "tags": ["Issues"],
        "parameters": [
          {
            "name": "page",
            "in": "query",
            "description": "Page number (1-based).",
            "schema": { "type": "integer", "minimum": 1, "default": 1 }
          },
          {
            "name": "limit",
            "in": "query",
            "description": "Items per page. Ignored when compact=1.",
            "schema": { "type": "integer", "minimum": 1, "maximum": 200, "default": 50 }
          },
          {
            "name": "type",
            "in": "query",
            "description": "Filter by issue type. The 8 types are: \"Cykelbana, cykelv\u00e4g\", \"Cykel \u00f6vergiven\", \"Cykelst\u00e4ll, cykelparkering\", \"Cykel- och g\u00e5ngv\u00e4g i park\" (cycling issues), and \"Sn\u00f6r\u00f6jning i gatumilj\u00f6\", \"Sn\u00f6h\u00f6g, sn\u00f6vall\", \"Hinder i gatumilj\u00f6\", \"Cykelv\u00e4g sopsaltning\" (winter issues).",
            "schema": {
              "type": "string",
              "enum": [
                "Cykelbana, cykelv\u00e4g",
                "Cykel \u00f6vergiven",
                "Cykelst\u00e4ll, cykelparkering",
                "Cykel- och g\u00e5ngv\u00e4g i park",
                "Sn\u00f6r\u00f6jning i gatumilj\u00f6",
                "Sn\u00f6h\u00f6g, sn\u00f6vall",
                "Hinder i gatumilj\u00f6",
                "Cykelv\u00e4g sopsaltning"
              ]
            }
          },
          {
            "name": "year",
            "in": "query",
            "description": "Filter by year (e.g. \"2024\").",
            "schema": { "type": "string", "pattern": "^\\d{4}$", "examples": ["2023", "2024", "2025"] }
          },
          {
            "name": "month",
            "in": "query",
            "description": "Filter by month (zero-padded, e.g. \"03\" for March).",
            "schema": { "type": "string", "pattern": "^\\d{2}$", "examples": ["01", "06", "12"] }
          },
          {
            "name": "placed",
            "in": "query",
            "description": "Filter by geocoding status. 1 = has coordinates on the map, 0 = could not be geocoded.",
            "schema": { "type": "integer", "enum": [0, 1] }
          },
          {
            "name": "winter",
            "in": "query",
            "description": "Filter by winter classification. 1 = winter maintenance issue (snow/ice), 0 = cycling infrastructure issue. About 60% of issues are winter-related.",
            "schema": { "type": "integer", "enum": [0, 1] }
          },
          {
            "name": "area",
            "in": "query",
            "description": "Filter by Stockholm area/district name (e.g. \"S\u00f6dermalm\", \"\u00d6stermalm\").",
            "schema": { "type": "string" }
          },
          {
            "name": "compact",
            "in": "query",
            "description": "Set to \"1\" for a compact response with minimal fields and no pagination. Returns all matching issues.",
            "schema": { "type": "string", "enum": ["1"] }
          }
        ],
        "responses": {
          "200": {
            "description": "Paginated issue list (or compact list when compact=1).",
            "content": {
              "application/json": {
                "schema": {
                  "oneOf": [
                    { "$ref": "#/components/schemas/IssueListResponse" },
                    { "$ref": "#/components/schemas/IssueListCompactResponse" }
                  ]
                }
              }
            }
          },
          "500": {
            "description": "Internal server error.",
            "content": {
              "application/json": {
                "schema": { "$ref": "#/components/schemas/ErrorResponse" }
              }
            }
          }
        }
      }
    },
    "/api/issues/{id}": {
      "get": {
        "operationId": "getIssue",
        "summary": "Get a single issue with full text",
        "description": "Returns a single issue by ID, including the full citizen-reported text (full_text field). Issue IDs follow the pattern \"2024SC123456\".",
        "tags": ["Issues"],
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "description": "Issue ID (e.g. \"2024SC123456\").",
            "schema": { "type": "string", "pattern": "^\\d{4}SC\\d+$" }
          }
        ],
        "responses": {
          "200": {
            "description": "The issue with full text.",
            "content": {
              "application/json": {
                "schema": { "$ref": "#/components/schemas/IssueFull" }
              }
            }
          },
          "404": {
            "description": "Issue not found.",
            "content": {
              "application/json": {
                "schema": { "$ref": "#/components/schemas/ErrorResponse" }
              }
            }
          },
          "500": {
            "description": "Internal server error.",
            "content": {
              "application/json": {
                "schema": { "$ref": "#/components/schemas/ErrorResponse" }
              }
            }
          }
        }
      }
    },
    "/api/search": {
      "get": {
        "operationId": "searchIssues",
        "summary": "Full-text search across issues",
        "description": "Searches issues using FTS5 full-text indexing across summary, full_text, area, and ID fields. Supports prefix matching. Returns results ranked by relevance.",
        "tags": ["Search"],
        "parameters": [
          {
            "name": "q",
            "in": "query",
            "required": true,
            "description": "Search query string. Prefix matching is applied automatically.",
            "schema": { "type": "string", "minLength": 1 }
          },
          {
            "name": "limit",
            "in": "query",
            "description": "Maximum number of results to return.",
            "schema": { "type": "integer", "minimum": 1, "maximum": 500, "default": 20 }
          }
        ],
        "responses": {
          "200": {
            "description": "Search results. Returns empty array if query is empty.",
            "content": {
              "application/json": {
                "schema": { "$ref": "#/components/schemas/SearchResponse" }
              }
            }
          },
          "500": {
            "description": "Internal server error.",
            "content": {
              "application/json": {
                "schema": { "$ref": "#/components/schemas/ErrorResponse" }
              }
            }
          }
        }
      }
    },
    "/api/stats": {
      "get": {
        "operationId": "getStats",
        "summary": "Get issue count statistics",
        "description": "Returns aggregate counts: total issues, placed (geocoded) issues, and unplaced issues. By default excludes winter maintenance issues; set winter=1 to include them.",
        "tags": ["Stats"],
        "parameters": [
          {
            "name": "winter",
            "in": "query",
            "description": "Set to \"1\" to include winter maintenance issues in the counts. Default behavior (omitted or \"0\") excludes winter issues.",
            "schema": { "type": "string", "enum": ["0", "1"] }
          }
        ],
        "responses": {
          "200": {
            "description": "Aggregate issue counts.",
            "content": {
              "application/json": {
                "schema": { "$ref": "#/components/schemas/StatsResponse" }
              }
            }
          },
          "500": {
            "description": "Internal server error.",
            "content": {
              "application/json": {
                "schema": { "$ref": "#/components/schemas/ErrorResponse" }
              }
            }
          }
        }
      }
    },
    "/api/edits": {
      "get": {
        "operationId": "listEdits",
        "summary": "List all community edits",
        "description": "Returns all coordinate overrides, hidden-issue markers, and notes added by community editors. These edits correct geocoding errors or hide irrelevant issues from the map.",
        "tags": ["Edits"],
        "responses": {
          "200": {
            "description": "Array of all edits.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "array",
                  "items": { "$ref": "#/components/schemas/Edit" }
                }
              }
            }
          },
          "500": {
            "description": "Internal server error.",
            "content": {
              "application/json": {
                "schema": { "$ref": "#/components/schemas/ErrorResponse" }
              }
            }
          }
        }
      }
    },
    "/data/stats.json": {
      "get": {
        "operationId": "getStaticStats",
        "summary": "Pre-built comprehensive statistics",
        "description": "Static JSON file with detailed breakdowns: totals by type, by category (Felanm\u00e4lan, Klagom\u00e5l, Fr\u00e5ga, Id\u00e9, Ber\u00f6m), by year, monthly trends, and yearly-by-type cross-tabulation. Served from CDN, regenerated periodically.",
        "tags": ["Static Data"],
        "responses": {
          "200": {
            "description": "Comprehensive statistics object.",
            "content": {
              "application/json": {
                "schema": { "$ref": "#/components/schemas/StaticStats" }
              }
            }
          }
        }
      }
    },
    "/data/geojson.json": {
      "get": {
        "operationId": "getGeoJSON",
        "summary": "GeoJSON FeatureCollection of all geocoded locations",
        "description": "Static GeoJSON file (~2.9 MB) containing all geocoded locations as Point features. Each feature represents a street address or location with aggregated issue counts and types. Used to render the map. Served from CDN.",
        "tags": ["Static Data"],
        "responses": {
          "200": {
            "description": "GeoJSON FeatureCollection.",
            "content": {
              "application/json": {
                "schema": { "$ref": "#/components/schemas/GeoJSONFeatureCollection" }
              }
            }
          }
        }
      }
    }
  },
  "components": {
    "schemas": {
      "IssueSummary": {
        "type": "object",
        "description": "An issue in list view (without full_text).",
        "required": ["id", "date", "type", "category", "area", "summary", "winter", "placed"],
        "properties": {
          "id": {
            "type": "string",
            "description": "Unique issue ID from Stockholm's system (e.g. \"2024SC123456\").",
            "examples": ["2024SC53082"]
          },
          "date": {
            "type": "string",
            "format": "date",
            "description": "Date the issue was reported (YYYY-MM-DD).",
            "examples": ["2024-03-15"]
          },
          "type": {
            "type": "string",
            "description": "Issue type. 4 cycling types and 4 winter maintenance types.",
            "enum": [
              "Cykelbana, cykelv\u00e4g",
              "Cykel \u00f6vergiven",
              "Cykelst\u00e4ll, cykelparkering",
              "Cykel- och g\u00e5ngv\u00e4g i park",
              "Sn\u00f6r\u00f6jning i gatumilj\u00f6",
              "Sn\u00f6h\u00f6g, sn\u00f6vall",
              "Hinder i gatumilj\u00f6",
              "Cykelv\u00e4g sopsaltning"
            ]
          },
          "category": {
            "type": "string",
            "description": "Feedback category.",
            "enum": ["Felanm\u00e4lan", "Klagom\u00e5l", "Fr\u00e5ga", "Id\u00e9", "Ber\u00f6m", "\u00d6vrigt"]
          },
          "area": {
            "type": ["string", "null"],
            "description": "Stockholm district/area name.",
            "examples": ["S\u00f6dermalm", "\u00d6stermalm", "Kungsholmen"]
          },
          "summary": {
            "type": ["string", "null"],
            "description": "Short summary of the issue."
          },
          "winter": {
            "type": "integer",
            "description": "1 if this is a winter maintenance issue, 0 if cycling infrastructure.",
            "enum": [0, 1]
          },
          "placed": {
            "type": "integer",
            "description": "1 if the issue has been geocoded to map coordinates, 0 if not.",
            "enum": [0, 1]
          }
        }
      },
      "IssueCompact": {
        "type": "object",
        "description": "Minimal issue representation for compact mode.",
        "required": ["id", "date", "type", "winter", "placed"],
        "properties": {
          "id": { "type": "string" },
          "date": { "type": "string", "format": "date" },
          "type": { "type": "string" },
          "winter": { "type": "integer", "enum": [0, 1] },
          "placed": { "type": "integer", "enum": [0, 1] }
        }
      },
      "IssueFull": {
        "type": "object",
        "description": "A single issue with full citizen-reported text. Personal information has been redacted.",
        "required": ["id", "date", "type", "category", "area", "summary", "full_text", "location_name", "winter", "placed"],
        "properties": {
          "id": { "type": "string", "examples": ["2024SC53082"] },
          "date": { "type": "string", "format": "date" },
          "type": {
            "type": "string",
            "enum": [
              "Cykelbana, cykelv\u00e4g",
              "Cykel \u00f6vergiven",
              "Cykelst\u00e4ll, cykelparkering",
              "Cykel- och g\u00e5ngv\u00e4g i park",
              "Sn\u00f6r\u00f6jning i gatumilj\u00f6",
              "Sn\u00f6h\u00f6g, sn\u00f6vall",
              "Hinder i gatumilj\u00f6",
              "Cykelv\u00e4g sopsaltning"
            ]
          },
          "category": { "type": "string" },
          "area": { "type": ["string", "null"] },
          "summary": { "type": ["string", "null"] },
          "full_text": {
            "type": ["string", "null"],
            "description": "Full citizen-reported text. PII has been redacted (e.g. \"[e-post borttagen]\", \"[namn borttaget]\")."
          },
          "location_name": {
            "type": ["string", "null"],
            "description": "Extracted street address or location name used for geocoding."
          },
          "winter": { "type": "integer", "enum": [0, 1] },
          "placed": { "type": "integer", "enum": [0, 1] }
        }
      },
      "Pagination": {
        "type": "object",
        "required": ["page", "limit", "total", "totalPages"],
        "properties": {
          "page": { "type": "integer", "description": "Current page number." },
          "limit": { "type": "integer", "description": "Items per page." },
          "total": { "type": "integer", "description": "Total number of matching issues." },
          "totalPages": { "type": "integer", "description": "Total number of pages." }
        }
      },
      "IssueListResponse": {
        "type": "object",
        "description": "Paginated issue list response.",
        "required": ["issues", "pagination"],
        "properties": {
          "issues": {
            "type": "array",
            "items": { "$ref": "#/components/schemas/IssueSummary" }
          },
          "pagination": { "$ref": "#/components/schemas/Pagination" }
        }
      },
      "IssueListCompactResponse": {
        "type": "object",
        "description": "Compact issue list response (no pagination, minimal fields).",
        "required": ["issues"],
        "properties": {
          "issues": {
            "type": "array",
            "items": { "$ref": "#/components/schemas/IssueCompact" }
          }
        }
      },
      "SearchResponse": {
        "type": "object",
        "required": ["results"],
        "properties": {
          "results": {
            "type": "array",
            "items": { "$ref": "#/components/schemas/IssueSummary" }
          }
        }
      },
      "StatsResponse": {
        "type": "object",
        "description": "Aggregate issue counts from the live database.",
        "required": ["total", "placed", "unplaced"],
        "properties": {
          "total": { "type": "integer", "description": "Total number of issues matching the filter." },
          "placed": { "type": "integer", "description": "Number of geocoded issues." },
          "unplaced": { "type": "integer", "description": "Number of issues without coordinates." }
        }
      },
      "Edit": {
        "type": "object",
        "description": "A community edit that overrides an issue's coordinates or hides it from the map.",
        "required": ["issue_id", "lat", "lon", "hidden", "note", "updated_at"],
        "properties": {
          "issue_id": { "type": "string", "description": "The issue ID this edit applies to." },
          "lat": { "type": ["number", "null"], "description": "Override latitude (WGS84), or null if not changing coordinates." },
          "lon": { "type": ["number", "null"], "description": "Override longitude (WGS84), or null if not changing coordinates." },
          "hidden": { "type": "integer", "description": "1 if the issue should be hidden from the map, 0 otherwise.", "enum": [0, 1] },
          "note": { "type": ["string", "null"], "description": "Optional editor note explaining the edit." },
          "updated_at": { "type": "string", "format": "date-time", "description": "When the edit was last modified." }
        }
      },
      "StaticStats": {
        "type": "object",
        "description": "Comprehensive pre-built statistics. Regenerated periodically from the full dataset.",
        "required": ["generated_at", "data_description", "date_range", "total_issues", "overview", "yearly_by_type", "monthly_trends"],
        "properties": {
          "generated_at": { "type": "string", "format": "date-time" },
          "data_description": { "type": "string" },
          "date_range": {
            "type": "object",
            "required": ["from", "to"],
            "properties": {
              "from": { "type": "string", "format": "date" },
              "to": { "type": "string", "format": "date" }
            }
          },
          "total_issues": { "type": "integer" },
          "overview": {
            "type": "object",
            "required": ["by_type", "by_category", "by_year", "placed", "unplaced", "cycling_issues", "winter_issues", "total_areas"],
            "properties": {
              "by_type": {
                "type": "object",
                "description": "Issue count per type.",
                "additionalProperties": { "type": "integer" }
              },
              "by_category": {
                "type": "object",
                "description": "Issue count per feedback category (Felanm\u00e4lan, Klagom\u00e5l, Fr\u00e5ga, Id\u00e9, Ber\u00f6m, \u00d6vrigt).",
                "additionalProperties": { "type": "integer" }
              },
              "by_year": {
                "type": "object",
                "description": "Issue count per year.",
                "additionalProperties": { "type": "integer" }
              },
              "placed": { "type": "integer" },
              "unplaced": { "type": "integer" },
              "cycling_issues": { "type": "integer", "description": "Non-winter cycling infrastructure issues." },
              "winter_issues": { "type": "integer", "description": "Winter maintenance issues (~60% of total)." },
              "total_areas": { "type": "integer", "description": "Number of distinct Stockholm areas/districts." }
            }
          },
          "yearly_by_type": {
            "type": "object",
            "description": "Per-year breakdown by issue type.",
            "additionalProperties": {
              "type": "object",
              "additionalProperties": { "type": "integer" }
            }
          },
          "monthly_trends": {
            "type": "array",
            "description": "Monthly time series with total, cycling, and winter counts.",
            "items": {
              "type": "object",
              "required": ["month", "total", "cycling", "winter"],
              "properties": {
                "month": { "type": "string", "description": "YYYY-MM format.", "examples": ["2024-03"] },
                "total": { "type": "integer" },
                "cycling": { "type": "integer" },
                "winter": { "type": "integer" }
              }
            }
          }
        }
      },
      "GeoJSONFeatureCollection": {
        "type": "object",
        "description": "Standard GeoJSON FeatureCollection. Each feature is a Point representing a geocoded location with aggregated issue data.",
        "required": ["type", "features"],
        "properties": {
          "type": { "type": "string", "const": "FeatureCollection" },
          "features": {
            "type": "array",
            "items": {
              "type": "object",
              "required": ["type", "geometry", "properties"],
              "properties": {
                "type": { "type": "string", "const": "Feature" },
                "geometry": {
                  "type": "object",
                  "required": ["type", "coordinates"],
                  "properties": {
                    "type": { "type": "string", "const": "Point" },
                    "coordinates": {
                      "type": "array",
                      "description": "[longitude, latitude] in WGS84.",
                      "items": { "type": "number" },
                      "minItems": 2,
                      "maxItems": 2
                    }
                  }
                },
                "properties": {
                  "type": "object",
                  "required": ["name", "count", "types", "issueIds"],
                  "properties": {
                    "name": { "type": "string", "description": "Street address or location name.", "examples": ["Stora Essingep\u00e5farten 1"] },
                    "count": { "type": "integer", "description": "Total number of issues at this location." },
                    "types": {
                      "type": "object",
                      "description": "Issue count per type at this location.",
                      "additionalProperties": { "type": "integer" }
                    },
                    "issueIds": {
                      "type": "array",
                      "description": "List of issue IDs at this location.",
                      "items": { "type": "string" }
                    }
                  }
                }
              }
            }
          }
        }
      },
      "ErrorResponse": {
        "type": "object",
        "required": ["error"],
        "properties": {
          "error": { "type": "string", "description": "Error message." }
        }
      }
    }
  }
}
