Back
GuidesArea Development ReportingTracking Grünerløkka's district trend
Node.js
NOR

Tracking Grünerløkka's district trend

This example shows how to run a standing measurement of a city district, the continuous lens of Area Development Reporting: the scorecard a district consortium or asset owner refreshes every quarter, with a benchmark, a rhythm read, and a visit-quality line. The subject is Grünerløkka, Oslo's best-known urban district.

For this we use our Norwegian neighborhood metrics: daily visitation with the resident, worker and visitor split, and daily visit-length distributions, both resolved by district name. Data runs from January 2023 to five days behind today; this run uses everything through 25 August 2026. The queries are written in Node.js 20 or later, with two small helpers and no dependencies:

const API = "https://api.pine59.com/v3/datasets";

async function runQuery(dataset, body) {
  const r = await fetch(`${API}/${dataset}:runQuery`, {
    method: "POST",
    headers: {
      Authorization: `Bearer ${process.env.PINE59_API_KEY}`,
      "Content-Type": "application/json",
    },
    body: JSON.stringify(body),
  });
  if (!r.ok) throw new Error(`${r.status} ${await r.text()}`);
  return ((await r.json()).records ?? []).map((rec) => rec.fields);
}

const groupBy = (rows, key) =>
  rows.reduce((acc, row) => ((acc[key(row)] ??= []).push(row), acc), {});

1. The district series

Norwegian neighborhoods resolve directly by name, so there is no location lookup step: location_name plus municipality is the filter. One call returns the whole daily history for the district, about 1,300 rows:

const DISTRICT = [
  { fieldName: "location_name", operator: "==", value: "Grünerløkka" },
  { fieldName: "municipality",  operator: "==", value: "Oslo" },
];

const days = await runQuery("pl_telco_nor_neighborhoods_al3.dl_telco_foot_traffic_1", {
  fields: [
    { name: "observation_start_date" },
    { name: "visit_count" },
    { name: "resident_visit_fraction" },
    { name: "worker_visit_fraction" },
    { name: "non_resident_non_worker_visit_fraction" },
  ],
  filters: DISTRICT,
  options: { orderBy: { fieldName: "observation_start_date", direction: "ASC" } },
  pageSize: 2000,
});
console.log(days.length, "days,", days[0].observation_start_date, "to", days.at(-1).observation_start_date);

Every row is one day with the visit count and three shares: visits by residents of the district, by people who work there, and by everyone else. That last share is the destination share, the visitors who neither live nor work in the district and chose to come. The rest of the example is transforms on this one array.

2. The scorecard

The report's first lines are yearly: average daily visits and the activity mix. Group the days by year and average:

const mean = (xs) => xs.reduce((a, b) => a + b, 0) / xs.length;
const byYear = groupBy(days, (d) => d.observation_start_date.slice(0, 4));

console.table(Object.entries(byYear).map(([year, rows]) => ({
  year,
  days: rows.length,
  avgDailyVisits: Math.round(mean(rows.map((r) => +r.visit_count))),
  destinationShare: mean(rows.map((r) => +r.non_resident_non_worker_visit_fraction)).toFixed(3),
  residentShare:    mean(rows.map((r) => +r.resident_visit_fraction)).toFixed(3),
  workerShare:      mean(rows.map((r) => +r.worker_visit_fraction)).toFixed(3),
})));
YearAvg daily visitsDestination shareResident shareWorker share
202333.6k52.2%41.4%16.2%
202432.6k50.4%43.1%16.0%
202531.5k51.4%42.3%15.8%
2026 (to 25 Aug)33.6k53.7%40.8%15.2%

How to read it: two soft years, then a turn. 2026 is back at the 2023 level, and the destination share is at a four-year high: more of the district's activity is people who chose to come.

The three shares are daily averages of each fraction and sum to about 110%, not 100%. The roles overlap: a person who both lives and works in the district is counted as a resident and as a worker. Read each share on its own against its own history rather than as a split of the total.

3. Development against a benchmark

A district's change only means something against the city it sits in. The benchmark is the same dataset with the district filter replaced by the municipality and the visit count summed per day across all of Oslo's 60 districts. Compare identical months, January to July, so the partial 2026 compares cleanly:

const oslo = await runQuery("pl_telco_nor_neighborhoods_al3.dl_telco_foot_traffic_1", {
  fields: [
    { name: "observation_start_date" },
    { name: "visit_count", aggregation: "SUM" },
  ],
  filters: [{ fieldName: "municipality", operator: "==", value: "Oslo" }],
  groupBy: [{ fieldName: "observation_start_date" }],
  pageSize: 2000,
});

const janJul = (rows, year, key) => rows
  .filter((r) => r.observation_start_date.startsWith(year) && +r.observation_start_date.slice(5, 7) <= 7)
  .reduce((sum, r) => sum + +r[key], 0);
const change = (rows, y0, y1, key) =>
  ((janJul(rows, y1, key) / janJul(rows, y0, key) - 1) * 100).toFixed(1) + "%";

console.table(["2024", "2025", "2026"].map((y) => ({
  window: `Jan–Jul ${y} vs ${y - 1}`,
  district: change(days, String(y - 1), y, "visit_count"),
  oslo:     change(oslo, String(y - 1), y, "visit_count_SUM"),
})));
WindowGrünerløkkaOslo, all 60 districts
Jan–Jul 2024 vs 2023−2.8%−0.8%
Jan–Jul 2025 vs 2024−5.1%−1.6%
Jan–Jul 2026 vs 2025+7.8%−0.1%

How to read it: this is the headline line of the report, and the benchmark is what makes it one. The two soft years were partly the district's own: Oslo drifted down a point or two while Grünerløkka lost three and five. The 2026 recovery is entirely the district's: +7.8% on identical months against a flat city.

4. Rhythm

The same array, cut two more ways, tells the consortium what kind of activity it is measuring. Average daily visits per month, with the years side by side, plus two ratios:

const byMonth = groupBy(days, (d) => d.observation_start_date.slice(0, 7));
const monthly = Object.entries(byMonth).map(([ym, rows]) => ({
  year: ym.slice(0, 4), month: +ym.slice(5), avgDaily: Math.round(mean(rows.map((r) => +r.visit_count))),
}));
console.table(Object.values(groupBy(monthly, (m) => m.month)).map((ms) =>
  Object.fromEntries([["month", ms[0].month], ...ms.map((m) => [m.year, m.avgDaily])])));

const y25 = days.filter((d) => d.observation_start_date.startsWith("2025"));
const weekend = (d) => [0, 6].includes(new Date(d.observation_start_date).getUTCDay());
const avg = (rows) => mean(rows.map((r) => +r.visit_count));
console.log("weekend / weekday:", (avg(y25.filter(weekend)) / avg(y25.filter((d) => !weekend(d)))).toFixed(2));
console.log("summer index:", (avg(y25.filter((d) => ["06", "07", "08"].includes(d.observation_start_date.slice(5, 7)))) / avg(y25)).toFixed(2));

Average daily visits by month (thousands):

Month202420252026
Jan30.729.831.9
Feb31.931.632.4
Mar31.833.634.3
Apr34.031.234.1
May35.733.936.3
Jun36.131.636.3
Jul26.424.228.6
Aug32.733.035.0
Sep34.932.7
Oct34.032.5
Nov34.133.1
Dec29.030.6

How to read it: 2026 runs above both prior years in every month so far. The July trough is the Norwegian holiday month, the same every year. In 2025 weekends ran only 3% above weekdays (ratio 1.03) and the summer months at 94% of the annual average. Grünerløkka's activity is an all-week, all-year pattern rather than an event economy, which is itself a stability argument for a prospective tenant.

5. Visit quality

Visit length separates pass-through from stay. dl_telco_visit_length_1 uses the same name filter and gives a daily median plus four duration buckets:

const lengths = await runQuery("pl_telco_nor_neighborhoods_al3.dl_telco_visit_length_1", {
  fields: [
    { name: "observation_start_date" },
    { name: "median_visit_length" },
    { name: "quick_visit_fraction" },
    { name: "long_visit_fraction" },
  ],
  filters: DISTRICT,
  pageSize: 2000,
});
const median = (xs) => xs.toSorted((a, b) => a - b)[Math.floor(xs.length / 2)];
console.table(Object.entries(groupBy(lengths, (d) => d.observation_start_date.slice(0, 4)))
  .map(([year, rows]) => ({
    year,
    medianMinutes: median(rows.map((r) => +r.median_visit_length)),
    longShare:  mean(rows.map((r) => +r.long_visit_fraction)).toFixed(3),
    quickShare: mean(rows.map((r) => +r.quick_visit_fraction)).toFixed(3),
  })));
YearMedian visit (minutes)Long-visit shareQuick-visit share
202310137.7%2.1%
202410438.9%2.3%
202510338.3%2.2%
2026 (to 25 Aug)10237.6%2.0%

How to read it: for this district the finding is the stability itself. The 2025 dip and the 2026 recovery happened in how many people came, not in how long they stayed: the median visit is 101 to 104 minutes in every year and the long-stay share does not move. That points the 2025 investigation at reach, not experience.

6. The report

Assemble the lines into the same short format every period:

Grünerløkka, Jan–Jul 2026: visits +7.8% year over year on identical months against a flat Oslo (−0.1%), reversing two soft years. Destination share 53.7%, the highest in four years of measurement. Median visit unchanged at about 100 minutes with the long-stay share steady; the recovery is in reach, not visit quality. All-week, all-year activity pattern.

The report works because it repeats: same windows, same benchmark, same lines next quarter. When a line moves and the consortium asks why, the natural follow-up is a void analysis of what the district's audience still lacks.

Datasets used

Dl Telco Foot Traffic 1
Reference data
Dl Telco Visit Length 1
Reference data
Did you find what you were looking for?