A website for music events across the Boston area, specifically Porchfest. Also see http://porchfest.info porchfest.at/
0

Configure Feed

Select the types of activity you want to include in your feed.

Add Swampscott Porchfest 2026 (36 bands, hourly zones)

- Scrape from https://swampscottporchfest.org/map/ using Pattern B (JS object dump)
- 36 scheduled bands (5 unsolved gigs skipped), hourly zone buckets 12-5PM
- City boundary GeoJSON from Nominatim
- Add to City union type, cityConfig.ts, components.tsx, vite.config.js
- Unit + E2E tests for swampscott

Tim Ryan (May 19, 2026, 11:29 PM EDT) 548e765e 2306ed23

+2358 -13
+557
data/swampscott/bands.json
··· 1 + [ 2 + { 3 + "title": "Allycats", 4 + "start_time": "2026-05-31 16:00:00", 5 + "end_time": "2026-05-31 17:00:00", 6 + "latitude": "42.47332505", 7 + "longitude": "-70.91293762123883", 8 + "street": "71 Walker Road", 9 + "grid": "", 10 + "genres": [ 11 + "Rock" 12 + ], 13 + "porch_zone": "16", 14 + "field_support_the_band_value": "", 15 + "is_withdrawn": "false" 16 + }, 17 + { 18 + "title": "Atch Amour", 19 + "start_time": "2026-05-31 16:00:00", 20 + "end_time": "2026-05-31 18:00:00", 21 + "latitude": "42.47052835", 22 + "longitude": "-70.9141010376832", 23 + "street": "15 Sheridan Road", 24 + "grid": "", 25 + "genres": [ 26 + "rock and rnb" 27 + ], 28 + "porch_zone": "16", 29 + "field_support_the_band_value": "", 30 + "is_withdrawn": "false" 31 + }, 32 + { 33 + "title": "Billy Goodrich", 34 + "start_time": "2026-05-31 14:30:00", 35 + "end_time": "2026-05-31 15:30:00", 36 + "latitude": "42.473492750000005", 37 + "longitude": "-70.91428980304721", 38 + "street": "38 Grant Road", 39 + "grid": "", 40 + "genres": [ 41 + "Country/Bluegrass", 42 + "Acoustic Solo" 43 + ], 44 + "porch_zone": "14", 45 + "field_support_the_band_value": "https://tr.ee/rZfFHDI_Ug", 46 + "is_withdrawn": "false" 47 + }, 48 + { 49 + "title": "Birdface", 50 + "start_time": "2026-05-31 16:00:00", 51 + "end_time": "2026-05-31 17:30:00", 52 + "latitude": "42.46636475", 53 + "longitude": "-70.89412959710208", 54 + "street": "24 Winshaw Road", 55 + "grid": "", 56 + "genres": [ 57 + "Rock", 58 + "Funk", 59 + "Rock", 60 + "and Jam" 61 + ], 62 + "porch_zone": "16", 63 + "field_support_the_band_value": "https://www.facebook.com/birdfaced/", 64 + "is_withdrawn": "false" 65 + }, 66 + { 67 + "title": "Blue Big/Little Band", 68 + "start_time": "2026-05-31 12:00:00", 69 + "end_time": "2026-05-31 13:00:00", 70 + "latitude": "42.469027", 71 + "longitude": "-70.916976", 72 + "street": "Linscott Park", 73 + "grid": "", 74 + "genres": [ 75 + "Jazz/Swing" 76 + ], 77 + "porch_zone": "12", 78 + "field_support_the_band_value": "", 79 + "is_withdrawn": "false" 80 + }, 81 + { 82 + "title": "Colorado Bob", 83 + "start_time": "2026-05-31 14:00:00", 84 + "end_time": "2026-05-31 15:30:00", 85 + "latitude": "42.47032245", 86 + "longitude": "-70.9164730529814", 87 + "street": "57 Monument Avenue", 88 + "grid": "", 89 + "genres": [ 90 + "Country/Bluegrass", 91 + "country blues" 92 + ], 93 + "porch_zone": "14", 94 + "field_support_the_band_value": "https://bobbykramer.weebly.com", 95 + "is_withdrawn": "false" 96 + }, 97 + { 98 + "title": "Community Drum Circle with Philip Alexander", 99 + "start_time": "2026-05-31 14:00:00", 100 + "end_time": "2026-05-31 15:00:00", 101 + "latitude": "42.4696128", 102 + "longitude": "-70.91830994134898", 103 + "street": "Swampscott Town Hall/Elihu Thompson House", 104 + "grid": "", 105 + "genres": [ 106 + "World" 107 + ], 108 + "porch_zone": "14", 109 + "field_support_the_band_value": "https://www.PhilipAlexanderMusic.com", 110 + "is_withdrawn": "false" 111 + }, 112 + { 113 + "title": "Conor Shanahan & Will Todisco", 114 + "start_time": "2026-05-31 17:00:00", 115 + "end_time": "2026-05-31 18:00:00", 116 + "latitude": "42.4710696", 117 + "longitude": "-70.92266245035253", 118 + "street": "3 Boynton Street", 119 + "grid": "", 120 + "genres": [ 121 + "Acoustic Duo" 122 + ], 123 + "porch_zone": "17", 124 + "field_support_the_band_value": "", 125 + "is_withdrawn": "false" 126 + }, 127 + { 128 + "title": "Digging for Shovels", 129 + "start_time": "2026-05-31 15:00:00", 130 + "end_time": "2026-05-31 16:30:00", 131 + "latitude": "42.469598149999996", 132 + "longitude": "-70.91649114379811", 133 + "street": "106 Elmwood Road", 134 + "grid": "", 135 + "genres": [ 136 + "Rock/Reggae/Hip-hop" 137 + ], 138 + "porch_zone": "15", 139 + "field_support_the_band_value": "https://rich-pin-music.github.io/", 140 + "is_withdrawn": "false" 141 + }, 142 + { 143 + "title": "Familiar Strangers ", 144 + "start_time": "2026-05-31 16:00:00", 145 + "end_time": "2026-05-31 18:00:00", 146 + "latitude": "42.47387", 147 + "longitude": "-70.914947", 148 + "street": "87 Farragut Road", 149 + "grid": "", 150 + "genres": [ 151 + "Country/Bluegrass" 152 + ], 153 + "porch_zone": "16", 154 + "field_support_the_band_value": "https://Familiarstrangersband.com", 155 + "is_withdrawn": "false" 156 + }, 157 + { 158 + "title": "John Coleman Bennett", 159 + "start_time": "2026-05-31 16:00:00", 160 + "end_time": "2026-05-31 16:30:00", 161 + "latitude": "42.468859", 162 + "longitude": "-70.92221", 163 + "street": "33 Kings Beach Terrace", 164 + "grid": "", 165 + "genres": [ 166 + "Folk" 167 + ], 168 + "porch_zone": "16", 169 + "field_support_the_band_value": "https://www.johncolemanbennett.com/", 170 + "is_withdrawn": "false" 171 + }, 172 + { 173 + "title": "Marc Maccini", 174 + "start_time": "2026-05-31 16:00:00", 175 + "end_time": "2026-05-31 18:00:00", 176 + "latitude": "42.464982500000005", 177 + "longitude": "-70.9058485290796", 178 + "street": "120 Puritan Road", 179 + "grid": "", 180 + "genres": [ 181 + "Singer/Songwriter" 182 + ], 183 + "porch_zone": "16", 184 + "field_support_the_band_value": "https://MarcMaccini.com", 185 + "is_withdrawn": "false" 186 + }, 187 + { 188 + "title": "Mike Brison", 189 + "start_time": "2026-05-31 13:30:00", 190 + "end_time": "2026-05-31 14:30:00", 191 + "latitude": "42.470493", 192 + "longitude": "-70.915261", 193 + "street": "144 Elmwood Road", 194 + "grid": "", 195 + "genres": [ 196 + "mix of rock", 197 + "blues", 198 + "originals" 199 + ], 200 + "porch_zone": "13", 201 + "field_support_the_band_value": "", 202 + "is_withdrawn": "false" 203 + }, 204 + { 205 + "title": "Molly Pinto Madigan Band ", 206 + "start_time": "2026-05-31 16:30:00", 207 + "end_time": "2026-05-31 18:00:00", 208 + "latitude": "42.47090095", 209 + "longitude": "-70.91566798314976", 210 + "street": "85 Monument Avenue", 211 + "grid": "", 212 + "genres": [ 213 + "Singer/Songwriter", 214 + "Indie", 215 + "folk/rock" 216 + ], 217 + "porch_zone": "16", 218 + "field_support_the_band_value": "https://www.mollypintomadigan.com", 219 + "is_withdrawn": "false" 220 + }, 221 + { 222 + "title": "Moretti Family Band", 223 + "start_time": "2026-05-31 15:00:00", 224 + "end_time": "2026-05-31 16:00:00", 225 + "latitude": "42.47375455", 226 + "longitude": "-70.91304894952589", 227 + "street": "48 Banks Road", 228 + "grid": "", 229 + "genres": [ 230 + "Rock" 231 + ], 232 + "porch_zone": "15", 233 + "field_support_the_band_value": "https://youtu.be/KADLlYa26D8?si=Ed2UUzHfVSsj75yz", 234 + "is_withdrawn": "false" 235 + }, 236 + { 237 + "title": "Music with Ms. Alex", 238 + "start_time": "2026-05-31 15:00:00", 239 + "end_time": "2026-05-31 16:00:00", 240 + "latitude": "42.4696128", 241 + "longitude": "-70.91830994134898", 242 + "street": "Swampscott Town Hall/Elihu Thompson House", 243 + "grid": "", 244 + "genres": "", 245 + "porch_zone": "15", 246 + "field_support_the_band_value": "https://linktr.ee/alexandrakirby", 247 + "is_withdrawn": "false" 248 + }, 249 + { 250 + "title": "North Shore Revival", 251 + "start_time": "2026-05-31 16:30:00", 252 + "end_time": "2026-05-31 17:30:00", 253 + "latitude": "42.468859", 254 + "longitude": "-70.92221", 255 + "street": "33 Kings Beach Terrace", 256 + "grid": "", 257 + "genres": [ 258 + "Rock", 259 + "Full band" 260 + ], 261 + "porch_zone": "16", 262 + "field_support_the_band_value": "https://www.johncolemanbennett.com", 263 + "is_withdrawn": "false" 264 + }, 265 + { 266 + "title": "Not Bothered ", 267 + "start_time": "2026-05-31 14:00:00", 268 + "end_time": "2026-05-31 15:00:00", 269 + "latitude": "42.46636475", 270 + "longitude": "-70.89412959710208", 271 + "street": "24 Winshaw Road", 272 + "grid": "", 273 + "genres": [ 274 + "Acoustic rock" 275 + ], 276 + "porch_zone": "14", 277 + "field_support_the_band_value": "https://www.facebook.com/share/1G9SsRyQQA/", 278 + "is_withdrawn": "false" 279 + }, 280 + { 281 + "title": "One and Friends", 282 + "start_time": "2026-05-31 16:00:00", 283 + "end_time": "2026-05-31 17:00:00", 284 + "latitude": "42.47020565", 285 + "longitude": "-70.91756698483393", 286 + "street": "First Congregational Church of Swampscott", 287 + "grid": "", 288 + "genres": [ 289 + "Folk" 290 + ], 291 + "porch_zone": "16", 292 + "field_support_the_band_value": "", 293 + "is_withdrawn": "false" 294 + }, 295 + { 296 + "title": "Paraphernalia", 297 + "start_time": "2026-05-31 16:00:00", 298 + "end_time": "2026-05-31 16:45:00", 299 + "latitude": "42.4703887", 300 + "longitude": "-70.91086594454615", 301 + "street": "79 Greenwood Avenue", 302 + "grid": "", 303 + "genres": [ 304 + "Indie" 305 + ], 306 + "porch_zone": "16", 307 + "field_support_the_band_value": "", 308 + "is_withdrawn": "false" 309 + }, 310 + { 311 + "title": "Porch Pirates", 312 + "start_time": "2026-05-31 14:00:00", 313 + "end_time": "2026-05-31 15:00:00", 314 + "latitude": "42.47462315", 315 + "longitude": "-70.91432986844353", 316 + "street": "12 Banks Road", 317 + "grid": "", 318 + "genres": [ 319 + "Sea Shanties" 320 + ], 321 + "porch_zone": "14", 322 + "field_support_the_band_value": "", 323 + "is_withdrawn": "false" 324 + }, 325 + { 326 + "title": "Red Rock Ramblers", 327 + "start_time": "2026-05-31 13:30:00", 328 + "end_time": "2026-05-31 14:00:00", 329 + "latitude": "42.4695161", 330 + "longitude": "-70.91912984965366", 331 + "street": "Swampscott Public Library", 332 + "grid": "", 333 + "genres": [ 334 + "Mixture of styles", 335 + "typically popular songs that are more on the folk/singer songwriter genre than anything else." 336 + ], 337 + "porch_zone": "13", 338 + "field_support_the_band_value": "", 339 + "is_withdrawn": "false" 340 + }, 341 + { 342 + "title": "Silent Ground", 343 + "start_time": "2026-05-31 14:00:00", 344 + "end_time": "2026-05-31 15:00:00", 345 + "latitude": "42.466303", 346 + "longitude": "-70.905742", 347 + "street": "86 Puritan Road", 348 + "grid": "", 349 + "genres": [ 350 + "Rock" 351 + ], 352 + "porch_zone": "14", 353 + "field_support_the_band_value": "", 354 + "is_withdrawn": "false" 355 + }, 356 + { 357 + "title": "Steve Tapper and Shira Moss - Flute and Percussion Duo", 358 + "start_time": "2026-05-31 17:00:00", 359 + "end_time": "2026-05-31 18:00:00", 360 + "latitude": "42.470617399999995", 361 + "longitude": "-70.91051454173774", 362 + "street": "19 Greenwood Terrace", 363 + "grid": "", 364 + "genres": [ 365 + "World" 366 + ], 367 + "porch_zone": "17", 368 + "field_support_the_band_value": "https://m.facebook.com/profile.php?id=100087570474291", 369 + "is_withdrawn": "false" 370 + }, 371 + { 372 + "title": "Summer Pierce", 373 + "start_time": "2026-05-31 15:30:00", 374 + "end_time": "2026-05-31 16:30:00", 375 + "latitude": "42.473492750000005", 376 + "longitude": "-70.91428980304721", 377 + "street": "38 Grant Road", 378 + "grid": "", 379 + "genres": [ 380 + "A bit of everything", 381 + "mostly pop/indie" 382 + ], 383 + "porch_zone": "15", 384 + "field_support_the_band_value": "", 385 + "is_withdrawn": "false" 386 + }, 387 + { 388 + "title": "The B-Side Hustle", 389 + "start_time": "2026-05-31 13:00:00", 390 + "end_time": "2026-05-31 14:00:00", 391 + "latitude": "42.469598149999996", 392 + "longitude": "-70.91649114379811", 393 + "street": "106 Elmwood Road", 394 + "grid": "", 395 + "genres": [ 396 + "rhythm & soul" 397 + ], 398 + "porch_zone": "13", 399 + "field_support_the_band_value": "https://thebsidehustle.com", 400 + "is_withdrawn": "false" 401 + }, 402 + { 403 + "title": "The Band With A View", 404 + "start_time": "2026-05-31 15:00:00", 405 + "end_time": "2026-05-31 16:00:00", 406 + "latitude": "42.47674465", 407 + "longitude": "-70.91984865", 408 + "street": "153 Essex Street", 409 + "grid": "", 410 + "genres": [ 411 + "Pop" 412 + ], 413 + "porch_zone": "15", 414 + "field_support_the_band_value": "https://web.media.mit.edu/~lieber/BwaV/Cambridge%20Porch%20Fest%20-%20Band%20with%20a%20View.mp3", 415 + "is_withdrawn": "false" 416 + }, 417 + { 418 + "title": "The Five Year Plan", 419 + "start_time": "2026-05-31 15:00:00", 420 + "end_time": "2026-05-31 16:00:00", 421 + "latitude": "42.47387", 422 + "longitude": "-70.914947", 423 + "street": "87 Farragut Road", 424 + "grid": "", 425 + "genres": [ 426 + "Americana" 427 + ], 428 + "porch_zone": "15", 429 + "field_support_the_band_value": "", 430 + "is_withdrawn": "false" 431 + }, 432 + { 433 + "title": "The Kevin Farren Band", 434 + "start_time": "2026-05-31 15:00:00", 435 + "end_time": "2026-05-31 16:00:00", 436 + "latitude": "42.46636475", 437 + "longitude": "-70.89412959710208", 438 + "street": "24 Winshaw Road", 439 + "grid": "", 440 + "genres": [ 441 + "Americana", 442 + "Classic Rock and Americana" 443 + ], 444 + "porch_zone": "15", 445 + "field_support_the_band_value": "https://open.spotify.com/artist/2GYyvrvbnFiknwZV2FDCeA?si=5zIrnDqbS8arfiJgDPOgPA", 446 + "is_withdrawn": "false" 447 + }, 448 + { 449 + "title": "The King Serpent Variety Troupe", 450 + "start_time": "2026-05-31 13:00:00", 451 + "end_time": "2026-05-31 14:00:00", 452 + "latitude": "42.4696128", 453 + "longitude": "-70.91830994134898", 454 + "street": "Swampscott Town Hall/Elihu Thompson House", 455 + "grid": "", 456 + "genres": [ 457 + "Folk", 458 + "Sea Chanties", 459 + "Pirate Songs" 460 + ], 461 + "porch_zone": "13", 462 + "field_support_the_band_value": "http://linktree.com/kingserpentvariety", 463 + "is_withdrawn": "false" 464 + }, 465 + { 466 + "title": "The Lucky Ducks", 467 + "start_time": "2026-05-31 14:00:00", 468 + "end_time": "2026-05-31 15:00:00", 469 + "latitude": "42.47340505", 470 + "longitude": "-70.91370014314452", 471 + "street": "68 Walker Road", 472 + "grid": "", 473 + "genres": [ 474 + "Pop" 475 + ], 476 + "porch_zone": "14", 477 + "field_support_the_band_value": "https://m.youtube.com/watch?v=cH5RHb38AKw", 478 + "is_withdrawn": "false" 479 + }, 480 + { 481 + "title": "The Malingerers", 482 + "start_time": "2026-05-31 15:00:00", 483 + "end_time": "2026-05-31 16:00:00", 484 + "latitude": "42.470493", 485 + "longitude": "-70.915261", 486 + "street": "144 Elmwood Road", 487 + "grid": "", 488 + "genres": [ 489 + "Alternative", 490 + "College Rock" 491 + ], 492 + "porch_zone": "15", 493 + "field_support_the_band_value": "https://themalingerers.bandcamp.com/", 494 + "is_withdrawn": "false" 495 + }, 496 + { 497 + "title": "The Mayflys", 498 + "start_time": "2026-05-31 13:00:00", 499 + "end_time": "2026-05-31 15:00:00", 500 + "latitude": "42.4725891", 501 + "longitude": "-70.924266", 502 + "street": "44 Duncan Terrace", 503 + "grid": "", 504 + "genres": [ 505 + "Folk" 506 + ], 507 + "porch_zone": "13", 508 + "field_support_the_band_value": "", 509 + "is_withdrawn": "false" 510 + }, 511 + { 512 + "title": "The Pleasing Terrors", 513 + "start_time": "2026-05-31 17:00:00", 514 + "end_time": "2026-05-31 18:00:00", 515 + "latitude": "42.4735746", 516 + "longitude": "-70.91340231210111", 517 + "street": "72 Walker Road", 518 + "grid": "", 519 + "genres": [ 520 + "Rock" 521 + ], 522 + "porch_zone": "17", 523 + "field_support_the_band_value": "https://m.facebook.com/p/The-Pleasing-Terrors-61571457764456/", 524 + "is_withdrawn": "false" 525 + }, 526 + { 527 + "title": "Tin Box Poets", 528 + "start_time": "2026-05-31 13:00:00", 529 + "end_time": "2026-05-31 15:00:00", 530 + "latitude": "42.4683104", 531 + "longitude": "-70.9196659", 532 + "street": "64 Humphrey Street", 533 + "grid": "", 534 + "genres": [ 535 + "Poetry/Spoken Word" 536 + ], 537 + "porch_zone": "13", 538 + "field_support_the_band_value": "", 539 + "is_withdrawn": "false" 540 + }, 541 + { 542 + "title": "Zosia", 543 + "start_time": "2026-05-31 13:30:00", 544 + "end_time": "2026-05-31 14:30:00", 545 + "latitude": "42.47387", 546 + "longitude": "-70.914947", 547 + "street": "87 Farragut Road", 548 + "grid": "", 549 + "genres": [ 550 + "Singer/Songwriter", 551 + "folky acoustic pop!" 552 + ], 553 + "porch_zone": "13", 554 + "field_support_the_band_value": "https://@soph.amidleafygreens", 555 + "is_withdrawn": "false" 556 + } 557 + ]
+1
data/swampscott/city.geojson
··· 1 + {"type":"FeatureCollection","licence":"Data © OpenStreetMap contributors, ODbL 1.0. http://osm.org/copyright","features":[{"type":"Feature","properties":{"place_id":361180369,"osm_type":"relation","osm_id":2373034,"place_rank":16,"category":"boundary","type":"administrative","importance":0.44297043555740573,"addresstype":"town","name":"Swampscott","display_name":"Swampscott, Essex County, Massachusetts, 01907, United States"},"bbox":[-70.9341110,42.4166957,-70.8007974,42.4908413],"geometry":{"type":"Polygon","coordinates":[[[-70.934111,42.4908413],[-70.9340153,42.4906513],[-70.9339152,42.490453],[-70.9337431,42.4901122],[-70.9333849,42.4894028],[-70.9314401,42.4855511],[-70.9304467,42.4835824],[-70.9302915,42.4832746],[-70.9302812,42.4832543],[-70.9293683,42.481445],[-70.929277,42.4812639],[-70.9290327,42.4807798],[-70.9288876,42.4804921],[-70.9223333,42.4675],[-70.9222214,42.4672853],[-70.9216076,42.4661074],[-70.9214153,42.4657384],[-70.8976725,42.4478452],[-70.8154905,42.4166957],[-70.8007974,42.4295159],[-70.8793784,42.4786741],[-70.8794777,42.4787362],[-70.8795564,42.4787617],[-70.8797159,42.4788132],[-70.8799344,42.4788795],[-70.899033,42.4846717],[-70.8995249,42.4847763],[-70.899927,42.4848476],[-70.9005493,42.4850398],[-70.9009749,42.4852093],[-70.9013228,42.4853093],[-70.9015385,42.4853504],[-70.9017791,42.4853589],[-70.902197,42.4853248],[-70.9023792,42.4852821],[-70.9026482,42.4852033],[-70.9033528,42.4850681],[-70.9047557,42.4848907],[-70.9050902,42.4848379],[-70.9053758,42.4847594],[-70.9057688,42.4846005],[-70.9065398,42.4842637],[-70.9066083,42.4842838],[-70.906672,42.4843024],[-70.9123468,42.4856531],[-70.9124502,42.4856778],[-70.9130692,42.4858255],[-70.9133956,42.4859033],[-70.9216207,42.4878653],[-70.9296305,42.4897759],[-70.9312384,42.4901582],[-70.9322353,42.4903953],[-70.9329409,42.4905631],[-70.9330396,42.4905865],[-70.934111,42.4908413]]]}}]}
+274
data/swampscott/scraper.js
··· 1 + const https = require("node:https"); 2 + const fs = require("node:fs"); 3 + const vm = require("node:vm"); 4 + const path = require("node:path"); 5 + 6 + const TARGET_DIR = "data"; 7 + const URL = "https://swampscottporchfest.org/map/"; 8 + 9 + const HOURLY_ZONES = { 10 + "12": "12PM", 11 + "13": "1PM", 12 + "14": "2PM", 13 + "15": "3PM", 14 + "16": "4PM", 15 + "17": "5PM", 16 + }; 17 + 18 + https 19 + .get(URL, { rejectUnauthorized: false }, (res) => { 20 + let html = ""; 21 + res.on("data", (c) => (html += c)); 22 + res.on("end", () => { 23 + const reBands = /const bands\s*=\s*\{[\s\S]*?\};?\s*\n/; 24 + const rePorches = /const porches\s*=\s*\{[\s\S]*?\};?\s*\n/; 25 + const reGigs = /const gigs\s*=\s*\{[\s\S]*?\};?\s*\n/; 26 + const reImages = /var images\s*=\s*\{[\s\S]*?\};?\s*\n/; 27 + 28 + const bandsRaw = (html.match(reBands) || [])[0] || ""; 29 + const porchesRaw = (html.match(rePorches) || [])[0] || ""; 30 + const gigsRaw = (html.match(reGigs) || [])[0] || ""; 31 + const imagesRaw = (html.match(reImages) || [])[0] || ""; 32 + 33 + function parseObj(raw) { 34 + if (!raw) return {}; 35 + const s = raw 36 + .replace(/^const (\w+) = /, "") 37 + .replace(/^var (\w+) = /, "") 38 + .replace(/;\s*$/, ""); 39 + if (!s.endsWith("}")) s += "}"; 40 + const ctx = { __r: null }; 41 + vm.runInNewContext(`__r = ${s}`, ctx); 42 + return ctx.__r; 43 + } 44 + 45 + const bands = parseObj(bandsRaw); 46 + const porches = parseObj(porchesRaw); 47 + const gigs = parseObj(gigsRaw); 48 + const images = parseObj(imagesRaw); 49 + const porchimages = parseObj( 50 + (html.match(/var porchimages\s*=\s*\{[\s\S]*?\};?\s*\n/) || [])[0] || 51 + "", 52 + ); 53 + 54 + console.log( 55 + `Parsed: ${Object.keys(bands).length} bands, ${Object.keys(porches).length} porches, ${Object.keys(gigs).length} gigs, ${Object.keys(images || {}).length} image groups`, 56 + ); 57 + 58 + const porchesById = {}; 59 + for (const p of Object.values(porches)) { 60 + porchesById[p._id] = p; 61 + } 62 + 63 + const bandSet = new Set(); 64 + const enrichedBands = []; 65 + 66 + for (const gig of Object.values(gigs)) { 67 + // Skip unsolved gigs that have no valid start time or no matching band/porch 68 + if (!gig.starthour || !gig.startminute) { 69 + console.log( 70 + `Skipping unsolved gig: ${gig._id} (no start time)`, 71 + ); 72 + continue; 73 + } 74 + 75 + const bid = gig.bandid; 76 + const band = bands ? bands[bid] : null; 77 + if (!band) { 78 + console.log( 79 + `Skipping gig ${gig._id}: band \`${bid}\` not found in bands data (unsolved bid)`, 80 + ); 81 + continue; 82 + } 83 + if (bandSet.has(band._id)) continue; 84 + bandSet.add(band._id); 85 + 86 + const porch = porchesById[gig.porchid]; 87 + if (!porch) { 88 + console.log( 89 + `Skipping band "${band.actname}": porch \`${gig.porchid}\` not found`, 90 + ); 91 + continue; 92 + } 93 + 94 + // Skip bands without GPS coordinates (empty lat/lng porches) 95 + if (!porch.lat || !porch.lng) { 96 + console.log( 97 + `Skipping band "${band.actname}" at "${porch.address}": no GPS coords`, 98 + ); 99 + continue; 100 + } 101 + 102 + const startH = parseInt(gig.starthour, 10) || 0; 103 + const startM = parseInt(gig.startminute, 10) || 0; 104 + const dur = parseInt(gig.duration, 10) || 60; 105 + const startMinutes = startH * 60 + startM; 106 + const endMinutes = startMinutes + dur; 107 + 108 + const to12hStr = (m) => { 109 + const h = Math.floor(m / 60); 110 + const mn = m % 60; 111 + const hh = h > 12 ? h - 12 : h === 0 ? 12 : h; 112 + return `${String(hh).padStart(2, "0")}:${String(mn).padStart(2, "0")}${h >= 12 ? "pm" : "am"}`; 113 + }; 114 + 115 + let genres = (band.genre || "").trim(); 116 + if (band.other_genre && band.other_genre.trim()) { 117 + const other = band.other_genre.trim(); 118 + genres = genres ? `${genres}, ${other}` : other; 119 + } 120 + 121 + const links = {}; 122 + if (band.website) 123 + links.website = band.website.startsWith("http") 124 + ? band.website 125 + : `https://${band.website}`; 126 + if (band.facebook) 127 + links.facebook = band.facebook.startsWith("http") 128 + ? band.facebook 129 + : `https://${band.facebook}`; 130 + if (band.instagram) 131 + links.instagram = band.instagram.startsWith("http") 132 + ? band.instagram 133 + : `https://${band.instagram}`; 134 + if (band.bandcamp) 135 + links.bandcamp = band.bandcamp.startsWith("http") 136 + ? band.bandcamp 137 + : `https://${band.bandcamp}`; 138 + if (band.spotify) 139 + links.spotify = band.spotify.startsWith("http") 140 + ? band.spotify 141 + : `https://${band.spotify}`; 142 + if (band.apple) 143 + links.apple = band.apple.startsWith("http") 144 + ? band.apple 145 + : `https://${band.apple}`; 146 + if (band.youtube) 147 + links.youtube = band.youtube.startsWith("http") 148 + ? band.youtube 149 + : `https://${band.youtube}`; 150 + if (band.soniccloud) 151 + links.soniccloud = band.soniccloud.startsWith("http") 152 + ? band.soniccloud 153 + : `https://${band.soniccloud}`; 154 + if (band.otheronline) 155 + links.otheronline = band.otheronline.startsWith("http") 156 + ? band.otheronline 157 + : `https://${band.otheronline}`; 158 + 159 + const bandPhotosList = (images[band.image] || []).map( 160 + (img) => 161 + `https://swampscottporchfest.org/img/${band.image}/${img}`, 162 + ); 163 + 164 + const street = porch.address || ""; 165 + const lat = String(porch.lat || ""); 166 + const lng = String(porch.lng || ""); 167 + const porchNumber = porch.porchnum || ""; 168 + 169 + // Bucket bands by rounded start hour for hourly zone display 170 + const hourlyKey = String(Math.floor(startMinutes / 60)); 171 + 172 + enrichedBands.push({ 173 + title: band.actname, 174 + start_time: to12hStr(startMinutes), 175 + end_time: to12hStr(endMinutes), 176 + latitude: lat, 177 + longitude: lng, 178 + street: street, 179 + genres: genres 180 + ? genres.split(/,\s*/).map((g) => g.trim()).filter(Boolean) 181 + : [], 182 + porch_zone: hourlyKey, 183 + porch_num: porchNumber, 184 + is_withdrawn: band.play_rain === "No" ? "Y" : "N", 185 + ...links, 186 + description: band.description || "", 187 + photos: bandPhotosList, 188 + _startMinutes: startMinutes, 189 + _endMinutes: endMinutes, 190 + _zoneKey: hourlyKey, 191 + }); 192 + } 193 + 194 + function transformToPorchfestFormat(bands) { 195 + const EVENT_DATE = "2026-05-31"; 196 + 197 + function toIsoDate(minutes) { 198 + const h = Math.floor(minutes / 60); 199 + const mn = minutes % 60; 200 + const hh = String(h).padStart(2, "0"); 201 + const mm = String(mn).padStart(2, "0"); 202 + return `${EVENT_DATE} ${hh}:${mm}:00`; 203 + } 204 + 205 + return bands.map((b) => { 206 + const cleanGenres = (b.genres || []).filter( 207 + (g) => g !== "Other (specify below)", 208 + ); 209 + 210 + const linkValues = [ 211 + b.website, 212 + b.facebook, 213 + b.instagram, 214 + b.bandcamp, 215 + b.spotify, 216 + b.apple, 217 + b.youtube, 218 + b.soniccloud, 219 + b.otheronline, 220 + ].filter(Boolean); 221 + const supportUrl = linkValues.length > 0 ? linkValues[0] : ""; 222 + 223 + return { 224 + title: b.title, 225 + start_time: toIsoDate(b._startMinutes), 226 + end_time: toIsoDate(b._endMinutes), 227 + latitude: b.latitude, 228 + longitude: b.longitude, 229 + street: b.street, 230 + grid: "", 231 + genres: cleanGenres.length > 0 ? cleanGenres : "", 232 + porch_zone: b._zoneKey, 233 + field_support_the_band_value: supportUrl, 234 + is_withdrawn: b.is_withdrawn === "Y" ? "true" : "false", 235 + }; 236 + }); 237 + } 238 + 239 + const transformed = transformToPorchfestFormat(enrichedBands); 240 + 241 + fs.writeFileSync( 242 + path.resolve(__dirname, "..", "swampscott", "bands.json"), 243 + JSON.stringify( 244 + transformed.sort((a, b) => a.title.localeCompare(b.title)), 245 + null, 246 + 2, 247 + ), 248 + ); 249 + 250 + console.log( 251 + `\nWrote ${transformed.length} bands to ${TARGET_DIR}/swampscott/bands.json\n`, 252 + ); 253 + 254 + const hourlyLabels = ["12PM", "1PM", "2PM", "3PM", "4PM", "5PM"]; 255 + const zoneCounts = {}; 256 + for (const b of transformed) { 257 + zoneCounts[b.porch_zone] = (zoneCounts[b.porch_zone] || 0) + 1; 258 + } 259 + console.log("Hourly zone distribution:"); 260 + for (const key of Object.keys(HOURLY_ZONES)) { 261 + const label = HOURLY_ZONES[key]; 262 + if (zoneCounts[key]) { 263 + console.log(` ${label}: ${zoneCounts[key]}`); 264 + } 265 + } 266 + 267 + for (const b of transformed.slice(0, 10)) { 268 + console.log( 269 + ` Zone ${b.porch_zone}. ${b.title} — ${b.start_time.substring(11)}–${b.end_time.substring(11)} @ ${b.street} (${b.genres || "no genre"}) withdrawn:${b.is_withdrawn}`, 270 + ); 271 + } 272 + }); 273 + }) 274 + .on("error", (e) => console.error(e.message));
+19 -12
e2e/basic.spec.js
··· 152 152 // ============================================================ 153 153 154 154 test('total band count is 145 with correct zone distribution', () => { 155 - if (B.isHourly) { 156 - expect(B.all.length).toBe(205) 157 - expect(B.hourly['12PM'].length).toBe(49) 158 - expect(B.hourly['1PM'].length).toBe(30) 159 - expect(B.hourly['2PM'].length).toBe(44) 160 - expect(B.hourly['3PM'].length).toBe(36) 161 - expect(B.hourly['4PM'].length).toBe(33) 162 - expect(B.hourly['5PM'].length).toBe(13) 163 - } else { 164 - const city = process.env.TEST_CITY || "watertown" 165 - if (city === "gloucester-magnolia") { 155 + const city = process.env.TEST_CITY || "watertown" 156 + if (city === "gloucester-magnolia") { 166 157 expect(B.all.length).toBe(18) 158 + expect(B.grouped['Gloucester-Magnolia'].length).toBe(18) 159 + } else if (B.isHourly && city === "swampscott") { 160 + expect(B.all.length).toBe(36) 161 + expect(B.hourly['12PM'].length).toBe(1) 162 + expect(B.hourly['1PM'].length).toBe(7) 163 + expect(B.hourly['2PM'].length).toBe(7) 164 + expect(B.hourly['3PM'].length).toBe(8) 165 + expect(B.hourly['4PM'].length).toBe(10) 166 + expect(B.hourly['5PM'].length).toBe(3) 167 + } else if (B.isHourly && city === "watertown") { 168 + expect(B.all.length).toBe(205) 169 + expect(B.hourly['12PM'].length).toBe(49) 170 + expect(B.hourly['1PM'].length).toBe(30) 171 + expect(B.hourly['2PM'].length).toBe(44) 172 + expect(B.hourly['3PM'].length).toBe(36) 173 + expect(B.hourly['4PM'].length).toBe(33) 174 + expect(B.hourly['5PM'].length).toBe(13) 167 175 } else { 168 176 expect(B.all.length).toBe(145) 169 177 expect(B.grouped['West'].length).toBe(53) 170 178 expect(B.grouped['Central'].length).toBe(36) 171 179 expect(B.grouped['East'].length).toBe(56) 172 180 } 173 - } 174 181 }) 175 182 176 183 test('every band has all required fields', () => {
+29
src/__tests__/bandSchema.test.ts
··· 63 63 : undefined, 64 64 ).toBe(0); 65 65 }); 66 + 67 + it("validates all 36 swampscott bands without errors", () => { 68 + const swampscottPath = path.resolve( 69 + __dirname, 70 + "../../data/swampscott/bands.json", 71 + ); 72 + const raw = JSON.parse( 73 + fs.readFileSync(swampscottPath, "utf-8"), 74 + ) as Record<string, unknown>[]; 75 + 76 + expect(raw.length).toBe(36); 77 + 78 + const failures: string[] = []; 79 + for (let i = 0; i < raw.length; i++) { 80 + const result = bandSchema.safeParse(raw[i]); 81 + if (!result.success) { 82 + failures.push( 83 + `Band #${i + 1} "${raw[i].title ?? "(no title)"}": ${result.error.message}`, 84 + ); 85 + } 86 + } 87 + 88 + expect( 89 + failures.length, 90 + failures.length > 0 91 + ? `${failures.length} failure(s):\n${failures.join("\n")}` 92 + : undefined, 93 + ).toBe(0); 94 + }); 66 95 });
+6
src/cityConfig.ts
··· 32 32 url: "https://somervilleartscouncil.org/porchfest/", 33 33 optionLabel: "Somerville PorchFest 2026", 34 34 }, 35 + swampscott: { 36 + label: "Swampscott", 37 + date: "Saturday May 31st, 2026", 38 + url: "https://swampscottporchfest.org/map/#schedule", 39 + optionLabel: "Swampscott PorchFest 2026", 40 + }, 35 41 };
+2
src/components.tsx
··· 65 65 malden: "data/malden/city.geojson", 66 66 somerville: "data/somerville/city.geojson", 67 67 watertown: "data/watertown/city.geojson", 68 + swampscott: "data/swampscott/city.geojson", 68 69 }; 69 70 70 71 function CityBoundary({ city }: { city: City }) { ··· 889 890 malden: "data/malden/city.geojson", 890 891 somerville: "data/somerville/city.geojson", 891 892 watertown: "data/watertown/city.geojson", 893 + swampscott: "data/swampscott/city.geojson", 892 894 }; 893 895 894 896 function GlobalBoundary({ city, color }: { city: City; color?: string }) {
+5 -1
src/types.ts
··· 20 20 | "somerville" 21 21 | "watertown" 22 22 | "malden" 23 - | "gloucester-magnolia"; 23 + | "gloucester-magnolia" 24 + | "swampscott"; 24 25 export type ViewMode = "city" | "global"; 25 26 26 27 const CITY_PATHS: Record<City, string> = { ··· 112 113 watertown: "#3b82f6", 113 114 malden: "#10b981", 114 115 "gloucester-magnolia": "#a855f7", 116 + swampscott: "#06b6d4", 115 117 }; 116 118 117 119 export const CITY_DISPLAY_NAMES: Record<string, string> = { ··· 119 121 watertown: "Watertown", 120 122 malden: "Malden", 121 123 "gloucester-magnolia": "Gloucester-Magnolia", 124 + swampscott: "Swampscott", 122 125 }; 123 126 124 127 export const CITY_ZONE_COLORS: Record<City | string, string> = { ··· 126 129 watertown: "#3b82f6", 127 130 malden: "#10b981", 128 131 "gloucester-magnolia": "#a855f7", 132 + swampscott: "#06b6d4", 129 133 }; 130 134 131 135 // ── Constants ──────────────────────────────────────────────
+958
test-results/basic-app-loads-and-renders-map-band-cards/error-context.md
··· 1 + # Instructions 2 + 3 + - Following Playwright test failed. 4 + - Explain why, be concise, respect Playwright best practices. 5 + - Provide a snippet of code with the fix, if possible. 6 + 7 + # Test info 8 + 9 + - Name: basic.spec.js >> app loads and renders map + band cards 10 + - Location: e2e/basic.spec.js:59:1 11 + 12 + # Error details 13 + 14 + ``` 15 + Error: expect(received).toContain(expected) // indexOf 16 + 17 + Expected substring: "575" 18 + Received string: "145 bands · West: 53 · Central: 36 · East: 56" 19 + ``` 20 + 21 + # Page snapshot 22 + 23 + ```yaml 24 + - generic [ref=e3]: 25 + - banner [ref=e4]: 26 + - button "Toggle sidebar" [ref=e5]: 27 + - img [ref=e6] 28 + - button "Malden PorchFest 2026" [ref=e8] [cursor=pointer]: 29 + - text: Malden PorchFest 2026 30 + - img 31 + - textbox "Filter by name, genre, or address..." [ref=e9] 32 + - link "Source code": 33 + - /url: https://tangled.org/timryan.org/porchfest-at 34 + - img 35 + - generic [ref=e10]: 36 + - complementary [ref=e11]: 37 + - generic [ref=e12]: 38 + - generic [ref=e13]: Saturday May 9th, 2026 39 + - link "↗ Website" [ref=e14] [cursor=pointer]: 40 + - /url: https://somervilleartscouncil.org/porchfest/ 41 + - generic [ref=e15]: 42 + - generic [ref=e16]: 43 + - button "All" [ref=e17] [cursor=pointer] 44 + - button "Live Now" [ref=e18] [cursor=pointer] 45 + - button "Upcoming" [ref=e19] [cursor=pointer] 46 + - generic [ref=e20]: 47 + - generic [ref=e21]: West · 12:00–2:00pm53 48 + - generic [ref=e22]: Central · 4:00–6:00pm36 49 + - generic [ref=e23]: East · 4:00–6:00pm56 50 + - generic [ref=e24]: 145 bands 51 + - generic [ref=e26]: "145 bands · West: 53 · Central: 36 · East: 56" 52 + - generic [ref=e27]: 53 + - generic [ref=e28]: West · 12:00–2:00pm 54 + - generic [ref=e30]: Central · 4:00–6:00pm 55 + - generic [ref=e32]: East · 4:00–6:00pm 56 + - generic [ref=e34]: Withdrawn 57 + - generic [ref=e36]: 58 + - generic [ref=e37]: 59 + - heading "West · 12:00–2:00pm" [level=3] [ref=e38] [cursor=pointer]: West · 12:00–2:00pm 60 + - generic [ref=e40] [cursor=pointer]: 61 + - generic [ref=e41]: 62 + - generic [ref=e43]: Brassterisk 63 + - generic [ref=e44]: 12:00pm–1:00pm 64 + - generic [ref=e45]: Funk · brass · 36 Glen St 65 + - generic [ref=e46] [cursor=pointer]: 66 + - generic [ref=e47]: 67 + - generic [ref=e49]: Combo Combo 68 + - generic [ref=e50]: 12:00pm–12:45pm 69 + - generic [ref=e51]: Jazz/Swing · 148 Hawthorne St 70 + - generic [ref=e52] [cursor=pointer]: 71 + - generic [ref=e53]: 72 + - generic [ref=e55]: First Parish in Malden UU Band 73 + - generic [ref=e56]: 12:00pm–1:00pm 74 + - generic [ref=e57]: Rock · Folk · Gospel · 2 Elm St 75 + - generic [ref=e58] [cursor=pointer]: 76 + - generic [ref=e59]: 77 + - generic [ref=e61]: Jim’s Acoustic Jukebox 78 + - generic [ref=e62]: 12:00pm–1:00pm 79 + - generic [ref=e63]: Alternative · 177 West St 80 + - generic [ref=e64] [cursor=pointer]: 81 + - generic [ref=e65]: 82 + - generic [ref=e67]: Panic! At the T Stop 83 + - generic [ref=e68]: 12:00pm–1:00pm 84 + - generic [ref=e69]: Alternative · Pop punk · 89 Commercial St 85 + - generic [ref=e70] [cursor=pointer]: 86 + - generic [ref=e71]: 87 + - generic [ref=e73]: The Hidden Trax 88 + - generic [ref=e74]: 12:00pm–1:00pm 89 + - generic [ref=e75]: Rock · Jamband · 65 Rockland Ave 90 + - generic [ref=e76] [cursor=pointer]: 91 + - generic [ref=e77]: 92 + - generic [ref=e79]: Blomerth Hootenany Band 93 + - generic [ref=e80]: 1:00pm–3:00pm 94 + - generic [ref=e81]: Indie · Classics and variety · 186 East Border Rd 95 + - generic [ref=e82] [cursor=pointer]: 96 + - generic [ref=e83]: 97 + - generic [ref=e85]: Crow Follow 98 + - generic [ref=e86]: 1:00pm–1:45pm 99 + - generic [ref=e87]: Alternative · ORIGINAL SHAMAN ROCK AND URBAN CABARET MUSIC · 65 Wentworth St 100 + - generic [ref=e88] [cursor=pointer]: 101 + - generic [ref=e89]: 102 + - generic [ref=e91]: Matt Rich 103 + - generic [ref=e92]: 1:00pm–2:00pm 104 + - generic [ref=e93]: Singer/Songwriter · 20 Earl St 105 + - generic [ref=e94] [cursor=pointer]: 106 + - generic [ref=e95]: 107 + - generic [ref=e97]: Ronnie Schwaller 108 + - generic [ref=e98]: 1:00pm–1:30pm 109 + - generic [ref=e99]: Singer/Songwriter · 36 Glen St 110 + - generic [ref=e100] [cursor=pointer]: 111 + - generic [ref=e101]: 112 + - generic [ref=e103]: Round Trip 113 + - generic [ref=e104]: 1:00pm–1:45pm 114 + - generic [ref=e105]: Jamband/Fusion · 148 Hawthorne St 115 + - generic [ref=e106] [cursor=pointer]: 116 + - generic [ref=e107]: 117 + - generic [ref=e109]: Soloonar 118 + - generic [ref=e110]: 1:00pm–2:00pm 119 + - generic [ref=e111]: Alternative · 89 Commercial St 120 + - generic [ref=e112] [cursor=pointer]: 121 + - generic [ref=e113]: 122 + - generic [ref=e115]: The Secret Pizza Trio 123 + - generic [ref=e116]: 1:00pm–1:45pm 124 + - generic [ref=e117]: Americana · 25 Clyde St 125 + - generic [ref=e118] [cursor=pointer]: 126 + - generic [ref=e119]: 127 + - generic [ref=e121]: The Squeaky Wheels Band 128 + - generic [ref=e122]: 1:00pm–2:00pm 129 + - generic [ref=e123]: Blues inspired rock · classic rock both original songs and covers · 2 Elm St 130 + - generic [ref=e124] [cursor=pointer]: 131 + - generic [ref=e125]: 132 + - generic [ref=e127]: Brendan Ahern 133 + - generic [ref=e128]: 1:15pm–1:45pm 134 + - generic [ref=e129]: Americana · 177 West St 135 + - generic [ref=e130] [cursor=pointer]: 136 + - generic [ref=e131]: 137 + - generic [ref=e133]: Ozi 138 + - generic [ref=e134]: 1:30pm–2:00pm 139 + - generic [ref=e135]: Acoustic covers · 36 Glen St 140 + - generic [ref=e136] [cursor=pointer]: 141 + - generic [ref=e137]: 142 + - generic [ref=e139]: Whiskey Cherrybomb 143 + - generic [ref=e140]: 1:30pm–3:30pm 144 + - generic [ref=e141]: Folk · Irish Celtic Folk music with our own twist · 65 Rockland Ave 145 + - generic [ref=e142] [cursor=pointer]: 146 + - generic [ref=e143]: 147 + - generic [ref=e145]: alicia devereaux 148 + - generic [ref=e146]: 2:00pm–2:45pm 149 + - generic [ref=e147]: Pop · 559 Fellsway East 150 + - generic [ref=e148] [cursor=pointer]: 151 + - generic [ref=e149]: 152 + - generic [ref=e151]: Buck Lonesome and the Table for One 153 + - generic [ref=e152]: 2:00pm–3:00pm 154 + - generic [ref=e153]: Country/Bluegrass · 25 Clyde St 155 + - generic [ref=e154] [cursor=pointer]: 156 + - generic [ref=e155]: 157 + - generic [ref=e157]: Chrissy Ann &amp; Co 158 + - generic [ref=e158]: 2:00pm–3:00pm 159 + - generic [ref=e159]: Americana · Singer/ songwriter indie bluegrass folk · 368 Pleasant St 160 + - generic [ref=e160] [cursor=pointer]: 161 + - generic [ref=e161]: 162 + - generic [ref=e163]: Gretchen &amp; The Banshees 163 + - generic [ref=e164]: 2:00pm–2:45pm 164 + - generic [ref=e165]: Tribute to Siouxsie · 17 Corey Rd 165 + - generic [ref=e166] [cursor=pointer]: 166 + - generic [ref=e167]: 167 + - generic [ref=e169]: Hiding in Public 168 + - generic [ref=e170]: 2:00pm–3:00pm 169 + - generic [ref=e171]: Alternative · Rock and Roll · 14 Maurice St 170 + - generic [ref=e172] [cursor=pointer]: 171 + - generic [ref=e173]: 172 + - generic [ref=e175]: Ice Lemonade and The Apple Snaps 173 + - generic [ref=e176]: 2:00pm–2:45pm 174 + - generic [ref=e177]: Indie Rock/Punk · 177 West St 175 + - generic [ref=e178] [cursor=pointer]: 176 + - generic [ref=e179]: 177 + - generic [ref=e181]: Johnny Most 178 + - generic [ref=e182]: 2:00pm–2:45pm 179 + - generic [ref=e183]: Rock · 148 Hawthorne St 180 + - generic [ref=e184] [cursor=pointer]: 181 + - generic [ref=e185]: 182 + - generic [ref=e187]: Love Letter 183 + - generic [ref=e188]: 2:00pm–2:45pm 184 + - generic [ref=e189]: Singer/Songwriter · 113 Adams St 185 + - generic [ref=e190] [cursor=pointer]: 186 + - generic [ref=e191]: 187 + - generic [ref=e193]: Ohm Foam 188 + - generic [ref=e194]: 2:00pm–2:45pm 189 + - generic [ref=e195]: Psychedelic rock · 65 Wentworth St 190 + - generic [ref=e196] [cursor=pointer]: 191 + - generic [ref=e197]: 192 + - generic [ref=e199]: Standard Deviants Jazz Combo 193 + - generic [ref=e200]: 2:00pm–3:30pm 194 + - generic [ref=e201]: Jazz/Swing · 89 Commercial St 195 + - generic [ref=e202] [cursor=pointer]: 196 + - generic [ref=e203]: 197 + - generic [ref=e205]: The Parkwoods 198 + - generic [ref=e206]: 2:00pm–2:45pm 199 + - generic [ref=e207]: Alternative · Pop punk/ indie · 36 Glen St 200 + - generic [ref=e208] [cursor=pointer]: 201 + - generic [ref=e209]: 202 + - generic [ref=e211]: The Secret 203 + - generic [ref=e212]: 2:30pm–3:30pm 204 + - generic [ref=e213]: Rock · 6 Highland Park 205 + - generic [ref=e214] [cursor=pointer]: 206 + - generic [ref=e215]: 207 + - generic [ref=e217]: Astra Mortis 208 + - generic [ref=e218]: 3:00pm–3:30pm 209 + - generic [ref=e219]: Symphonic Metal · 25 Clyde St 210 + - generic [ref=e220] [cursor=pointer]: 211 + - generic [ref=e221]: 212 + - generic [ref=e223]: Captain Easychord 213 + - generic [ref=e224]: 3:00pm–3:45pm 214 + - generic [ref=e225]: Rock · Quirky · melodic · 65 Wentworth St 215 + - generic [ref=e226] [cursor=pointer]: 216 + - generic [ref=e227]: 217 + - generic [ref=e229]: doctor.e.phd 218 + - generic [ref=e230]: 3:00pm–4:00pm 219 + - generic [ref=e231]: Various Styles · 36 Glen St 220 + - generic [ref=e232] [cursor=pointer]: 221 + - generic [ref=e233]: 222 + - generic [ref=e235]: Hey Blondie 223 + - generic [ref=e236]: 3:00pm–3:45pm 224 + - generic [ref=e237]: Rock · 17 Corey Rd 225 + - generic [ref=e238] [cursor=pointer]: 226 + - generic [ref=e239]: 227 + - generic [ref=e241]: Jazz Conundrum 228 + - generic [ref=e242]: 3:00pm–4:00pm 229 + - generic [ref=e243]: Jazz · 18 Maurice St 230 + - generic [ref=e244] [cursor=pointer]: 231 + - generic [ref=e245]: 232 + - generic [ref=e247]: Less Than Zero 233 + - generic [ref=e248]: 3:00pm–4:00pm 234 + - generic [ref=e249]: Pop · 148 Hawthorne St 235 + - generic [ref=e250] [cursor=pointer]: 236 + - generic [ref=e251]: 237 + - generic [ref=e253]: Mortal Folly 238 + - generic [ref=e254]: 3:00pm–3:45pm 239 + - generic [ref=e255]: Rock · 113 Adams St 240 + - generic [ref=e256] [cursor=pointer]: 241 + - generic [ref=e257]: 242 + - generic [ref=e259]: Mystic Horn Club 243 + - generic [ref=e260]: 3:00pm–3:45pm 244 + - generic [ref=e261]: Classical · 556 Fellsway East 245 + - generic [ref=e262] [cursor=pointer]: 246 + - generic [ref=e263]: 247 + - generic [ref=e265]: Noel Smyth 248 + - generic [ref=e266]: 3:00pm–3:45pm 249 + - generic [ref=e267]: Singer/Songwriter · Americana · 368 Pleasant St 250 + - generic [ref=e268] [cursor=pointer]: 251 + - generic [ref=e269]: 252 + - generic [ref=e271]: Public Faces 253 + - generic [ref=e272]: 3:00pm–4:00pm 254 + - generic [ref=e273]: Indie · 20 Earl St 255 + - generic [ref=e274] [cursor=pointer]: 256 + - generic [ref=e275]: 257 + - generic [ref=e277]: School of Rock Medford House Band 258 + - generic [ref=e278]: 3:00pm–4:00pm 259 + - generic [ref=e279]: Rock · 177 West St 260 + - generic [ref=e280] [cursor=pointer]: 261 + - generic [ref=e281]: 262 + - generic [ref=e283]: Sweeney &amp; Sweeney 263 + - generic [ref=e284]: 3:00pm–5:00pm 264 + - generic [ref=e285]: Rock · Indie/Alternative · 68 McCormack St 265 + - generic [ref=e286] [cursor=pointer]: 266 + - generic [ref=e287]: 267 + - generic [ref=e289]: The Slackademics 268 + - generic [ref=e290]: 3:00pm–4:30pm 269 + - generic [ref=e291]: Indie Power-Pop with Horns · 89 Commercial St 270 + - generic [ref=e292] [cursor=pointer]: 271 + - generic [ref=e293]: 272 + - generic [ref=e295]: IYA 273 + - generic [ref=e296]: 4:00pm–4:45pm 274 + - generic [ref=e297]: Metal · Prog/Rock · 25 Clyde St 275 + - generic [ref=e298] [cursor=pointer]: 276 + - generic [ref=e299]: 277 + - generic [ref=e301]: Nicole Dobberpuhl 278 + - generic [ref=e302]: 4:00pm–5:30pm 279 + - generic [ref=e303]: Pop · Indie · Rock · 6 Highland Park 280 + - generic [ref=e304] [cursor=pointer]: 281 + - generic [ref=e305]: 282 + - generic [ref=e307]: Owen Connelly 283 + - generic [ref=e308]: 4:00pm–4:45pm 284 + - generic [ref=e309]: Rock · 17 Corey Rd 285 + - generic [ref=e310] [cursor=pointer]: 286 + - generic [ref=e311]: 287 + - generic [ref=e313]: Rival Galaxies 288 + - generic [ref=e314]: 4:00pm–5:00pm 289 + - generic [ref=e315]: Dance punk · 113 Adams St 290 + - generic [ref=e316] [cursor=pointer]: 291 + - generic [ref=e317]: 292 + - generic [ref=e319]: The Rollo Tomasi Quintet 293 + - generic [ref=e320]: 4:00pm–5:00pm 294 + - generic [ref=e321]: Jazz · 18 Maurice St 295 + - generic [ref=e322] [cursor=pointer]: 296 + - generic [ref=e323]: 297 + - generic [ref=e325]: The Double Lives 298 + - generic [ref=e326]: 4:15pm–5:15pm 299 + - generic [ref=e327]: Rock · 177 West St 300 + - generic [ref=e328] [cursor=pointer]: 301 + - generic [ref=e329]: 302 + - generic [ref=e331]: Bikini Whale 303 + - generic [ref=e332]: 5:00pm–5:45pm 304 + - generic [ref=e333]: Pop · B 52s Tribute Band · 17 Corey Rd 305 + - generic [ref=e334] [cursor=pointer]: 306 + - generic [ref=e335]: 307 + - generic [ref=e337]: Girl Sküll 308 + - generic [ref=e338]: 5:00pm–6:00pm 309 + - generic [ref=e339]: Punk/Metal · 89 Commercial St 310 + - generic [ref=e340] [cursor=pointer]: 311 + - generic [ref=e341]: 312 + - generic [ref=e343]: Ignoramus 313 + - generic [ref=e344]: 5:00pm–5:30pm 314 + - generic [ref=e345]: Rock · Progressive · Hard Rock · 25 Clyde St 315 + - generic [ref=e346] [cursor=pointer]: 316 + - generic [ref=e347]: 317 + - generic [ref=e349]: Sons Of Levin 318 + - generic [ref=e350]: 5:00pm–5:45pm 319 + - generic [ref=e351]: Rock · Jam/Funk Rock · 93 Rockland Ave 320 + - generic [ref=e352] [cursor=pointer]: 321 + - generic [ref=e353]: 322 + - generic [ref=e355]: Buster 323 + - generic [ref=e356]: 5:30pm–6:15pm 324 + - generic [ref=e357]: Alternative · Grunge · 177 West St 325 + - generic [ref=e358]: 326 + - heading "Central · 4:00–6:00pm" [level=3] [ref=e359] [cursor=pointer]: Central · 4:00–6:00pm 327 + - generic [ref=e361] [cursor=pointer]: 328 + - generic [ref=e362]: 329 + - generic [ref=e364]: ControVerse 330 + - generic [ref=e365]: 12:00pm–12:30pm 331 + - generic [ref=e366]: Spoken Word with Accompaniment · 73 Linden Ave 332 + - generic [ref=e367] [cursor=pointer]: 333 + - generic [ref=e368]: 334 + - generic [ref=e370]: Pocketbuskers 335 + - generic [ref=e371]: 12:00pm–2:00pm 336 + - generic [ref=e372]: Singer/Songwriter · Singer/Songwriter Originals · 108 Mountain Ave 337 + - generic [ref=e373] [cursor=pointer]: 338 + - generic [ref=e374]: 339 + - generic [ref=e376]: The Johnny Williams Band and Friends 340 + - generic [ref=e377]: 12:00pm–6:00pm 341 + - generic [ref=e378]: Blues · Jamband Bluesrock · 45 Pleasant St 342 + - generic [ref=e379] [cursor=pointer]: 343 + - generic [ref=e380]: 344 + - generic [ref=e382]: Vices Inc 345 + - generic [ref=e383]: 12:30pm–1:30pm 346 + - generic [ref=e384]: Alternative · Pop Punk · 164 Linden Av 347 + - generic [ref=e385] [cursor=pointer]: 348 + - generic [ref=e386]: 349 + - generic [ref=e388]: Julian McDonald 350 + - generic [ref=e389]: 12:45pm–1:05pm 351 + - generic [ref=e390]: Classical · Rock · 215 Pleasant St 352 + - generic [ref=e391] [cursor=pointer]: 353 + - generic [ref=e392]: 354 + - generic [ref=e394]: Out of the Way 355 + - generic [ref=e395]: 12:45pm–1:45pm 356 + - generic [ref=e396]: Metal · Progressive Metal · 73 Linden Ave 357 + - generic [ref=e397] [cursor=pointer]: 358 + - generic [ref=e398]: 359 + - generic [ref=e400]: Antarctica 360 + - generic [ref=e401]: 1:00pm–3:00pm 361 + - generic [ref=e402]: Pop · 60s/70s Pop Covers · 70 Bell Rock St 362 + - generic [ref=e403] [cursor=pointer]: 363 + - generic [ref=e404]: 364 + - generic [ref=e406]: Laura Lee 365 + - generic [ref=e407]: 1:00pm–3:00pm 366 + - generic [ref=e408]: Singer/Songwriter · 37 Beltran St 367 + - generic [ref=e409] [cursor=pointer]: 368 + - generic [ref=e410]: 369 + - generic [ref=e412]: Melt 370 + - generic [ref=e413]: 1:00pm–2:30pm 371 + - generic [ref=e414]: Alternative · world · funk · jazz · blues · 47 Perkins Ave 372 + - generic [ref=e415] [cursor=pointer]: 373 + - generic [ref=e416]: 374 + - generic [ref=e418]: Miguel Matias 375 + - generic [ref=e419]: 1:00pm–1:45pm 376 + - generic [ref=e420]: Multiple genres. Showtune · acoustic · Latin · pop · country. · 54 Eastern Ave 377 + - generic [ref=e421] [cursor=pointer]: 378 + - generic [ref=e422]: 379 + - generic [ref=e424]: The After Jam 380 + - generic [ref=e425]: 1:00pm–1:45pm 381 + - generic [ref=e426]: Jazz/Swing · ukulele jazz · 18 Clayton St 382 + - generic [ref=e427] [cursor=pointer]: 383 + - generic [ref=e428]: 384 + - generic [ref=e430]: Tim Yoon 385 + - generic [ref=e431]: 1:00pm–3:00pm 386 + - generic [ref=e432]: Pop · 96 Tremont St 387 + - generic [ref=e433] [cursor=pointer]: 388 + - generic [ref=e434]: 389 + - generic [ref=e436]: Students of MAP Family Learning Center 390 + - generic [ref=e437]: 1:30pm–2:15pm 391 + - generic [ref=e438]: Classical · Classical · Jazz · and Rock · 215 Pleasant St 392 + - generic [ref=e439] [cursor=pointer]: 393 + - generic [ref=e440]: 394 + - generic [ref=e442]: Davey Melton 395 + - generic [ref=e443]: 2:00pm–3:00pm 396 + - generic [ref=e444]: Singer/Songwriter · Covers &amp; originals · 164 Linden Av 397 + - generic [ref=e445] [cursor=pointer]: 398 + - generic [ref=e446]: 399 + - generic [ref=e448]: Ethan Armstrong 400 + - generic [ref=e449]: 2:00pm–3:30pm 401 + - generic [ref=e450]: Singer/Songwriter · classic covers · 18 Clayton St 402 + - generic [ref=e451] [cursor=pointer]: 403 + - generic [ref=e452]: 404 + - generic [ref=e454]: Keith Rogers 405 + - generic [ref=e455]: 2:00pm–2:30pm 406 + - generic [ref=e456]: Folk · 664 Main St 407 + - generic [ref=e457] [cursor=pointer]: 408 + - generic [ref=e458]: 409 + - generic [ref=e460]: So-matic 410 + - generic [ref=e461]: 2:00pm–2:45pm 411 + - generic [ref=e462]: Alternative · Goo goo dolls · silver sun pickups · 47 Perkins Ave 412 + - generic [ref=e463] [cursor=pointer]: 413 + - generic [ref=e464]: 414 + - generic [ref=e466]: TOBY. 415 + - generic [ref=e467]: 2:00pm–3:00pm 416 + - generic [ref=e468]: Neo Soul · Singer/Songwriter · R&amp;B · Indie · 54 Eastern Ave 417 + - generic [ref=e469] [cursor=pointer]: 418 + - generic [ref=e470]: 419 + - generic [ref=e472]: Val Kelner 420 + - generic [ref=e473]: 2:00pm–2:45pm 421 + - generic [ref=e474]: Singer/Songwriter · Tenderpunk · 73 Linden Ave 422 + - generic [ref=e475] [cursor=pointer]: 423 + - generic [ref=e476]: 424 + - generic [ref=e478]: Huck 2 425 + - generic [ref=e479]: 2:45pm–3:30pm 426 + - generic [ref=e480]: Indie · Alternative Rock and Roll · 215 Pleasant St 427 + - generic [ref=e481] [cursor=pointer]: 428 + - generic [ref=e482]: 429 + - generic [ref=e484]: Amir Hafez and his existential dread 430 + - generic [ref=e485]: 3:00pm–3:30pm 431 + - generic [ref=e486]: Indie · Electronic Folk Jazz · 158 Canal St 432 + - generic [ref=e487] [cursor=pointer]: 433 + - generic [ref=e488]: 434 + - generic [ref=e490]: Kent Vienot 435 + - generic [ref=e491]: 3:00pm–3:45pm 436 + - generic [ref=e492]: Solo Acoustic · 54 Eastern Ave 437 + - generic [ref=e493] [cursor=pointer]: 438 + - generic [ref=e494]: 439 + - generic [ref=e496]: Kevin ONeil and Friends 440 + - generic [ref=e497]: 3:00pm–5:00pm 441 + - generic [ref=e498]: Singer/Songwriter · 108 Mountain Ave 442 + - generic [ref=e499] [cursor=pointer]: 443 + - generic [ref=e500]: 444 + - generic [ref=e502]: Leon Rich 445 + - generic [ref=e503]: 3:00pm–3:45pm 446 + - generic [ref=e504]: Pop · 664 Main St 447 + - generic [ref=e505] [cursor=pointer]: 448 + - generic [ref=e506]: 449 + - generic [ref=e508]: Mister Vertigo 450 + - generic [ref=e509]: 3:00pm–3:30pm 451 + - generic [ref=e510]: Alternative · Indie Rock. Acoustic set · 47 Perkins Ave 452 + - generic [ref=e511] [cursor=pointer]: 453 + - generic [ref=e512]: 454 + - generic [ref=e514]: Thomas Charles Clukey 455 + - generic [ref=e515]: 3:00pm–4:30pm 456 + - generic [ref=e516]: Americana · Folk country · 164 Linden Av 457 + - generic [ref=e517] [cursor=pointer]: 458 + - generic [ref=e518]: 459 + - generic [ref=e520]: African Roots Band 460 + - generic [ref=e521]: 4:00pm–5:00pm 461 + - generic [ref=e522]: African traditional music · 37 Beltran St 462 + - generic [ref=e523] [cursor=pointer]: 463 + - generic [ref=e524]: 464 + - generic [ref=e526]: Aluminum Brain 465 + - generic [ref=e527]: 4:00pm–4:45pm 466 + - generic [ref=e528]: Alternative · Alternative Pop · 664 Main St 467 + - generic [ref=e529] [cursor=pointer]: 468 + - generic [ref=e530]: 469 + - generic [ref=e532]: Digging for Shovels 470 + - generic [ref=e533]: 4:00pm–5:30pm 471 + - generic [ref=e534]: Rock · HipHop-reggae infused · 70 Bell Rock St 472 + - generic [ref=e535] [cursor=pointer]: 473 + - generic [ref=e536]: 474 + - generic [ref=e538]: Gompson 475 + - generic [ref=e539]: 4:00pm–4:45pm 476 + - generic [ref=e540]: Rock · 158 Canal St 477 + - generic [ref=e541] [cursor=pointer]: 478 + - generic [ref=e542]: 479 + - generic [ref=e544]: JF Whelan Band 480 + - generic [ref=e545]: 4:00pm–4:45pm 481 + - generic [ref=e546]: Jazz/Swing · Jazz and Funk instrumental · 215 Pleasant St 482 + - generic [ref=e547] [cursor=pointer]: 483 + - generic [ref=e548]: 484 + - generic [ref=e550]: Juan Pedraza and friends 485 + - generic [ref=e551]: 4:00pm–6:00pm 486 + - generic [ref=e552]: Rock · Latin rock/folk · 64 Converse Av 487 + - generic [ref=e553] [cursor=pointer]: 488 + - generic [ref=e554]: 489 + - generic [ref=e556]: Nick Zaino 490 + - generic [ref=e557]: 4:00pm–5:30pm 491 + - generic [ref=e558]: Americana · 164 Linden Av 492 + - generic [ref=e559] [cursor=pointer]: 493 + - generic [ref=e560]: 494 + - generic [ref=e562]: Perk Up, Loser Prince 495 + - generic [ref=e563]: 4:00pm–4:30pm 496 + - generic [ref=e564]: Rock · Emo · 47 Perkins Ave 497 + - generic [ref=e565] [cursor=pointer]: 498 + - generic [ref=e566]: 499 + - generic [ref=e568]: Castling 500 + - generic [ref=e569]: 4:30pm–5:00pm 501 + - generic [ref=e570]: Indie · emo/post-rock · 164 Linden Av 502 + - generic [ref=e571] [cursor=pointer]: 503 + - generic [ref=e572]: 504 + - generic [ref=e574]: Conical Cacophony 505 + - generic [ref=e575]: 5:00pm–6:00pm 506 + - generic [ref=e576]: Balkan Brass · 165 Washington St 507 + - generic [ref=e577]: 508 + - heading "East · 4:00–6:00pm" [level=3] [ref=e578] [cursor=pointer]: East · 4:00–6:00pm 509 + - generic [ref=e580] [cursor=pointer]: 510 + - generic [ref=e581]: 511 + - generic [ref=e583]: AppleJack Trio 512 + - generic [ref=e584]: 12:00pm–1:00pm 513 + - generic [ref=e585]: Rock · 64 Bainbridge St 514 + - generic [ref=e586] [cursor=pointer]: 515 + - generic [ref=e587]: 516 + - generic [ref=e589]: Code 4 517 + - generic [ref=e590]: 12:00pm–2:00pm 518 + - generic [ref=e591]: Rock · 12 Fairmont St 519 + - generic [ref=e592] [cursor=pointer]: 520 + - generic [ref=e593]: 521 + - generic [ref=e595]: DadBand 522 + - generic [ref=e596]: 12:00pm–1:00pm 523 + - generic [ref=e597]: Rock · 44 Rich St 524 + - generic [ref=e598] [cursor=pointer]: 525 + - generic [ref=e599]: 526 + - generic [ref=e601]: David Rivera Bozon, Tenor 527 + - generic [ref=e602]: 12:00pm–1:00pm 528 + - generic [ref=e603]: Classical · Opera · Broadway and popular tunes · 63 Lawrence St 529 + - generic [ref=e604] [cursor=pointer]: 530 + - generic [ref=e605]: 531 + - generic [ref=e607]: Fezz n Friends 532 + - generic [ref=e608]: 12:00pm–12:30pm 533 + - generic [ref=e609]: Singer/Songwriter · 35 Lillian Rd 534 + - generic [ref=e610] [cursor=pointer]: 535 + - generic [ref=e611]: 536 + - generic [ref=e613]: Jessi Tanguy 537 + - generic [ref=e614]: 12:00pm–1:00pm 538 + - generic [ref=e615]: Rock · Original Songs and Pop/rock covers mostly 80’s/90’s such as Heart and Stevie Nicks · 295R Lebanon St 539 + - generic [ref=e616] [cursor=pointer]: 540 + - generic [ref=e617]: 541 + - generic [ref=e619]: Nick LaFauci 542 + - generic [ref=e620]: 12:00pm–2:00pm 543 + - generic [ref=e621]: Americana · Singer/Songwriter · Pop · Country · Folk · 42 First St 544 + - generic [ref=e622] [cursor=pointer]: 545 + - generic [ref=e623]: 546 + - generic [ref=e625]: Already Dead 547 + - generic [ref=e626]: 12:30pm–1:00pm 548 + - generic [ref=e627]: Singer/Songwriter · 35 Lillian Rd 549 + - generic [ref=e628] [cursor=pointer]: 550 + - generic [ref=e629]: 551 + - generic [ref=e631]: Hey Arnold Band 552 + - generic [ref=e632]: 12:30pm–1:00pm 553 + - generic [ref=e633]: Rock · 141 Kimball St 554 + - generic [ref=e634] [cursor=pointer]: 555 + - generic [ref=e635]: 556 + - generic [ref=e637]: Hunaye 557 + - generic [ref=e638]: 12:30pm–1:30pm 558 + - generic [ref=e639]: Rock · 12 Fairmont St 559 + - generic [ref=e640] [cursor=pointer]: 560 + - generic [ref=e641]: 561 + - generic [ref=e643]: Adi Uppal 562 + - generic [ref=e644]: 1:00pm–1:45pm 563 + - generic [ref=e645]: Rock · Grunge Rock-Folk (can be acoustic) · 20 Havelock St 564 + - generic [ref=e646] [cursor=pointer]: 565 + - generic [ref=e647]: 566 + - generic [ref=e649]: Bob Taylor 567 + - generic [ref=e650]: 1:00pm–1:30pm 568 + - generic [ref=e651]: Pop · Gospel &amp; Light Jazz · 235 Forest St 569 + - generic [ref=e652] [cursor=pointer]: 570 + - generic [ref=e653]: 571 + - generic [ref=e655]: Ghostlander 572 + - generic [ref=e656]: 1:00pm–2:00pm 573 + - generic [ref=e657]: Indie · Americana · Alternative · Singer/Songwriter · Rock · 12 Fairmont St 574 + - generic [ref=e658] [cursor=pointer]: 575 + - generic [ref=e659]: 576 + - generic [ref=e661]: Goodcake 577 + - generic [ref=e662]: 1:00pm–2:00pm 578 + - generic [ref=e663]: Alternative · 44 Rich St 579 + - generic [ref=e664] [cursor=pointer]: 580 + - generic [ref=e665]: 581 + - generic [ref=e667]: Mike Barrasso 582 + - generic [ref=e668]: 1:00pm–2:30pm 583 + - generic [ref=e669]: Singer/Songwriter · 62 Church St 584 + - generic [ref=e670] [cursor=pointer]: 585 + - generic [ref=e671]: 586 + - generic [ref=e673]: Scott and Rick (&amp; Sons) 587 + - generic [ref=e674]: 1:00pm–2:00pm 588 + - generic [ref=e675]: Folk · Irish · Scottish · 11 Baincroft Rd 589 + - generic [ref=e676] [cursor=pointer]: 590 + - generic [ref=e677]: 591 + - generic [ref=e679]: Screw Cart 592 + - generic [ref=e680]: 1:00pm–1:45pm 593 + - generic [ref=e681]: Punk/Metal · 29 Kenilworth St 594 + - generic [ref=e682] [cursor=pointer]: 595 + - generic [ref=e683]: 596 + - generic [ref=e685]: Stone Tones 597 + - generic [ref=e686]: 1:00pm–2:30pm 598 + - generic [ref=e687]: Oldies (50&#039;s) · 24 Claremont St 599 + - generic [ref=e688] [cursor=pointer]: 600 + - generic [ref=e689]: 601 + - generic [ref=e691]: Pleasure Wheel 602 + - generic [ref=e692]: 1:15pm–1:45pm 603 + - generic [ref=e693]: Rock · Indy Rock · 35 Lillian Rd 604 + - generic [ref=e694] [cursor=pointer]: 605 + - generic [ref=e695]: 606 + - generic [ref=e697]: Who.iAm &amp; Thraxx 607 + - generic [ref=e698]: 1:15pm–2:00pm 608 + - generic [ref=e699]: Pop · Synthesizer and Drum · 59 Bainbridge St 609 + - generic [ref=e700] [cursor=pointer]: 610 + - generic [ref=e701]: 611 + - generic [ref=e703]: Damian &amp; The Displayers 612 + - generic [ref=e704]: 1:30pm–2:30pm 613 + - generic [ref=e705]: Singer/Songwriter · indie rock (acoustic or electric) · 295R Lebanon St 614 + - generic [ref=e706] [cursor=pointer]: 615 + - generic [ref=e707]: 616 + - generic [ref=e709]: DDR’s Pop Piano Jukebox 617 + - generic [ref=e710]: 1:30pm–2:30pm 618 + - generic [ref=e711]: Pop · Rock piano · 64 Bainbridge St 619 + - generic [ref=e712] [cursor=pointer]: 620 + - generic [ref=e713]: 621 + - generic [ref=e715]: Katie and the Roses 622 + - generic [ref=e716]: 1:30pm–2:15pm 623 + - generic [ref=e717]: Alternative · Pop/rock · 295 Broadway 624 + - generic [ref=e718] [cursor=pointer]: 625 + - generic [ref=e719]: 626 + - generic [ref=e721]: That’s Ben Gardner’s Boat 627 + - generic [ref=e722]: 1:30pm–2:30pm 628 + - generic [ref=e723]: Rock · Classic rock · 32 Swan St 629 + - generic [ref=e724] [cursor=pointer]: 630 + - generic [ref=e725]: 631 + - generic [ref=e727]: Ponyfish 632 + - generic [ref=e728]: 1:45pm–2:15pm 633 + - generic [ref=e729]: Rock · Roots Rock · 35 Lillian Rd 634 + - generic [ref=e730] [cursor=pointer]: 635 + - generic [ref=e731]: 636 + - generic [ref=e733]: Abby Mittelman 637 + - generic [ref=e734]: 2:00pm–2:30pm 638 + - generic [ref=e735]: Folk · I do some folk-indie style aswell. · 8 Floral Ave 639 + - generic [ref=e736] [cursor=pointer]: 640 + - generic [ref=e737]: 641 + - generic [ref=e739]: Ken Mattsson 642 + - generic [ref=e740]: 2:00pm–3:00pm 643 + - generic [ref=e741]: Singer/Songwriter · 235 Forest St 644 + - generic [ref=e742] [cursor=pointer]: 645 + - generic [ref=e743]: 646 + - generic [ref=e745]: Mike Sim 647 + - generic [ref=e746]: 2:00pm–2:45pm 648 + - generic [ref=e747]: Alternative · 141 Kimball St 649 + - generic [ref=e748] [cursor=pointer]: 650 + - generic [ref=e749]: 651 + - generic [ref=e751]: Penny Oranges 652 + - generic [ref=e752]: 2:00pm–3:00pm 653 + - generic [ref=e753]: Rock · 35 Porter St 654 + - generic [ref=e754] [cursor=pointer]: 655 + - generic [ref=e755]: 656 + - generic [ref=e757]: The Spackles 657 + - generic [ref=e758]: 2:00pm–2:45pm 658 + - generic [ref=e759]: Rock · 29 Kenilworth St 659 + - generic [ref=e760] [cursor=pointer]: 660 + - generic [ref=e761]: 661 + - generic [ref=e763]: Wildfeuer 662 + - generic [ref=e764]: 2:00pm–2:45pm 663 + - generic [ref=e765]: Rock · Indie Alternative · 20 Havelock St 664 + - generic [ref=e766] [cursor=pointer]: 665 + - generic [ref=e767]: 666 + - generic [ref=e769]: Jasmine Im 667 + - generic [ref=e770]: 2:15pm–4:15pm 668 + - generic [ref=e771]: Singer/Songwriter · 59 Bainbridge St 669 + - generic [ref=e772] [cursor=pointer]: 670 + - generic [ref=e773]: 671 + - generic [ref=e775]: Bill Barrasso 672 + - generic [ref=e776]: 2:30pm–4:00pm 673 + - generic [ref=e777]: R&amp;B · 42 First St 674 + - generic [ref=e778] [cursor=pointer]: 675 + - generic [ref=e779]: 676 + - generic [ref=e781]: King Serpent Variety Troupe 677 + - generic [ref=e782]: 2:30pm–3:30pm 678 + - generic [ref=e783]: Pirates Songs · Sea Chanties · Pirate Stories · Comedy · 32 Swan St 679 + - generic [ref=e784] [cursor=pointer]: 680 + - generic [ref=e785]: 681 + - generic [ref=e787]: Monkey Knife Fight 682 + - generic [ref=e788]: 2:30pm–3:00pm 683 + - generic [ref=e789]: Rock · 35 Lillian Rd 684 + - generic [ref=e790] [cursor=pointer]: 685 + - generic [ref=e791]: 686 + - generic [ref=e793]: 2 Introverts 687 + - generic [ref=e794]: 3:00pm–4:00pm 688 + - generic [ref=e795]: Alternative · 64 Bainbridge St 689 + - generic [ref=e796] [cursor=pointer]: 690 + - generic [ref=e797]: 691 + - generic [ref=e799]: Chris Steele 692 + - generic [ref=e800]: 3:00pm–4:30pm 693 + - generic [ref=e801]: Indie · Alt-country · 62 Church St 694 + - generic [ref=e802] [cursor=pointer]: 695 + - generic [ref=e803]: 696 + - generic [ref=e805]: Moon &amp; Pearl 697 + - generic [ref=e806]: 3:00pm–4:30pm 698 + - generic [ref=e807]: Singer/Songwriter · Folk · pop · and musical theater · 20 Havelock St 699 + - generic [ref=e808] [cursor=pointer]: 700 + - generic [ref=e809]: 701 + - generic [ref=e811]: Ring Cam Cover Band 702 + - generic [ref=e812]: 3:00pm–4:00pm 703 + - generic [ref=e813]: Rock · 68 Gilbert St 704 + - generic [ref=e814] [cursor=pointer]: 705 + - generic [ref=e815]: 706 + - generic [ref=e817]: Riverside Riot 707 + - generic [ref=e818]: 3:00pm–4:00pm 708 + - generic [ref=e819]: Alternative · Pop Punk · 30 Lillian Rd 709 + - generic [ref=e820] [cursor=pointer]: 710 + - generic [ref=e821]: 711 + - generic [ref=e823]: Spectrum 712 + - generic [ref=e824]: 3:00pm–3:45pm 713 + - generic [ref=e825]: Rock · Grunge · 295 Broadway 714 + - generic [ref=e826] [cursor=pointer]: 715 + - generic [ref=e827]: 716 + - generic [ref=e829]: The EndTimes Band 717 + - generic [ref=e830]: 3:00pm–5:00pm 718 + - generic [ref=e831]: Gospel Folk/Rock &amp; Oldies · 235 Forest St 719 + - generic [ref=e832] [cursor=pointer]: 720 + - generic [ref=e833]: 721 + - generic [ref=e835]: The Late Bloomers 722 + - generic [ref=e836]: 3:00pm–4:00pm 723 + - generic [ref=e837]: Jazz/Swing · Blues · 11 Baincroft Rd 724 + - generic [ref=e838] [cursor=pointer]: 725 + - generic [ref=e839]: 726 + - generic [ref=e841]: Vivienne 727 + - generic [ref=e842]: 3:00pm–3:30pm 728 + - generic [ref=e843]: Singer/Songwriter · Indie Pop · 12 Fairmont St 729 + - generic [ref=e844] [cursor=pointer]: 730 + - generic [ref=e845]: 731 + - generic [ref=e847]: Warthog 732 + - generic [ref=e848]: 3:00pm–4:00pm 733 + - generic [ref=e849]: Punk/Metal · Loud fast · 29 Kenilworth St 734 + - generic [ref=e850] [cursor=pointer]: 735 + - generic [ref=e851]: 736 + - generic [ref=e853]: Steven Harz 737 + - generic [ref=e854]: 3:45pm–4:15pm 738 + - generic [ref=e855]: Americana · Americana Soul · 32 Swan St 739 + - generic [ref=e856] [cursor=pointer]: 740 + - generic [ref=e857]: 741 + - generic [ref=e859]: deletia 742 + - generic [ref=e860]: 4:00pm–4:45pm 743 + - generic [ref=e861]: Singer/Songwriter · Folksy · glitchy indie rock · 235 Forest St 744 + - generic [ref=e862] [cursor=pointer]: 745 + - generic [ref=e863]: 746 + - generic [ref=e865]: The Brio 747 + - generic [ref=e866]: 4:00pm–5:00pm 748 + - generic [ref=e867]: Jazz/Swing · 30 Lillian Rd 749 + - generic [ref=e868] [cursor=pointer]: 750 + - generic [ref=e869]: 751 + - generic [ref=e871]: Tokyo Tramps 752 + - generic [ref=e872]: 4:00pm–5:00pm 753 + - generic [ref=e873]: Blues · Rock &amp; Roll · 42 First St 754 + - generic [ref=e874] [cursor=pointer]: 755 + - generic [ref=e875]: 756 + - generic [ref=e877]: Iron Will Iguana 757 + - generic [ref=e878]: 4:15pm–5:15pm 758 + - generic [ref=e879]: Rock · 32 Swan St 759 + - generic [ref=e880] [cursor=pointer]: 760 + - generic [ref=e881]: 761 + - generic [ref=e883]: Queer on Sunday 762 + - generic [ref=e884]: 4:30pm–5:00pm 763 + - generic [ref=e885]: Punk/Metal · Queercore · 59 Bainbridge St 764 + - generic [ref=e886] [cursor=pointer]: 765 + - generic [ref=e887]: 766 + - generic [ref=e889]: Crushed Vinyl 767 + - generic [ref=e890]: 5:00pm–6:30pm 768 + - generic [ref=e891]: Rock · Classic · modern rock · 64 Bainbridge St 769 + - generic [ref=e892] [cursor=pointer]: 770 + - generic [ref=e893]: 771 + - generic [ref=e895]: Shadows on the Mountain 772 + - generic [ref=e896]: 5:00pm–6:00pm 773 + - generic [ref=e897]: Pop/Folk · 42 First St 774 + - generic [ref=e898] [cursor=pointer]: 775 + - generic [ref=e899]: 776 + - generic [ref=e901]: Soulo 777 + - generic [ref=e902]: 5:00pm–6:00pm 778 + - generic [ref=e903]: soul · indie · band performance · 62 Church St 779 + - generic [ref=e904] [cursor=pointer]: 780 + - generic [ref=e905]: 781 + - generic [ref=e907]: The Wojis 782 + - generic [ref=e908]: 5:00pm–6:00pm 783 + - generic [ref=e909]: Rock/Mixed · 30 Lillian Rd 784 + - generic [ref=e910] [cursor=pointer]: 785 + - generic [ref=e911]: 786 + - generic [ref=e913]: Kiera Knox 787 + - generic [ref=e914]: 5:30pm–5:50pm 788 + - generic [ref=e915]: Jazz/Swing · 59 Bainbridge St 789 + - generic [ref=e917]: 790 + - generic: 791 + - generic: 792 + - img 793 + - generic: 794 + - generic [ref=e1064]: 795 + - button "Zoom in" [ref=e1065] [cursor=pointer]: + 796 + - button "Zoom out" [ref=e1066] [cursor=pointer]: − 797 + - link "Leaflet" [ref=e1068] [cursor=pointer]: 798 + - /url: https://leafletjs.com 799 + - img [ref=e1069] 800 + - text: Leaflet 801 + - link "Leaflet" [ref=e1073] [cursor=pointer]: 802 + - /url: https://leafletjs.com 803 + ``` 804 + 805 + # Test source 806 + 807 + ```ts 808 + 1 | // E2E test: Somerville PorchFest 2026 Band Data & Map 809 + 2 | // Usage: npm run test:e2e (run from project root) 810 + 3 | // 811 + 4 | // Tests are split into two groups: 812 + 5 | // LIVE — require the Vite dev server (started automatically by Playwright config). 813 + 6 | // OFFLINE — load bands.json from disk, no server needed (~0ms). 814 + 7 | 815 + 8 | const { test, expect } = require('@playwright/test') 816 + 9 | const fs = require('fs') 817 + 10 | const path = require('path') 818 + 11 | 819 + 12 | // ---- preload bands.json (used by offline + live tests) ---- 820 + 13 | 821 + 14 | const HOURLY_ZONES = {"12":"12PM","13":"1PM","14":"2PM","15":"3PM","16":"4PM","17":"5PM"} 822 + 15 | const SCALE = {"12PM":12,"1PM":13,"2PM":14,"3PM":15,"4PM":16,"5PM":17} 823 + 16 | 824 + 17 | function loadBands() { 825 + 18 | const city = process.env.TEST_CITY || "watertown" 826 + 19 | const raw = JSON.parse(fs.readFileSync(path.resolve(__dirname, '..', 'data', city, 'bands.json'), 'utf-8')) 827 + 20 | // Detect Water town-specific (hourly zones) vs zone-specific (West/Central/East) 828 + 21 | const isHourly = raw.some(b => !["West","Central","East"].includes(b.porch_zone)) 829 + 22 | const zones = {} 830 + 23 | for (const b of raw) { 831 + 24 | const zoneKey = isHourly 832 + 25 | ? String(parseInt(b.start_time.split(' ')[1].split(':')[0], 10)) 833 + 26 | : b.porch_zone 834 + 27 | if (!zones[zoneKey]) { 835 + 28 | zones[zoneKey] = [] 836 + 29 | } 837 + 30 | zones[zoneKey].push(b) 838 + 31 | } 839 + 32 | const hourlyBands = {} 840 + 33 | for (const h of ['12','13','14','15','16','17']) { 841 + 34 | hourlyBands[HOURLY_ZONES[h]] = zones[h] || [] 842 + 35 | } 843 + 36 | return { 844 + 37 | all: raw, 845 + 38 | hourly: hourlyBands, 846 + 39 | grouped: zones, 847 + 40 | withdrawn: raw.filter(b => b.is_withdrawn === 'true'), 848 + 41 | isHourly, 849 + 42 | } 850 + 43 | } 851 + 44 | const B = loadBands() 852 + 45 | 853 + 46 | // ---- helpers ---- 854 + 47 | 855 + 48 | function expectZoneStats(statsText) { 856 + > 49 | expect(statsText).toContain(String(B.all.length)) 857 + | ^ Error: expect(received).toContain(expected) // indexOf 858 + 50 | expect(statsText).toContain('bands') 859 + 51 | } 860 + 52 | 861 + 53 | async function waitForDataLoaded(page) { 862 + 54 | // Wait for the stats element to show band count text (not "Loading" or "Error") 863 + 55 | await expect(page.locator('#stats span').first()).toBeVisible() 864 + 56 | await page.locator('#searchInput').waitFor({ state: 'visible' }) 865 + 57 | } 866 + 58 | 867 + 59 | test('app loads and renders map + band cards', async ({ page }) => { 868 + 60 | await page.goto('/') 869 + 61 | await waitForDataLoaded(page) 870 + 62 | 871 + 63 | // Verify stats text 872 + 64 | const statsText = await page.locator('#stats').first().textContent() 873 + 65 | await expectZoneStats(statsText) 874 + 66 | 875 + 67 | // Verify map dimensions (prevents the "map renders below sidebar" regression) 876 + 68 | const mapBox = await page.locator(".leaflet-container").boundingBox() 877 + 69 | expect(mapBox.height).toBeGreaterThan(50) 878 + 70 | 879 + 71 | // Verify band cards exist with correct count 880 + 72 | const cardCount = await page.locator('div[id^="card_"]').count() 881 + 73 | expect(cardCount).toBe(B.all.length) 882 + 74 | }) 883 + 75 | 884 + 76 | test('search filters by name, genre, and shows no results for unknown terms', async ({ page }) => { 885 + 77 | await page.goto('/') 886 + 78 | await waitForDataLoaded(page) 887 + 79 | 888 + 80 | const searchInput = page.locator('#searchInput') 889 + 81 | 890 + 82 | // Search by first band name 891 + 83 | await searchInput.fill(B.all[0].title) 892 + 84 | await expect(page.locator('div[id^="card_"]')).toHaveCount(1) 893 + 85 | 894 + 86 | // Search by genre 895 + 87 | const rockBands = B.all.filter( 896 + 88 | b => (b.title + ' ' + (b.genres || []).join(' ') + ' ' + (b.street || '')).toLowerCase().includes('rock') 897 + 89 | ) 898 + 90 | await searchInput.fill('Rock') 899 + 91 | await expect(page.locator('div[id^="card_"]')).toHaveCount(rockBands.length) 900 + 92 | 901 + 93 | // Search for something that doesn't exist 902 + 94 | await searchInput.fill('zzzzxyww-not') 903 + 95 | await expect(page.locator('div[id^="card_"]')).toHaveCount(0) 904 + 96 | }) 905 + 97 | 906 + 98 | test('map layout is correct flex children with indoor/outdoor zones', async ({ page }) => { 907 + 99 | await page.goto('/') 908 + 100 | await waitForDataLoaded(page) 909 + 101 | 910 + 102 | const meta = await page.evaluate(() => { 911 + 103 | const outerContainer = document.querySelector('.h-\\[100vh\\].w-\\[100vw\\].overflow-hidden.bg-\\[\\#0d0d0d\\].flex.flex-col') 912 + 104 | const innerContainer = outerContainer ? outerContainer.querySelector(':scope > div.flex.overflow-hidden') : null 913 + 105 | const innerChildren = innerContainer ? Array.from(innerContainer.children) : [] 914 + 106 | const sidebar = innerChildren?.some(n => n.tagName.toLowerCase() === 'aside') ? 'yes' : 'no' 915 + 107 | const mapPane = innerChildren?.some(n => n.classList.contains('flex-1') && n.id === 'mapid') ? 'yes' : 'no' 916 + 108 | const mapId = document.querySelector('.leaflet-container') 917 + 109 | const mapIdInContentOuter = mapId && (innerContainer ? mapId.closest('.h-\\[100vh\\].w-\\[100vw\\].overflow-hidden.bg-\\[\\#0d0d0d\\].flex.flex-col') === outerContainer : false) ? 'yes' : 'no' 918 + 110 | const tileCount = document.querySelectorAll('.leaflet-tile').length 919 + 111 | return { sidebar, mapPane, mapIdInContentOuter, tileCount, numChildren: innerChildren.length, hasHeader: outerContainer?.children[0]?.tagName.toLowerCase() === 'header' } 920 + 112 | }) 921 + 113 | expect(meta.sidebar).toBe('yes') 922 + 114 | expect(meta.mapPane).toBe('yes') 923 + 115 | expect(meta.mapIdInContentOuter).toBe('yes') 924 + 116 | expect(meta.tileCount).toBeGreaterThan(0) 925 + 117 | expect(meta.hasHeader).toBe(true) 926 + 118 | expect(meta.numChildren).toBe(2) 927 + 119 | }) 928 + 120 | 929 + 121 | test('search input has white text for visibility on dark background', async ({ page }) => { 930 + 122 | await page.goto('/') 931 + 123 | await waitForDataLoaded(page) 932 + 124 | const textColor = await page.locator('#searchInput').evaluate(el => getComputedStyle(el).color) 933 + 125 | expect(textColor).toEqual('rgb(255, 255, 255)') 934 + 126 | }) 935 + 127 | 936 + 128 | test('map zooms to city level (not world view)', async ({ page }) => { 937 + 129 | await page.goto('/') 938 + 130 | await waitForDataLoaded(page) 939 + 131 | await page.waitForTimeout(3000) 940 + 132 | 941 + 133 | const tileInfo = await page.evaluate(() => { 942 + 134 | const container = document.querySelector('.leaflet-container') 943 + 135 | const tiles = container?.querySelectorAll('.leaflet-tile') 944 + 136 | let zoom = null 945 + 137 | const firstSrc = tiles?.[0]?.src 946 + 138 | if (firstSrc) { 947 + 139 | const parts = firstSrc.split('/') 948 + 140 | const zoomStr = parts.filter(p => !isNaN(parseInt(p)))[0] 949 + 141 | if (zoomStr !== undefined) { 950 + 142 | zoom = parseInt(zoomStr); 951 + 143 | } 952 + 144 | } 953 + 145 | return { zoom } 954 + 146 | }) 955 + 147 | expect(tileInfo.zoom).toBeGreaterThan(10) 956 + 148 | }) 957 + 149 | 958 + ```
+274
test-results/basic-search-filters-by-na-95f91-o-results-for-unknown-terms/error-context.md
··· 1 + # Instructions 2 + 3 + - Following Playwright test failed. 4 + - Explain why, be concise, respect Playwright best practices. 5 + - Provide a snippet of code with the fix, if possible. 6 + 7 + # Test info 8 + 9 + - Name: basic.spec.js >> search filters by name, genre, and shows no results for unknown terms 10 + - Location: e2e/basic.spec.js:76:1 11 + 12 + # Error details 13 + 14 + ``` 15 + Error: expect(locator).toHaveCount(expected) failed 16 + 17 + Locator: locator('div[id^="card_"]') 18 + Expected: 1 19 + Received: 0 20 + Timeout: 5000ms 21 + 22 + Call log: 23 + - Expect "toHaveCount" with timeout 5000ms 24 + - waiting for locator('div[id^="card_"]') 25 + 9 × locator resolved to 0 elements 26 + - unexpected value "0" 27 + 28 + ``` 29 + 30 + # Page snapshot 31 + 32 + ```yaml 33 + - generic [ref=e3]: 34 + - banner [ref=e4]: 35 + - button "Toggle sidebar" [ref=e5]: 36 + - img [ref=e6] 37 + - button "Malden PorchFest 2026" [ref=e8] [cursor=pointer]: 38 + - text: Malden PorchFest 2026 39 + - img 40 + - textbox "Filter by name, genre, or address..." [active] [ref=e9]: hot lunch 41 + - link "Source code" [ref=e10] [cursor=pointer]: 42 + - /url: https://tangled.org/timryan.org/porchfest-at 43 + - img [ref=e11] 44 + - generic [ref=e12]: 45 + - complementary [ref=e13]: 46 + - generic [ref=e14]: 47 + - generic [ref=e15]: Saturday May 9th, 2026 48 + - link "↗ Website" [ref=e16] [cursor=pointer]: 49 + - /url: https://somervilleartscouncil.org/porchfest/ 50 + - generic [ref=e17]: 51 + - generic [ref=e18]: 52 + - button "All" [ref=e19] [cursor=pointer] 53 + - button "Live Now" [ref=e20] [cursor=pointer] 54 + - button "Upcoming" [ref=e21] [cursor=pointer] 55 + - generic [ref=e22]: 56 + - generic [ref=e23]: West · 12:00–2:00pm53 57 + - generic [ref=e24]: Central · 4:00–6:00pm36 58 + - generic [ref=e25]: East · 4:00–6:00pm56 59 + - generic [ref=e26]: 145 bands 60 + - generic [ref=e28]: "0 bands · West: 0 · Central: 0 · East: 0" 61 + - generic [ref=e29]: 62 + - generic [ref=e30]: West · 12:00–2:00pm 63 + - generic [ref=e32]: Central · 4:00–6:00pm 64 + - generic [ref=e34]: East · 4:00–6:00pm 65 + - generic [ref=e36]: Withdrawn 66 + - generic [ref=e38]: 67 + - heading "West · 12:00–2:00pm" [level=3] [ref=e40] [cursor=pointer]: West · 12:00–2:00pm 68 + - heading "Central · 4:00–6:00pm" [level=3] [ref=e43] [cursor=pointer]: Central · 4:00–6:00pm 69 + - heading "East · 4:00–6:00pm" [level=3] [ref=e46] [cursor=pointer]: East · 4:00–6:00pm 70 + - generic [ref=e49]: 71 + - generic: 72 + - generic: 73 + - img 74 + - generic: 75 + - generic [ref=e50]: 76 + - button "Zoom in" [ref=e51] [cursor=pointer]: + 77 + - button "Zoom out" [ref=e52] [cursor=pointer]: − 78 + - link "Leaflet" [ref=e54] [cursor=pointer]: 79 + - /url: https://leafletjs.com 80 + - img [ref=e55] 81 + - text: Leaflet 82 + - link "Leaflet" [ref=e59] [cursor=pointer]: 83 + - /url: https://leafletjs.com 84 + ``` 85 + 86 + # Test source 87 + 88 + ```ts 89 + 1 | // E2E test: Somerville PorchFest 2026 Band Data & Map 90 + 2 | // Usage: npm run test:e2e (run from project root) 91 + 3 | // 92 + 4 | // Tests are split into two groups: 93 + 5 | // LIVE — require the Vite dev server (started automatically by Playwright config). 94 + 6 | // OFFLINE — load bands.json from disk, no server needed (~0ms). 95 + 7 | 96 + 8 | const { test, expect } = require('@playwright/test') 97 + 9 | const fs = require('fs') 98 + 10 | const path = require('path') 99 + 11 | 100 + 12 | // ---- preload bands.json (used by offline + live tests) ---- 101 + 13 | 102 + 14 | const HOURLY_ZONES = {"12":"12PM","13":"1PM","14":"2PM","15":"3PM","16":"4PM","17":"5PM"} 103 + 15 | const SCALE = {"12PM":12,"1PM":13,"2PM":14,"3PM":15,"4PM":16,"5PM":17} 104 + 16 | 105 + 17 | function loadBands() { 106 + 18 | const city = process.env.TEST_CITY || "watertown" 107 + 19 | const raw = JSON.parse(fs.readFileSync(path.resolve(__dirname, '..', 'data', city, 'bands.json'), 'utf-8')) 108 + 20 | // Detect Water town-specific (hourly zones) vs zone-specific (West/Central/East) 109 + 21 | const isHourly = raw.some(b => !["West","Central","East"].includes(b.porch_zone)) 110 + 22 | const zones = {} 111 + 23 | for (const b of raw) { 112 + 24 | const zoneKey = isHourly 113 + 25 | ? String(parseInt(b.start_time.split(' ')[1].split(':')[0], 10)) 114 + 26 | : b.porch_zone 115 + 27 | if (!zones[zoneKey]) { 116 + 28 | zones[zoneKey] = [] 117 + 29 | } 118 + 30 | zones[zoneKey].push(b) 119 + 31 | } 120 + 32 | const hourlyBands = {} 121 + 33 | for (const h of ['12','13','14','15','16','17']) { 122 + 34 | hourlyBands[HOURLY_ZONES[h]] = zones[h] || [] 123 + 35 | } 124 + 36 | return { 125 + 37 | all: raw, 126 + 38 | hourly: hourlyBands, 127 + 39 | grouped: zones, 128 + 40 | withdrawn: raw.filter(b => b.is_withdrawn === 'true'), 129 + 41 | isHourly, 130 + 42 | } 131 + 43 | } 132 + 44 | const B = loadBands() 133 + 45 | 134 + 46 | // ---- helpers ---- 135 + 47 | 136 + 48 | function expectZoneStats(statsText) { 137 + 49 | expect(statsText).toContain(String(B.all.length)) 138 + 50 | expect(statsText).toContain('bands') 139 + 51 | } 140 + 52 | 141 + 53 | async function waitForDataLoaded(page) { 142 + 54 | // Wait for the stats element to show band count text (not "Loading" or "Error") 143 + 55 | await expect(page.locator('#stats span').first()).toBeVisible() 144 + 56 | await page.locator('#searchInput').waitFor({ state: 'visible' }) 145 + 57 | } 146 + 58 | 147 + 59 | test('app loads and renders map + band cards', async ({ page }) => { 148 + 60 | await page.goto('/') 149 + 61 | await waitForDataLoaded(page) 150 + 62 | 151 + 63 | // Verify stats text 152 + 64 | const statsText = await page.locator('#stats').first().textContent() 153 + 65 | await expectZoneStats(statsText) 154 + 66 | 155 + 67 | // Verify map dimensions (prevents the "map renders below sidebar" regression) 156 + 68 | const mapBox = await page.locator(".leaflet-container").boundingBox() 157 + 69 | expect(mapBox.height).toBeGreaterThan(50) 158 + 70 | 159 + 71 | // Verify band cards exist with correct count 160 + 72 | const cardCount = await page.locator('div[id^="card_"]').count() 161 + 73 | expect(cardCount).toBe(B.all.length) 162 + 74 | }) 163 + 75 | 164 + 76 | test('search filters by name, genre, and shows no results for unknown terms', async ({ page }) => { 165 + 77 | await page.goto('/') 166 + 78 | await waitForDataLoaded(page) 167 + 79 | 168 + 80 | const searchInput = page.locator('#searchInput') 169 + 81 | 170 + 82 | // Search by first band name 171 + 83 | await searchInput.fill(B.all[0].title) 172 + > 84 | await expect(page.locator('div[id^="card_"]')).toHaveCount(1) 173 + | ^ Error: expect(locator).toHaveCount(expected) failed 174 + 85 | 175 + 86 | // Search by genre 176 + 87 | const rockBands = B.all.filter( 177 + 88 | b => (b.title + ' ' + (b.genres || []).join(' ') + ' ' + (b.street || '')).toLowerCase().includes('rock') 178 + 89 | ) 179 + 90 | await searchInput.fill('Rock') 180 + 91 | await expect(page.locator('div[id^="card_"]')).toHaveCount(rockBands.length) 181 + 92 | 182 + 93 | // Search for something that doesn't exist 183 + 94 | await searchInput.fill('zzzzxyww-not') 184 + 95 | await expect(page.locator('div[id^="card_"]')).toHaveCount(0) 185 + 96 | }) 186 + 97 | 187 + 98 | test('map layout is correct flex children with indoor/outdoor zones', async ({ page }) => { 188 + 99 | await page.goto('/') 189 + 100 | await waitForDataLoaded(page) 190 + 101 | 191 + 102 | const meta = await page.evaluate(() => { 192 + 103 | const outerContainer = document.querySelector('.h-\\[100vh\\].w-\\[100vw\\].overflow-hidden.bg-\\[\\#0d0d0d\\].flex.flex-col') 193 + 104 | const innerContainer = outerContainer ? outerContainer.querySelector(':scope > div.flex.overflow-hidden') : null 194 + 105 | const innerChildren = innerContainer ? Array.from(innerContainer.children) : [] 195 + 106 | const sidebar = innerChildren?.some(n => n.tagName.toLowerCase() === 'aside') ? 'yes' : 'no' 196 + 107 | const mapPane = innerChildren?.some(n => n.classList.contains('flex-1') && n.id === 'mapid') ? 'yes' : 'no' 197 + 108 | const mapId = document.querySelector('.leaflet-container') 198 + 109 | const mapIdInContentOuter = mapId && (innerContainer ? mapId.closest('.h-\\[100vh\\].w-\\[100vw\\].overflow-hidden.bg-\\[\\#0d0d0d\\].flex.flex-col') === outerContainer : false) ? 'yes' : 'no' 199 + 110 | const tileCount = document.querySelectorAll('.leaflet-tile').length 200 + 111 | return { sidebar, mapPane, mapIdInContentOuter, tileCount, numChildren: innerChildren.length, hasHeader: outerContainer?.children[0]?.tagName.toLowerCase() === 'header' } 201 + 112 | }) 202 + 113 | expect(meta.sidebar).toBe('yes') 203 + 114 | expect(meta.mapPane).toBe('yes') 204 + 115 | expect(meta.mapIdInContentOuter).toBe('yes') 205 + 116 | expect(meta.tileCount).toBeGreaterThan(0) 206 + 117 | expect(meta.hasHeader).toBe(true) 207 + 118 | expect(meta.numChildren).toBe(2) 208 + 119 | }) 209 + 120 | 210 + 121 | test('search input has white text for visibility on dark background', async ({ page }) => { 211 + 122 | await page.goto('/') 212 + 123 | await waitForDataLoaded(page) 213 + 124 | const textColor = await page.locator('#searchInput').evaluate(el => getComputedStyle(el).color) 214 + 125 | expect(textColor).toEqual('rgb(255, 255, 255)') 215 + 126 | }) 216 + 127 | 217 + 128 | test('map zooms to city level (not world view)', async ({ page }) => { 218 + 129 | await page.goto('/') 219 + 130 | await waitForDataLoaded(page) 220 + 131 | await page.waitForTimeout(3000) 221 + 132 | 222 + 133 | const tileInfo = await page.evaluate(() => { 223 + 134 | const container = document.querySelector('.leaflet-container') 224 + 135 | const tiles = container?.querySelectorAll('.leaflet-tile') 225 + 136 | let zoom = null 226 + 137 | const firstSrc = tiles?.[0]?.src 227 + 138 | if (firstSrc) { 228 + 139 | const parts = firstSrc.split('/') 229 + 140 | const zoomStr = parts.filter(p => !isNaN(parseInt(p)))[0] 230 + 141 | if (zoomStr !== undefined) { 231 + 142 | zoom = parseInt(zoomStr); 232 + 143 | } 233 + 144 | } 234 + 145 | return { zoom } 235 + 146 | }) 236 + 147 | expect(tileInfo.zoom).toBeGreaterThan(10) 237 + 148 | }) 238 + 149 | 239 + 150 | // ============================================================ 240 + 151 | // OFFLINE TESTS (no server needed — load bands.json directly) 241 + 152 | // ============================================================ 242 + 153 | 243 + 154 | test('total band count is 145 with correct zone distribution', () => { 244 + 155 | const city = process.env.TEST_CITY || "watertown" 245 + 156 | if (city === "gloucester-magnolia") { 246 + 157 | expect(B.all.length).toBe(18) 247 + 158 | expect(B.grouped['Gloucester-Magnolia'].length).toBe(18) 248 + 159 | } else if (B.isHourly && city === "swampscott") { 249 + 160 | expect(B.all.length).toBe(36) 250 + 161 | expect(B.hourly['12PM'].length).toBe(1) 251 + 162 | expect(B.hourly['1PM'].length).toBe(7) 252 + 163 | expect(B.hourly['2PM'].length).toBe(7) 253 + 164 | expect(B.hourly['3PM'].length).toBe(8) 254 + 165 | expect(B.hourly['4PM'].length).toBe(10) 255 + 166 | expect(B.hourly['5PM'].length).toBe(3) 256 + 167 | } else if (B.isHourly && city === "watertown") { 257 + 168 | expect(B.all.length).toBe(205) 258 + 169 | expect(B.hourly['12PM'].length).toBe(49) 259 + 170 | expect(B.hourly['1PM'].length).toBe(30) 260 + 171 | expect(B.hourly['2PM'].length).toBe(44) 261 + 172 | expect(B.hourly['3PM'].length).toBe(36) 262 + 173 | expect(B.hourly['4PM'].length).toBe(33) 263 + 174 | expect(B.hourly['5PM'].length).toBe(13) 264 + 175 | } else { 265 + 176 | expect(B.all.length).toBe(145) 266 + 177 | expect(B.grouped['West'].length).toBe(53) 267 + 178 | expect(B.grouped['Central'].length).toBe(36) 268 + 179 | expect(B.grouped['East'].length).toBe(56) 269 + 180 | } 270 + 181 | }) 271 + 182 | 272 + 183 | test('every band has all required fields', () => { 273 + 184 | for (const band of B.all) { 274 + ```
+226
test-results/basic-total-band-count-is-145-with-correct-zone-distribution/error-context.md
··· 1 + # Instructions 2 + 3 + - Following Playwright test failed. 4 + - Explain why, be concise, respect Playwright best practices. 5 + - Provide a snippet of code with the fix, if possible. 6 + 7 + # Test info 8 + 9 + - Name: basic.spec.js >> total band count is 145 with correct zone distribution 10 + - Location: e2e/basic.spec.js:154:1 11 + 12 + # Error details 13 + 14 + ``` 15 + Error: expect(received).toBe(expected) // Object.is equality 16 + 17 + Expected: 145 18 + Received: 575 19 + ``` 20 + 21 + # Test source 22 + 23 + ```ts 24 + 76 | test('search filters by name, genre, and shows no results for unknown terms', async ({ page }) => { 25 + 77 | await page.goto('/') 26 + 78 | await waitForDataLoaded(page) 27 + 79 | 28 + 80 | const searchInput = page.locator('#searchInput') 29 + 81 | 30 + 82 | // Search by first band name 31 + 83 | await searchInput.fill(B.all[0].title) 32 + 84 | await expect(page.locator('div[id^="card_"]')).toHaveCount(1) 33 + 85 | 34 + 86 | // Search by genre 35 + 87 | const rockBands = B.all.filter( 36 + 88 | b => (b.title + ' ' + (b.genres || []).join(' ') + ' ' + (b.street || '')).toLowerCase().includes('rock') 37 + 89 | ) 38 + 90 | await searchInput.fill('Rock') 39 + 91 | await expect(page.locator('div[id^="card_"]')).toHaveCount(rockBands.length) 40 + 92 | 41 + 93 | // Search for something that doesn't exist 42 + 94 | await searchInput.fill('zzzzxyww-not') 43 + 95 | await expect(page.locator('div[id^="card_"]')).toHaveCount(0) 44 + 96 | }) 45 + 97 | 46 + 98 | test('map layout is correct flex children with indoor/outdoor zones', async ({ page }) => { 47 + 99 | await page.goto('/') 48 + 100 | await waitForDataLoaded(page) 49 + 101 | 50 + 102 | const meta = await page.evaluate(() => { 51 + 103 | const outerContainer = document.querySelector('.h-\\[100vh\\].w-\\[100vw\\].overflow-hidden.bg-\\[\\#0d0d0d\\].flex.flex-col') 52 + 104 | const innerContainer = outerContainer ? outerContainer.querySelector(':scope > div.flex.overflow-hidden') : null 53 + 105 | const innerChildren = innerContainer ? Array.from(innerContainer.children) : [] 54 + 106 | const sidebar = innerChildren?.some(n => n.tagName.toLowerCase() === 'aside') ? 'yes' : 'no' 55 + 107 | const mapPane = innerChildren?.some(n => n.classList.contains('flex-1') && n.id === 'mapid') ? 'yes' : 'no' 56 + 108 | const mapId = document.querySelector('.leaflet-container') 57 + 109 | const mapIdInContentOuter = mapId && (innerContainer ? mapId.closest('.h-\\[100vh\\].w-\\[100vw\\].overflow-hidden.bg-\\[\\#0d0d0d\\].flex.flex-col') === outerContainer : false) ? 'yes' : 'no' 58 + 110 | const tileCount = document.querySelectorAll('.leaflet-tile').length 59 + 111 | return { sidebar, mapPane, mapIdInContentOuter, tileCount, numChildren: innerChildren.length, hasHeader: outerContainer?.children[0]?.tagName.toLowerCase() === 'header' } 60 + 112 | }) 61 + 113 | expect(meta.sidebar).toBe('yes') 62 + 114 | expect(meta.mapPane).toBe('yes') 63 + 115 | expect(meta.mapIdInContentOuter).toBe('yes') 64 + 116 | expect(meta.tileCount).toBeGreaterThan(0) 65 + 117 | expect(meta.hasHeader).toBe(true) 66 + 118 | expect(meta.numChildren).toBe(2) 67 + 119 | }) 68 + 120 | 69 + 121 | test('search input has white text for visibility on dark background', async ({ page }) => { 70 + 122 | await page.goto('/') 71 + 123 | await waitForDataLoaded(page) 72 + 124 | const textColor = await page.locator('#searchInput').evaluate(el => getComputedStyle(el).color) 73 + 125 | expect(textColor).toEqual('rgb(255, 255, 255)') 74 + 126 | }) 75 + 127 | 76 + 128 | test('map zooms to city level (not world view)', async ({ page }) => { 77 + 129 | await page.goto('/') 78 + 130 | await waitForDataLoaded(page) 79 + 131 | await page.waitForTimeout(3000) 80 + 132 | 81 + 133 | const tileInfo = await page.evaluate(() => { 82 + 134 | const container = document.querySelector('.leaflet-container') 83 + 135 | const tiles = container?.querySelectorAll('.leaflet-tile') 84 + 136 | let zoom = null 85 + 137 | const firstSrc = tiles?.[0]?.src 86 + 138 | if (firstSrc) { 87 + 139 | const parts = firstSrc.split('/') 88 + 140 | const zoomStr = parts.filter(p => !isNaN(parseInt(p)))[0] 89 + 141 | if (zoomStr !== undefined) { 90 + 142 | zoom = parseInt(zoomStr); 91 + 143 | } 92 + 144 | } 93 + 145 | return { zoom } 94 + 146 | }) 95 + 147 | expect(tileInfo.zoom).toBeGreaterThan(10) 96 + 148 | }) 97 + 149 | 98 + 150 | // ============================================================ 99 + 151 | // OFFLINE TESTS (no server needed — load bands.json directly) 100 + 152 | // ============================================================ 101 + 153 | 102 + 154 | test('total band count is 145 with correct zone distribution', () => { 103 + 155 | const city = process.env.TEST_CITY || "watertown" 104 + 156 | if (city === "gloucester-magnolia") { 105 + 157 | expect(B.all.length).toBe(18) 106 + 158 | expect(B.grouped['Gloucester-Magnolia'].length).toBe(18) 107 + 159 | } else if (B.isHourly && city === "swampscott") { 108 + 160 | expect(B.all.length).toBe(36) 109 + 161 | expect(B.hourly['12PM'].length).toBe(1) 110 + 162 | expect(B.hourly['1PM'].length).toBe(7) 111 + 163 | expect(B.hourly['2PM'].length).toBe(7) 112 + 164 | expect(B.hourly['3PM'].length).toBe(8) 113 + 165 | expect(B.hourly['4PM'].length).toBe(10) 114 + 166 | expect(B.hourly['5PM'].length).toBe(3) 115 + 167 | } else if (B.isHourly && city === "watertown") { 116 + 168 | expect(B.all.length).toBe(205) 117 + 169 | expect(B.hourly['12PM'].length).toBe(49) 118 + 170 | expect(B.hourly['1PM'].length).toBe(30) 119 + 171 | expect(B.hourly['2PM'].length).toBe(44) 120 + 172 | expect(B.hourly['3PM'].length).toBe(36) 121 + 173 | expect(B.hourly['4PM'].length).toBe(33) 122 + 174 | expect(B.hourly['5PM'].length).toBe(13) 123 + 175 | } else { 124 + > 176 | expect(B.all.length).toBe(145) 125 + | ^ Error: expect(received).toBe(expected) // Object.is equality 126 + 177 | expect(B.grouped['West'].length).toBe(53) 127 + 178 | expect(B.grouped['Central'].length).toBe(36) 128 + 179 | expect(B.grouped['East'].length).toBe(56) 129 + 180 | } 130 + 181 | }) 131 + 182 | 132 + 183 | test('every band has all required fields', () => { 133 + 184 | for (const band of B.all) { 134 + 185 | expect(band.title).toBeTruthy() 135 + 186 | expect(band.start_time).toBeTruthy() 136 + 187 | expect(band.porch_zone).toBeTruthy() 137 + 188 | expect(band.is_withdrawn).toBeTruthy() 138 + 189 | } 139 + 190 | }) 140 + 191 | 141 + 192 | test('zones are hourly time slots for Watertown (12PM–5PM) or geographic for other cities', () => { 142 + 193 | if (B.isHourly) { 143 + 194 | const zones = B.all.map(b => { 144 + 195 | const hour = parseInt(b.start_time.split(' ')[1].split(':')[0], 10) 145 + 196 | return HOURLY_ZONES[String(hour)] 146 + 197 | }) 147 + 198 | const uniqueZones = [...new Set(zones)].sort((a, b) => (SCALE[a] || 0) - (SCALE[b] || 0)) 148 + 199 | expect(uniqueZones).toEqual(Object.values(HOURLY_ZONES)) 149 + 200 | } else { 150 + 201 | const city = process.env.TEST_CITY || "watertown" 151 + 202 | if (city === "gloucester-magnolia") { 152 + 203 | const zones = B.all.map(b => b.porch_zone) 153 + 204 | const uniqueZones = [...new Set(zones)] 154 + 205 | expect(uniqueZones).toEqual(["Gloucester-Magnolia"]) 155 + 206 | } else { 156 + 207 | const zones = B.all.map(b => b.porch_zone) 157 + 208 | const uniqueZones = [...new Set(zones)].sort() 158 + 209 | expect(uniqueZones).toEqual(["Central","East","West"]) 159 + 210 | } 160 + 211 | } 161 + 212 | }) 162 + 213 | 163 + 214 | test('zone stats text matches correct counts', () => { 164 + 215 | const statsText = B.all.length + ' bands' 165 + 216 | expect(statsText).toContain(String(B.all.length)) 166 + 217 | if (B.isHourly) { 167 + 218 | for (const z of Object.values(HOURLY_ZONES)) { 168 + 219 | expect(B.hourly[z].length).toBeGreaterThan(0) 169 + 220 | } 170 + 221 | expect(B.all.length).toBe(Object.values(B.hourly).reduce((s, arr) => s + arr.length, 0)) 171 + 222 | } else { 172 + 223 | const city = process.env.TEST_CITY || "watertown" 173 + 224 | if (city === "gloucester-magnolia") { 174 + 225 | expect(Object.keys(B.grouped).length).toBe(1) 175 + 226 | expect(B.all.length).toBe(Object.values(B.grouped).reduce((s, arr) => s + arr.length, 0)) 176 + 227 | } else { 177 + 228 | for (const z of ["West","Central","East"]) { 178 + 229 | expect(B.grouped[z].length).toBeGreaterThan(0) 179 + 230 | } 180 + 231 | expect(B.all.length).toBe(Object.values(B.grouped).reduce((s, arr) => s + arr.length, 0)) 181 + 232 | } 182 + 233 | } 183 + 234 | }) 184 + 235 | 185 + 236 | test('withdrawn bands have is_withdrawn "true"', () => { 186 + 237 | for (const band of B.withdrawn) { 187 + 238 | expect(band.is_withdrawn).toBe('true') 188 + 239 | } 189 + 240 | }) 190 + 241 | 191 + 242 | test('all bands have valid start_time and end_time', () => { 192 + 243 | for (const band of B.all) { 193 + 244 | expect(band.start_time).toMatch(/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/) 194 + 245 | expect(band.end_time).toMatch(/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/) 195 + 246 | } 196 + 247 | }) 197 + 248 | 198 + 249 | 199 + 250 | 200 + 251 | test('band card sidebar row badge renders as markup (not broken string) for withdrawn bands', async ({ page }) => { 201 + 252 | await page.goto('/') 202 + 253 | await waitForDataLoaded(page) 203 + 254 | 204 + 255 | const statsText = await page.locator('#stats span').first().textContent() 205 + 256 | const hasWithdrawn = /withdrawn/.test(statsText) 206 + 257 | const hasWD = await page.evaluate(() => { 207 + 258 | const cards = document.querySelectorAll('[class*="mx-1 my-1"]') 208 + 259 | for (const card of cards) { 209 + 260 | if (card.textContent.includes('[WD]')) { 210 + 261 | const childSpans = card.querySelectorAll('span') 211 + 262 | for (const span of childSpans) { 212 + 263 | if (span.textContent.includes('[WD]') && span.className.includes('text-red-400')) { 213 + 264 | return true 214 + 265 | } 215 + 266 | } 216 + 267 | } 217 + 268 | } 218 + 269 | return false 219 + 270 | }) 220 + 271 | // Watertown has 0 withdrawn bands, so hasWD should be false 221 + 272 | expect(hasWD).toBe(hasWithdrawn) 222 + 273 | }) 223 + 274 | 224 + 275 | test('sidebar toggle button controls visibility on desktop', async ({ page }) => { 225 + 276 | await page.goto('/') 226 + ```
+7
vite.config.js
··· 46 46 fs.mkdirSync(path.join(dstDir, 'watertown', 'atproto-events'), { recursive: true }) 47 47 fs.copyFileSync(path.join(srcDir, 'watertown', 'atproto-events', 'events.json'), path.join(dstDir, 'watertown', 'atproto-events', 'events.json')) 48 48 } 49 + if (fs.existsSync(path.join(srcDir, 'swampscott', 'bands.json'))) { 50 + fs.mkdirSync(path.join(dstDir, 'swampscott'), { recursive: true }) 51 + fs.copyFileSync(path.join(srcDir, 'swampscott', 'bands.json'), path.join(dstDir, 'swampscott', 'bands.json')) 52 + } 53 + if (fs.existsSync(path.join(srcDir, 'swampscott', 'city.geojson'))) { 54 + fs.copyFileSync(path.join(srcDir, 'swampscott', 'city.geojson'), path.join(dstDir, 'swampscott', 'city.geojson')) 55 + } 49 56 }, 50 57 }, 51 58 ],