@mynameistito/hcc-bin-day 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Tito
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,76 @@
1
+ # hcc-bin-day-api
2
+
3
+ [![CI](https://github.com/mynameistito/hcc-bin-day-api/actions/workflows/ci.yml/badge.svg)](https://github.com/mynameistito/hcc-bin-day-api/actions/workflows/ci.yml)
4
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](./LICENSE)
5
+
6
+ TypeScript client for the Hamilton City Council Fight the Landfill bin-day lookup API.
7
+
8
+ ## What it does
9
+
10
+ - searches Hamilton addresses
11
+ - resolves a street address to a bin collection schedule
12
+ - supports text and JSON output
13
+
14
+ ## API endpoints
15
+
16
+ This client talks to the public Hamilton City Council backend used by the Fight the Landfill page:
17
+
18
+ - `GET /FightTheLandFill/get_Addresses?search_string=...`
19
+ - `GET /FightTheLandFill/get_Collection_Dates?address_string=...`
20
+
21
+ Base URL:
22
+
23
+ ```text
24
+ https://api2.hcc.govt.nz
25
+ ```
26
+
27
+ ## Usage
28
+
29
+ ```bash
30
+ bun run src/index.ts
31
+ bun run src/index.ts search "12 Grey Street"
32
+ bun run src/index.ts lookup "12 Grey Street"
33
+ bun run src/index.ts schedule "12 Grey Street"
34
+ bun run src/index.ts --json lookup "12 Grey Street"
35
+ ```
36
+
37
+ ## Output modes
38
+
39
+ - `search` returns matching addresses
40
+ - `schedule` returns the collection schedule for an exact match
41
+ - `lookup` searches for an exact match and falls back to suggestions
42
+ - `--json` prints structured JSON for scripting
43
+
44
+ ## Project structure
45
+
46
+ ```text
47
+ src/
48
+ hamilton-api.ts
49
+ index.ts
50
+ types.ts
51
+ ```
52
+
53
+ ## Development
54
+
55
+ ```bash
56
+ bun install
57
+ bun run check
58
+ bun run typecheck
59
+ bun run fix
60
+ bun run build
61
+ ```
62
+
63
+ ## Tooling
64
+
65
+ - Type checking: `@typescript/native-preview` via `tsgo`
66
+ - Bundling: `tsdown` targeting Node.js
67
+
68
+ ## Disclaimer
69
+
70
+ This project is unofficial and is not affiliated with, endorsed by, supported by, or associated with Hamilton City Council.
71
+
72
+ It is provided independently as a convenience for accessing publicly available collection data. API behavior, availability, response formats, and returned data may change without notice.
73
+
74
+ ## License
75
+
76
+ MIT
package/dist/index.mjs ADDED
@@ -0,0 +1,301 @@
1
+ //#region src/schedule.ts
2
+ const COLLECTION_DAY_NAMES = [
3
+ "Monday",
4
+ "Tuesday",
5
+ "Wednesday",
6
+ "Thursday",
7
+ "Friday",
8
+ "Saturday",
9
+ "Sunday"
10
+ ];
11
+ const BINS_BY_WEEK = {
12
+ red: ["red bin", "food scraps bin"],
13
+ yellow: [
14
+ "yellow bin",
15
+ "glass crate",
16
+ "food scraps bin"
17
+ ]
18
+ };
19
+ const parseCouncilDate = (value) => value.slice(0, 10);
20
+ const collectionDayName = (day) => {
21
+ const name = COLLECTION_DAY_NAMES[day - 1];
22
+ if (!name) throw new Error(`Invalid collection day: ${day}`);
23
+ return name;
24
+ };
25
+ const buildSchedule = (result) => {
26
+ const redBin = parseCouncilDate(result.RedBin);
27
+ const yellowBin = parseCouncilDate(result.YellowBin);
28
+ const upcomingWeek = redBin < yellowBin ? "red" : "yellow";
29
+ const nextDate = upcomingWeek === "red" ? redBin : yellowBin;
30
+ return {
31
+ address: result.Address,
32
+ collectionDay: result.CollectionDay,
33
+ collectionDayName: collectionDayName(result.CollectionDay),
34
+ collectionWeek: result.CollectionWeek,
35
+ nextCollection: {
36
+ bins: BINS_BY_WEEK[upcomingWeek],
37
+ date: nextDate,
38
+ type: upcomingWeek
39
+ },
40
+ redBin,
41
+ upcomingWeek,
42
+ yellowBin
43
+ };
44
+ };
45
+ const formatWeekLabel = (week) => week === "red" ? "Red week" : "Yellow week";
46
+ const formatScheduleDate = (date) => {
47
+ return (/* @__PURE__ */ new Date(`${date}T12:00:00`)).toLocaleDateString("en-NZ", {
48
+ day: "numeric",
49
+ month: "long",
50
+ weekday: "long",
51
+ year: "numeric"
52
+ });
53
+ };
54
+ const formatScheduleText = (schedule) => {
55
+ const oppositeWeek = schedule.upcomingWeek === "red" ? "yellow" : "red";
56
+ const oppositeDate = schedule.upcomingWeek === "red" ? schedule.yellowBin : schedule.redBin;
57
+ return [
58
+ `${schedule.address} — ${schedule.collectionDayName} collection`,
59
+ `${formatWeekLabel(schedule.upcomingWeek)} (${formatScheduleDate(schedule.nextCollection.date)})`,
60
+ ` Put out: ${schedule.nextCollection.bins.join(", ")}`,
61
+ `${formatWeekLabel(oppositeWeek)} (${formatScheduleDate(oppositeDate)})`,
62
+ ` Put out: ${BINS_BY_WEEK[oppositeWeek].join(", ")}`
63
+ ].join("\n");
64
+ };
65
+ const toScheduleJson = (schedule) => {
66
+ const followingWeek = schedule.upcomingWeek === "red" ? "yellow" : "red";
67
+ const followingDate = schedule.upcomingWeek === "red" ? schedule.yellowBin : schedule.redBin;
68
+ return {
69
+ address: schedule.address,
70
+ collectionDay: schedule.collectionDay,
71
+ collectionDayName: schedule.collectionDayName,
72
+ collectionWeek: schedule.collectionWeek,
73
+ following: {
74
+ bins: [...BINS_BY_WEEK[followingWeek]],
75
+ date: followingDate,
76
+ dateFormatted: formatScheduleDate(followingDate),
77
+ week: followingWeek,
78
+ weekLabel: formatWeekLabel(followingWeek)
79
+ },
80
+ redBin: schedule.redBin,
81
+ upcoming: {
82
+ bins: [...schedule.nextCollection.bins],
83
+ date: schedule.nextCollection.date,
84
+ dateFormatted: formatScheduleDate(schedule.nextCollection.date),
85
+ week: schedule.upcomingWeek,
86
+ weekLabel: formatWeekLabel(schedule.upcomingWeek)
87
+ },
88
+ yellowBin: schedule.yellowBin
89
+ };
90
+ };
91
+
92
+ //#endregion
93
+ //#region src/hamilton-api.ts
94
+ const API_BASE_URL = "https://api2.hcc.govt.nz";
95
+ const fetchJson = async (url) => {
96
+ const response = await fetch(url);
97
+ if (!response.ok) {
98
+ if (response.status === 404) return [];
99
+ throw new Error(`Request failed: ${response.status} ${response.statusText}`);
100
+ }
101
+ return await response.json();
102
+ };
103
+ const searchAddresses = async (searchString) => {
104
+ const url = new URL("/FightTheLandFill/get_Addresses", API_BASE_URL);
105
+ url.searchParams.set("search_string", searchString);
106
+ return (await fetchJson(url.toString())).map((result) => result.Collection_Address);
107
+ };
108
+ const getCollectionSchedule = async (address) => {
109
+ const url = new URL("/FightTheLandFill/get_Collection_Dates", API_BASE_URL);
110
+ url.searchParams.set("address_string", address);
111
+ const [first] = await fetchJson(url.toString());
112
+ if (!first) return null;
113
+ return buildSchedule(first);
114
+ };
115
+
116
+ //#endregion
117
+ //#region src/normalize-address.ts
118
+ const STREET_TYPE_ALIASES = {
119
+ av: "avenue",
120
+ ave: "avenue",
121
+ avenue: "avenue",
122
+ blvd: "boulevard",
123
+ boulevard: "boulevard",
124
+ cct: "circuit",
125
+ circuit: "circuit",
126
+ cl: "close",
127
+ close: "close",
128
+ court: "court",
129
+ cres: "crescent",
130
+ crescent: "crescent",
131
+ ct: "court",
132
+ dr: "drive",
133
+ drive: "drive",
134
+ gr: "grove",
135
+ grove: "grove",
136
+ heights: "heights",
137
+ hts: "heights",
138
+ lane: "lane",
139
+ ln: "lane",
140
+ parade: "parade",
141
+ pde: "parade",
142
+ pl: "place",
143
+ place: "place",
144
+ rd: "road",
145
+ rise: "rise",
146
+ road: "road",
147
+ st: "street",
148
+ street: "street",
149
+ tce: "terrace",
150
+ terrace: "terrace",
151
+ way: "way"
152
+ };
153
+ const collapseWhitespace = (value) => value.trim().replaceAll(/\s+/gu, " ");
154
+ const normalizeUnitSuffix = (value) => value.replaceAll(/\b(?<number>\d+)\s*(?<suffix>[a-zA-Z])\b/gu, (_, number, suffix) => `${number}${suffix.toUpperCase()}`);
155
+ const expandStreetTypes = (value) => {
156
+ return value.split(" ").map((token) => STREET_TYPE_ALIASES[token.toLowerCase()] ?? token).join(" ");
157
+ };
158
+ const normalizeAddress = (address) => {
159
+ return expandStreetTypes(normalizeUnitSuffix(collapseWhitespace(address).toLowerCase()));
160
+ };
161
+ const expandAddressQuery = (query) => {
162
+ return expandStreetTypes(normalizeUnitSuffix(collapseWhitespace(query)));
163
+ };
164
+ const pickMatchingAddress = (query, matches) => {
165
+ const normalizedQuery = normalizeAddress(query);
166
+ const exactMatches = matches.filter((match) => normalizeAddress(match) === normalizedQuery);
167
+ if (exactMatches.length === 1) return exactMatches[0] ?? null;
168
+ return null;
169
+ };
170
+
171
+ //#endregion
172
+ //#region src/address.ts
173
+ const NO_ADDRESS_FOUND = "No address found";
174
+ const filterMatches = (matches) => matches.filter((match) => match !== NO_ADDRESS_FOUND);
175
+ const resolveAddressQuery = async (query) => {
176
+ const expandedQuery = expandAddressQuery(query);
177
+ let matches = filterMatches(await searchAddresses(query));
178
+ if (matches.length === 0 && expandedQuery !== query) matches = filterMatches(await searchAddresses(expandedQuery));
179
+ if (matches.length === 0) return {
180
+ matches: [],
181
+ ok: false
182
+ };
183
+ const matchedAddress = pickMatchingAddress(query, matches);
184
+ if (!matchedAddress) return {
185
+ matches,
186
+ ok: false
187
+ };
188
+ const schedule = await getCollectionSchedule(matchedAddress);
189
+ if (!schedule) return {
190
+ matches,
191
+ ok: false
192
+ };
193
+ return {
194
+ matchedAddress,
195
+ ok: true,
196
+ schedule
197
+ };
198
+ };
199
+
200
+ //#endregion
201
+ //#region src/index.ts
202
+ const TEXT_FLAGS = new Set([
203
+ "--text",
204
+ "--pretty",
205
+ "-p"
206
+ ]);
207
+ const JSON_FLAGS = new Set(["--json", "-j"]);
208
+ const printHelp = () => {
209
+ console.log(`Hamilton bin-day client
210
+
211
+ Usage:
212
+ bun run src/index.ts search <address>
213
+ bun run src/index.ts schedule <address>
214
+ bun run src/index.ts lookup <address>
215
+ bun run src/index.ts schedule <address> --text
216
+
217
+ Examples:
218
+ bun run src/index.ts schedule "14b mountbatten pl"
219
+ bun run src/index.ts schedule "14b mountbatten pl" --text
220
+ bun run src/index.ts lookup "12 grey st"
221
+
222
+ Output is JSON by default. Use --text (or --pretty) for human-readable output.
223
+ Address input is flexible: unit suffixes (14b -> 14B) and street types (pl, st, rd, etc.).
224
+ `);
225
+ };
226
+ const parseArgs = (argv) => {
227
+ const rawArgs = argv.slice(2);
228
+ let textFromFlag = false;
229
+ const args = [];
230
+ for (const arg of rawArgs) if (TEXT_FLAGS.has(arg) || JSON_FLAGS.has(arg)) {
231
+ if (TEXT_FLAGS.has(arg)) textFromFlag = true;
232
+ } else args.push(arg);
233
+ const [command, ...queryParts] = args;
234
+ const query = queryParts.join(" ");
235
+ return {
236
+ command,
237
+ json: !textFromFlag,
238
+ query
239
+ };
240
+ };
241
+ const printJson = (value) => {
242
+ console.log(JSON.stringify(value, null, 2));
243
+ };
244
+ const main = async () => {
245
+ const { json, command, query } = parseArgs(process.argv);
246
+ if (!command || command === "help" || command === "--help" || command === "-h") {
247
+ printHelp();
248
+ return;
249
+ }
250
+ if (!query) {
251
+ printHelp();
252
+ return;
253
+ }
254
+ if (command === "search") {
255
+ const matches = await searchAddresses(query);
256
+ if (json) {
257
+ printJson({
258
+ command,
259
+ matches,
260
+ query
261
+ });
262
+ return;
263
+ }
264
+ console.log("Matches:", matches);
265
+ return;
266
+ }
267
+ if (command === "schedule" || command === "lookup") {
268
+ const resolved = await resolveAddressQuery(query);
269
+ if (!resolved.ok) {
270
+ if (json) {
271
+ printJson({
272
+ command,
273
+ found: false,
274
+ matches: resolved.matches,
275
+ query
276
+ });
277
+ return;
278
+ }
279
+ console.log(`No match found for: ${query}`);
280
+ if (resolved.matches.length > 0) console.log("Did you mean:", resolved.matches.slice(0, 10));
281
+ return;
282
+ }
283
+ if (json) {
284
+ printJson({
285
+ command,
286
+ found: true,
287
+ matchedAddress: resolved.matchedAddress,
288
+ query,
289
+ schedule: toScheduleJson(resolved.schedule)
290
+ });
291
+ return;
292
+ }
293
+ console.log(formatScheduleText(resolved.schedule));
294
+ return;
295
+ }
296
+ printHelp();
297
+ };
298
+ await main();
299
+
300
+ //#endregion
301
+ export { };
package/package.json ADDED
@@ -0,0 +1,51 @@
1
+ {
2
+ "name": "@mynameistito/hcc-bin-day",
3
+ "version": "0.1.0",
4
+ "description": "TypeScript client for Hamilton City Council Fight the Landfill bin-day lookup API.",
5
+ "keywords": [
6
+ "api",
7
+ "bin-day",
8
+ "hamilton",
9
+ "hamilton-city-council",
10
+ "typescript",
11
+ "waste"
12
+ ],
13
+ "homepage": "https://github.com/mynameistito/hcc-bin-day#readme",
14
+ "bugs": {
15
+ "url": "https://github.com/mynameistito/hcc-bin-day/issues"
16
+ },
17
+ "license": "MIT",
18
+ "author": "Tito <contact@mynameistito.com>",
19
+ "repository": {
20
+ "type": "git",
21
+ "url": "git+https://github.com/mynameistito/hcc-bin-day.git"
22
+ },
23
+ "bin": {
24
+ "hcc-bin-day-api": "dist/src/index.js"
25
+ },
26
+ "files": [
27
+ "dist",
28
+ "README.md",
29
+ "LICENSE"
30
+ ],
31
+ "type": "module",
32
+ "main": "./dist/src/index.js",
33
+ "scripts": {
34
+ "build": "tsdown",
35
+ "dev": "bun run src/index.ts",
36
+ "check": "ultracite check",
37
+ "fix": "ultracite fix",
38
+ "typecheck": "tsgo --noEmit",
39
+ "knip": "bunx knip@latest",
40
+ "ci": "bun run check && bun run typecheck && bun run build",
41
+ "prepare": "lefthook install"
42
+ },
43
+ "devDependencies": {
44
+ "@typescript/native-preview": "^7.0.0-dev.20260624.1",
45
+ "lefthook": "^2.1.9",
46
+ "oxfmt": "^0.56.0",
47
+ "oxlint": "^1.71.0",
48
+ "tsdown": "0.16.0",
49
+ "ultracite": "7.8.3"
50
+ }
51
+ }