Easy-to-manage touch screens
Free, open-source touch screens. Guided USB setup, a drag-and-drop editor and local control. Made for Home Assistant.
Anglophone-friendly weekends in Montreal, with an itinerary builder and poster capture.
LifestyleJavaScript · HTMLGitHub @lifelike-and-believableOnlineOwner unconfirmed
Have you tried it? One tap is enough. No sign-in needed.
No updates detected yet. GitHub releases will appear here.
Owners of public repositories under a personal GitHub account can sign in to verify ownership and manage the introduction themselves. No administrator approval is needed.
Sign in, generate a verification file, and commit it to this repository’s default branch within 30 minutes. Files on other branches or forks do not count. Then check the file below to manage the project without administrator approval. Keep the file to retain your access.
If needed, you will go to sign-in. After signing in, return here and press your chosen verification button again.
Run /nomorevibe verify in your project folder to verify ownership and manage the introduction yourself.
Verify in one minuteAsk about other verification options
The maker's introduction consists of the line above. Read the repository README for more details. Repository README ↗
Loading the full README.
Read the full README ↗The Weekend Board, ported off the claude.ai artifact runtime onto Vercel, as an installable PWA.
public/index.html is the board. Through the port, the lines between the
claude-shim.js tag and the closing </script> were kept byte-identical
to the artifact source, so the board's own code could run unmodified while
everything around it was rewritten. The artifact has since been retired,
and that constraint with it — the board is now ordinary source, edited in
place like anything else here.
Most of the porting still sits outside it, because that is where it belongs, not because it has to:
public/claude-shim.js reimplements the three capabilities the board
asks the host for (db, assets, sample) against this app's own API.env(safe-area-inset-*) where standalone mode
needs it. It does not touch the sticky filter bar's height — see the
v12/v15 note in the board source..hidden on those sections and refills their grid,
so a class on the <section> and a count read off the grid both survive
every re-render — no board edit needed. Unlike a folded day, the choice
is not scoped to a weekend: it is a standing preference.__BOARD_FRESH__ and lives outside the board's script, like everything
else here.public/index.html the board (shell + unmodified board code)
public/claude-shim.js db / assets / sample shim
public/gate.js passcode gate
public/sw.js shell cache, /api never cached
public/manifest.webmanifest
public/icons/ app icons
api/db.js document read/write
api/upload.js poster photo upload
api/read-poster.js Anthropic vision proxy (holds the API key)
api/read-page.js the same read for an event's web page:
fetches the link, answers in the poster's shape
lib/page.js where a page fetch may go, and what of the
HTML the model gets to read
api/blob.js serves /_blob/<photoId>, the asset path
the board builds for poster photos
api/data.js serves /board-data.js — the four data
blocks, loaded before the board script,
and __BOARD_FRESH__ saying which path it
took, so a dead refresh can be seen
api/refresh.js the daily refresh POSTs the blocks here,
and prunes what the last weekend left
lib/busy.js cuts the owner's calendar to the
weekend's days, in Montreal time
api/calendar.js a fresh calendar read on demand
api/login.js api/session.js
api/health.js config presence check, booleans only
lib/auth.js signed cookie, one-year expiry
lib/store.js Redis documents, Blob photos
lib/prune.js reclaims expired records, their photos,
and stale plan documents
lib/location.js reads the free-text `hood` into a
structured `loc`, additively
lib/metro.js station -> line, verified, ASCII-keyed
lib/plan.js which days to plan (weekend + days off) and
where the owner is; pure, no I/O
lib/sources.js per-place list of good listing sources,
Montreal included
lib/home-sources.js the Montreal list's seed, read until
sources/montreal is first written
api/trip.js the routine's endpoint: plan the week,
keep a place's sources
tests/ node --test; `npm test` runs them all
A Google Calendar day off in the coming week is planned like a weekend day. And when the calendar shows the days are spent somewhere else, the listings are for there, with a running list of good sources for that place. Montreal keeps its list the same way.
The routine that reads Google Calendar and writes the listings lives outside this repo and must be updated by the owner. This repo only stores and serves the data and holds the rules; nothing changes until the routine follows the checklist below.
WEEKEND = { id, label, updated, days:[{key,date,name,num,kind}], place? }
id is the Saturday's ISO date. days are sorted by date, unique by
key and date. key is sun|mon|tue|wed|thu|fri|sat; name is
"Friday", num is "Fri 11 Sep"; kind is weekend (Fri/Sat/Sun) or
dayoff (Mon-Fri). The weekend opens on the Friday evening before it: that
day is kind:"weekend" with evening:true (research from about 5 p.m.),
unless Friday is a day off, when it is dayoff and planned all day.
/api/refresh fills in whatever is missing and sorts them.place is only present in travel mode:
{ name, region?, country?, slug, lat?, lon?, tz? }. Absent means home
(Montreal). /board-data.js also carries SOURCES: up to 8
[{name,url,kind}] for WEEKEND.place.slug, or for Montreal at home
(empty if the sources cannot be read, when the footer keeps the links
the page shipped with)./api/refresh stores AWAY empty (the listings are for
where the owner is, so "away from Montreal" must not dim them) and keeps
BUSY.api/trip.js, owner or refresh-job auth)GET /api/trip places with a list: [{slug,name,count,updatedAt}]
GET /api/trip?place=<slug> { place, updatedAt, sources } best first, dead flagged;
`home` or `montreal` is the Montreal list
POST /api/trip {op:"plan", events, away, today?}
the plan (days, label, dayOffs, place,
placeSource, weekend) plus `sources`
for the place (all of Montreal's at
home, the best 20 away); `today` is
YYYY-MM-DD, default the current
Montreal date
POST /api/trip {op:"sources", place:{name,region,country} | "home",
sources:[{url,name,kind,covers,note}], used:[url], dead:[url],
pin?, unpin?, require?, unrequire?, remove?}
owner or refresh job; merges and stores,
returns {slug,count,added,used,dead,pinned}.
The five edit lists (urls) are the
owner's only
POST /api/trip {op:"alias", alias, place}
refresh job only; files another name
under that place's list
Bodies are capped (100 events, 50 away spans, 50 sources, 100 used/dead
urls). Errors are {code,error} with 400/401/405.
lib/plan.js)eventType: "outOfOffice", dayOff: true, or a
single-day timed event of 6 hours or more) and says so: its title matches
DAY_OFF (day(s) off, off work, pto, time off, vacation, holiday, ooo, out
of office, personal day, congé, bank holiday, long weekend), or it is
out-of-office / flagged dayOff. A one-hour "Holiday party" is not a day off.
Edit the keywords in the one exported DAY_OFF constant.where / place, a single
word is fine); an event location (street addresses and postal codes are
dropped, then country, then region; a lone segment such as "Fenway Park"
could be a venue and is ignored); and titles shaped like
(trip|flight|train|drive|road trip|visit|weekend|stay|vacation|holiday|getaway|hotel|airbnb|travel)
AWAY[].where (trusted),
or set WEEKEND.place directly on /api/refresh.lib/places.js). "NYC", "New York City",
"Brooklyn, NY" and "Manhattan" are all New York, NY, with the fixed slug
new-york-ny; so are "DC", "Philly", "SF" and the other well-known ones in
the table there. A region or country that disagrees rules a match out
(Boston, Lincolnshire is not Boston, MA). "Trip to NYC" is read as a place
even though three capitals are otherwise an airport code. For a place the
table does not know, the routine files another name with
{op:"alias", alias, place}; plan, sources and GET ?place= then
follow it to the one list. Add to the table by hand when a place earns it;
its slug is stored with it, so editing the table never moves a list.sources/<slug> document also holds what the owner
has learned of a place, so a return trip starts from it rather than from a
search of the region. visits records each trip once per weekend
(POST /api/trip {op:"visit", place, weekendId, from?, to?}, which also
folds the owner's adds and stars for that weekend, read from
taste/profile, into personal: title, category, venue, add or star;
hides are never recorded). holds short travel notes in five
sections, , , , and
(): a note
with the same name replaces the old one, a section keeps its newest few
(12, 8, 8, 12, 8), takes a note out. is a neighbourhood
guide: each note is a couple of sentences (up to 420 characters, against
240 elsewhere) on a neighbourhood's character and what it has for the
owner's interests, read from their adds and stars; never dated events. returns
(, , , , ,
from earlier trips) for a place, null at home, and
returns the lot. A first visit is one with no trip on
an earlier weekend; the routine does a seeding pass on it (sources and a
guide) and leans on the ledger on a return.POST /api/trip {op:"plan", events, away} (events as {title,start,end,location,eventType,dayOff};
away as {start,end,where} for flights, hotels and trips).days (day-off days get
the same treatment as weekend days) and, if place is set, for that place
rather than Montreal. Search the returned sources first (at home, the
Montreal list; update runs fetch every required one), then discover
and vet new ones.POST /api/trip {op:"sources", place, sources, used, dead} with the new
good sources and the urls that worked (used) or failed (dead);
place is "home" at home.POST /api/refresh as before, with WEEKEND set to the plan's
weekend plus updated (ISO timestamp), and the usual blocks and BUSY.npx vercel --prod
The Vercel project montreal-weekend-board already exists under the
Lifelike Dev Team, and a smoke deploy has validated the build config
(outputDirectory: public plus root api/).
In the Vercel project settings:
| Variable | What it is |
|---|---|
AUTH_SECRET | openssl rand -base64 32. Signs the session cookie. |
BOARD_PASSCODE | The passcode you type on first launch. |
DOC_SALT | openssl rand -hex 16. Namespaces documents and photo paths. |
REFRESH_SECRET | openssl rand -base64 32. Bearer token for the daily refresh job. |
CALENDAR_SECRET | openssl rand -base64 32. Bearer token for the calendar routine only. Until it is set, REFRESH_SECRET is accepted there as well. |
ANTHROPIC_API_KEY | Poster and event-page reading. |
ANTHROPIC_MODEL | Optional. Defaults to claude-sonnet-5-5, the current Sonnet. Leave it unset to follow the code's default; set it only to pin a different model. |
CALENDAR_ROUTINE_URL | Optional. The calendar routine's API trigger URL (see "Refreshing the calendar by hand"). |
CALENDAR_ROUTINE_TOKEN | Optional. That trigger's token. |
The daily scheduled task and the calendar routine each have a token that does
only its job. REFRESH_SECRET (the daily task) can read this weekend's
listings (/api/data), post a refresh (/api/refresh), plan days off and
keep a place's sources (/api/trip), read the taste profile, and read and write captured posters
under spotted/ through /api/db. It cannot read or change plans or the
board directly, delete anything, upload or read photos, or read posters
(lib/scope.js holds the list for /api/db). CALENDAR_SECRET (the calendar routine) can
read and store the calendar through /api/calendar and nothing else. Only the
signed-in board can start a calendar refresh. Both read web pages, so a
page that tries to steer one gets no more than its own token allows. To
rotate a token, change it in Vercel, redeploy, and change it in the task or
routine that holds it.
Plus two Storage integrations (Vercel dashboard, Storage tab):
KV_REST_API_URL / KV_REST_API_TOKEN.
Documents. Redis rather than Blob because blob reads come off a CDN,
and a stale read would make a just-saved poster vanish from the board.BLOB_READ_WRITE_TOKEN. Poster photos.And turn Deployment Protection → Vercel Authentication OFF for this project. It is on by default for the team and its SSO redirect breaks the home-screen app. The passcode gate replaces it.
/api/health reports which of these are present.
The station on each row carries a dot per line, using the same dot the category chips use. A dot rather than tinted text: the yellow line has no readable text colour on this background, and recolouring the text would make the greyest thing on the card the loudest. The text is unchanged.
Only records the refresh wrote have loc, so only they get dots. The
inline fallback listings and captured posters render as they always did.
The itinerary already worked out the gap between consecutive picks and called anything under half an hour "to get across town — tight". It said that whether the two things were on opposite sides of the island or on the same street, because nothing in the board knew where anything was.
With loc it does, so the same line now says what is true:
30 min free · both in Mile End
15 min — direct on the orange line, tight
15 min to get across town — tight
15 min between them — tight (when either end is unplaced)
No distance is computed and no journey time is invented — nothing here knows how far apart two addresses are. It knows only whether two picks share a neighbourhood, a station, or a line, which is enough to stop the board calling a walk down the street a trip across town. An overlap is still an overlap, and a comfortable gap only names the relationship when there is something useful to say.
The poster read is synchronous: you photograph one, api/read-poster.js
reads it, and the form comes back filled in. When that read does not yield
a title and a date, the record is held off the board and listed under
"Needs filling in" until you complete it.
It used to say the record was "queued for the morning" and that the refresh would read it later. Nothing ever did — no server path has ever looked at a spotted record, and the only one that touches them now is the prune. The wording says what actually happens, and a test asserts the promise cannot creep back while no server code could keep it.
Three things went with that, all carried since the artifact:
thaw(), which defensively copied every snapshot because the artifact
host froze them. The shim does not, so it had been a no-op since the
port; a test pins that the shim still does not freeze.photoData, a base64 copy of the poster inside the record, kept so the
never-existent later read would have something to look at. Photos are
served from /_blob/<photoId>. A record captured before this whose
upload had failed loses its thumbnail — it keeps everything else.thumbDataUrl, which built that copy.A poster prints jackalope.co/festival, not https://jackalope.co/festival.
Stored as printed and handed to an href, that is a path on this site: the
one link the poster carried opens a page of our own that does not exist.
normUrl puts the scheme back on anything shaped like a host, completes a
protocol-relative link, and passes http, https, mailto and tel
through as typed.
Everything else comes back empty, on purpose. The "url" line on a poster is
often not a URL — "see the poster", "@venue_mtl", a phone number — and
prefixing those with https:// makes a card that looks clickable and is
not. titleLink then renders the title as plain text rather than as
<a href="">, which reloads the board. Records are normalised both on the
way in (the poster read, the capture form) and on the way out to a card, so
a record saved before any of this still gets a working link.
The field is now a plain text input: type="url" marks a bare host invalid
and offers nothing in return, given the scheme is added here anyway.
"Add a poster" also takes a link: paste an event's page under the photo
buttons and api/read-page.js fetches it, cuts it down to the title, the
meta and Open Graph lines, any schema.org JSON-LD (ticketing sites put the
date, venue and price there in plain fields) and the visible text, and
asks for the same JSON a poster read returns. The same form comes back,
to check before saving.
source: "page" and pageUrl
(the URL the page landed on after redirects). Its title links to the
ticket link the page gave, or to the page itself. Cards say "From a web
page"; a refresh twin says "Your link, now listed".photoId stays empty.Photograph a poster on Tuesday and by Saturday the morning refresh may well have found the organisers' own listing for the same night — with the showtimes, the price and a link the poster never printed. Left alone the board shows that evening twice, and the weaker of the two copies is the one read off a photograph in the street.
So the refreshed listing takes the slot and the poster record steps aside, handing over its Edit button: the photograph, the dates as printed and the record itself are all still there behind it, and the card carries a "Poster, now listed" tag instead of "Not checked yet" — the listing is the check the poster was waiting for. Nothing is written and nothing is deleted. The match is worked out fresh on every render, so a listing absent from the next morning's refresh brings the poster card straight back.
Matching is deliberately narrow, because a twin claimed too eagerly loses an event off the board entirely:
Picks and hidden state survive the hand-over on their own: both are keyed by day, category and title slug rather than by record id.
The three highlights are chosen by the refresh from taste/profile, which
used to learn only from what went on the plan. That misses the thing you
would have gone to if Saturday had gone differently. Every entry (timed
rows, the weekend-long and out-of-town cards, the highlights themselves)
now carries a ☆ next to its ✕. A star says "I like this", with no claim
that you are going.
A star lands in taste/profile exactly as an add does: the category,
venue, free-or-ticketed and out-of-town tallies all move, and it goes
into recent with via: "star". It is counted once per thing per
weekend, so unstarring and starring again does not inflate it, and a star
on something already added marks that entry starred: true rather than
counting twice. Unstarring, like removing a pick, takes nothing back out of
the profile. totalStars sits beside totalAdds, and the sync between
devices now compares the two together, so a device that has only starred
things still wins against an empty profile.
Which things are starred is per weekend, kept with the picks and hidden
entries in plans/<weekendId>. The ★ Starred pill in the filter bar
narrows the board to them; Reset clears it.
The refresh routine reads the profile from this app's store
(POST /api/db with {"op":"get","path":"taste/profile"} and its bearer
token), not from the retired artifact's database. via tells a star from
an add if it wants to weigh them differently.
The daily refresh is a Claude scheduled task ("Daily Refresh", set not to require the owner's computer, so it runs in the cloud). Its instructions are kept in docs/routine-prompt.md, so a change to them is reviewed with the code. The task runs the copy stored in its own instructions, so paste the file there after editing it. The calendar refresh below is the one that is a routine, at claude.ai/code/routines.
Picks can collide with things the board has never heard of: a trip, a
lunch, a dentist. The daily refresh task already holds the Google
Calendar connector, so it reads the weekend's events from the owner's
calendar and sends them as a fifth block, BUSY, beside the listings:
"BUSY": [
{ "id": "…", "title": "Trip to Quebec City",
"start": "2026-09-26", "end": "2026-09-28" }, all day, end exclusive
{ "id": "…", "title": "Dentist", "location": "Plateau",
"start": "2026-09-26T10:30:00-04:00",
"end": "2026-09-26T13:00:00-04:00" }
]
lib/busy.js cuts each one to the weekend's days in Montreal time, so a
trip from Friday noon to Sunday night becomes a partial Friday, an all-day
Saturday and a partial Sunday. Anything outside the weekend is dropped.
A run that sends no BUSY at all (the calendar could not be read) keeps
what the last run found for the same weekend, so a failed read never makes
the weekend look free. An empty array is an answer, and clears it.
On the board:
dur or two
hours; an all-day entry catches every showing that day) says so above
its Add button, which is dashed"+ Calendar" on the itinerary puts a pick into the same calendar, so the next morning it would come back as a clash with itself. A calendar entry whose title answers a listing's (the same matching the poster hand-over uses, or exactly "Title (Sub)" as that button names it) is treated as the listing and not shown.
A calendar event's location is usually a street address or a venue name,
which the location parser (built for hood) cannot place. So the routine,
which knows the city as well as it knows the listings, adds a hood in
the listings' own format when an event is in Montreal and it is sure:
{ "title": "Coffee with Sam", "location": "Café Olimpico, 124 rue Saint-Viateur O",
"hood": "Mile End · métro Laurier", ... }
busyForWeekend reads it into loc with the same parseHood the
listings go through. The entry then shows its neighbourhood and métro
line dots in "On your calendar" and in the itinerary, and the itinerary's
gap lines measure to and from it like any pick ("15 min — both in Mile
End", "direct on the orange line, tight"). An entry on another clock
keeps no hood: a Montreal station on a Revelstoke gig is a mistake.
An event with a time zone of its own (the routine sends Google's
timeZone for each) that keeps a different clock from Montreal's is cut
into days and shown in that clock: a Revelstoke gig reads "8:10 p.m. –
8:30 p.m. PDT" on the Saturday it happens there, not 11:10 p.m. here, or
after midnight on Sunday. Zones that keep Montreal's clock (New York) are
home. These entries are never compared with Montreal listings.
What they are compared against is AWAY: when you are out of town and nothing in Montreal is on the cards. The routine judges it from the calendar (flights out and back, hotel stays, trips, events in other cities) and sends it as stretches:
"AWAY": [{ "start": "2026-09-25T07:30:00-04:00",
"end": "2026-09-28T09:00:00-04:00", "where": "Revelstoke" }]
Any timed event in another clock counts as away too, whether or not the
routine says so. awayForWeekend cuts it all per day in Montreal time
and merges overlaps. BUSY and AWAY come from the same read, so a run keeps
or replaces them together.
On the board an away stretch sits first in "On your calendar", hatched; the day header says Away · Revelstoke, Away from 7:30 a.m. or Back 3:00 p.m.; listings inside it have dashed buttons and a line saying so, on every row, since a day is often opened straight from its fold; the itinerary carries it like any calendar entry and flags a pick inside it. ✕ ignores a stretch on this board, as it does a calendar entry.
The morning run reads the calendar once. Something added at lunch shows up the next day unless you ask: the stamp under the masthead says "calendar checked 7:05 a.m. · check again", and check again starts a small routine of its own ("Weekend Board: refresh calendar") that reads Google Calendar and posts the result back. The board checks every few seconds and redraws when the new read lands, usually within a minute or two.
GET /api/calendar owner or job: days, BUSY, busyAt
POST /api/calendar {op:"refresh"} owner: start the routine
POST /api/calendar {op:"store", weekendId, BUSY}
job: replace BUSY, move busyAt
Each run counts against the account's daily routine allowance, so a second tap within three minutes is refused rather than starting another. A read made for last weekend is refused if the board has moved on. The listings are never touched.
Setup, once. The routine is started through its API trigger, whose token can only be made in the claude.ai web UI:
CALENDAR_ROUTINE_URL (that URL) and
CALENDAR_ROUTINE_TOKEN (that token), and redeploy.The routine's instructions are kept in docs/routine-calendar-prompt.md; paste them into the routine after editing the file.
Until both are set the board shows when the calendar was read but offers
no refresh. /api/health reports calendarRoutine.
The refresh prunes as it writes. It is the only moment the app knows the weekend has turned over, so that is where reclaiming happens:
dropSpotted deletes the
document and nothing ever deleted the blob — once they are more than 7
days old, so a photo uploaded mid-capture is never takenplans/<weekendId> documents older than 120 daysA record with no date on it is never touched: that is a poster still
waiting to be read. Nothing here can fail a refresh — the listings are
already stored by then, and problems come back in pruned.errors.
# what a run would remove, without removing it
curl -X POST https://<host>/api/refresh \
-H "authorization: Bearer $REFRESH_SECRET" \
-H "content-type: application/json" \
-d '{"pruneDryRun":true, ...blocks}'
"prune": false skips it entirely. npm test covers the rules above,
the freshness banner, the service worker's cache behaviour, the location
parsing, and the two board helpers — which are lifted straight out of
index.html and run as they ship.
hood is one free-text field doing four jobs — neighbourhood, street
address, metro station, and the occasional venue note — so the same place
arrives spelled several ways. Across one weekend's listings, "Mile End"
appears as three different strings, and 32 distinct hood values cover
only 12 real neighbourhoods.
The refresh now reads each record's hood into an added loc:
hood: "Mile End · métro Laurier" unchanged, still rendered verbatim
loc: { area: "Mile End", areaKey: "mile-end",
metro: "Laurier", metroKey: "laurier",
lines: ["orange"], line: "orange" }
Strictly additive — hood keeps the text it arrived with, because the
board renders that string directly. POST /api/refresh reports coverage
in located. If the location layer throws, the listings are stored
unstructured rather than not at all.
lines holds every line the station serves, in line order, and the board
draws one dot each — most interchanges serve two of the four, and which
two is worth seeing. line is additionally set when there is exactly one.
A station the table does not know gets neither and renders as plain text:
a missing dot is invisible, a wrong one is a claim about the city that a
Montrealer will catch. lib/metro.js was checked against Wikipedia's station list rather
than written from memory, and a test asserts every station in the shipped
listings resolves.
Only what the refresh writes gets this. The inline fallback blocks and
captured-poster records keep their free-text hood and render exactly as
they do today.
loc.areaKey would make it work: easy transit and a car make filtering
by area much less useful than seeing where a thing is.guideareastransitstaystipshoods{op:"guide", place, areas?, transit?, stays?, tips?, hoods?, remove?}removehoodsplanledgerfirstVisitvisitsfirstVisitedAtlastVisitedAtguidepersonalGET ?place=<slug>/board-data.js carries it as GUIDE (names and notes only,
null when the ledger has nothing yet), and the panel is hidden at home.
Notes are corrected through the routine (remove), not from the board.sources/<slug>; https only, urls normalised
(lowercase host, no hash, no utm_*, no trailing slash); at most 40
unpinned per place. Rank is pinned first, then uses - 2*misses; an
unpinned source with 3+ misses and more misses than uses sinks and is
flagged status:"dead". Over the cap the lowest score is dropped, never
a used source while an unused one exists.pin:[url], up to 60 a place) and it is
never dropped or flagged dead, and does not count against the cap; its
misses are still counted, so a broken one shows. required marks a source
an update run must fetch. What the refresh job sends never sets either.sources/montreal. Until it is first written it reads as
the seed in lib/home-sources.js (every entry pinned, the update-run
ones required, out-of-town ones covering afield); the first write
stores the seed with it, and editing the seed after that changes nothing.
To add a Montreal source, sign in and
POST /api/trip {op:"sources", place:"home", sources:[{url,name,kind,note}], pin:[url], require:[url]} (drop require unless update runs must fetch it).