一間成立 35 年的家居護理公司如何打造自家的家庭入口網站
How a 35-year-old home care company built its own family portal
Integracare 為家居護理服務的客戶家屬打造 Family Room 私人網上入口網站,讓家屬查看護理員行程、探訪資料、照護記錄、帳單及付款,並向相關人員提出請求。入口網站只讀取 AlayaCare 資料;家屬以電郵登入連結登入,連結只可使用一次並於 15 分鐘後失效。當 AlayaCare 無法連線時,入口網站會顯示最近一次成功同步的資料。
When someone you love gets care at home, the questions start at once. Who is coming tomorrow? Did the caregiver make it this morning? What did they notice? For most home care companies the answer is a phone call to the office, and the office is busy.
Integracare has provided nurse-led home care in Ontario since 1991, and now in Winnipeg too. In September 2026 we launched the Family Room, a private web portal where a client's family can see:
- who is coming, and when, by day, week or month
- the caregivers who visit, with their photos, languages and training
- notes and the day's task list, as caregivers log them
- invoices and payments
- a form to send a schedule change, a care question or a supplies request straight to the person who can act on it
This post covers how we built it and the decisions that mattered.
The stack
- Next.js 16 and React 19, plain CSS on our brand tokens
- Postgres on Neon, with plain SQL through postgres.js
- Vercel, including Vercel Cron for the background jobs
- Microsoft Graph for email, since we already run on Microsoft 365
- Node's built-in test runner
Why build it
Our scheduling and clinical system, AlayaCare, offers its own family portal, and it is a reasonable choice. We built ours for three reasons. We wanted requests to land with our nurse care managers, who run each family's care. We wanted the portal to look and feel like the rest of Integracare. And we wanted to add features at our own pace, because a family's request can turn into a shipped feature within days.
The Family Room only reads from AlayaCare. The scheduling system stays the record of truth, and the portal is a window onto it.
1. One choke point for what leaves the server
AlayaCare's API returns rich records. A client record carries contacts, addresses and tags. An employee record carries personal details a family should never see. So no raw API object ever reaches the browser or our cache.
Every record passes through a "shaper": a pure function that copies out an exact, minimal set of fields and nothing else.
// Simplified from our shapers module
export function shapeCaregiver(raw) {
const r = raw || {};
return {
caregiverId: r.id ?? null,
displayName: shortName(r.first_name, r.last_name), // "Maria K."
languages: Array.isArray(r.languages) ? r.languages : [],
designation: r.designation ?? null,
};
}
The tests assert the exact key set of every shaper's output. If someone later writes { ...raw, displayName }, the suite fails before the change ships. Families see a caregiver's first name and last initial.
Shapers also absorb the API's quirks. Visit statuses such as offered, vacant and unassigned all become one state the family sees: "caregiver being confirmed."
2. Every read is scoped to the signed-in family
This rule shaped the whole codebase: no client id is ever trusted from the browser. Each family member is linked to one or more clients (a daughter may look after both parents). Every data accessor calls one gate before it touches the API or the cache:
// Simplified: the real gate also returns per-family settings in the same query
export async function assertClientAccess(db, userId, clientId) {
if (!userId || clientId == null) throw new AccessError();
const rows = await db`
select alayacare_client_id from portal_user_clients
where user_id = ${userId} and alayacare_client_id = ${String(clientId)}
`;
if (rows.length === 0) throw new AccessError(); // mapped to a 403
return rows[0];
}
An authorization test suite runs every data route as user A against client B's id and expects a 403 every time. A new route that forgets the gate fails that suite.
3. Sign-in without passwords
Many of our families are in their 60s and 70s, signing in to check on a parent in their 90s. Passwords would mean resets, lockouts and calls to the office. So each person signs in with a link sent to their email. The link works once and expires after 15 minutes, and it leads to a session cookie that renews as long as they keep using it.
There is no sign-up page. A care manager sends each invitation, and every family member gets their own access, arranged with the client's consent.
4. When the upstream system is slow or down
The portal syncs the visit schedule from AlayaCare every 15 minutes and keeps a short-lived cache for each client. If AlayaCare is unreachable, families see the last good copy with an "as of" time. Requests are always accepted: they are saved to our own database first and sent on when they can be.
The background jobs are a short list of Vercel Cron entries:
{ "path": "/api/cron/sync", "schedule": "*/15 * * * *" },
{ "path": "/api/cron/request-notifications", "schedule": "*/5 * * * *" },
{ "path": "/api/cron/weekly-schedule", "schedule": "0 19 * * 5" }
That last one sends each family next week's schedule every Friday afternoon. A second Friday email carries the week's care notes half an hour later.
Two public health endpoints answer only { ok }: one says the site can reach its database, the other that the background jobs are running. A scheduled check probes both and opens an issue if either fails, because an email alert can't tell you that email is down.
5. Requests instead of a chat window
We decided early against live chat. A chat window promises an instant reply that a care team can't always give at 11 p.m. Instead, families pick a request type: a schedule change, a billing question, a care question, or supplies. Each type goes to the right person with the details filled in, and the family sees its status move from Received to In progress to Done.
Requests that arrive after hours go to our on-call desk, and the Family Room tells families they will hear back within 24 hours.
6. Time zones, and Manitoba's new clock
AlayaCare sends every visit time as a UTC instant. We show each one on the client's clock, because that is where the care happens. A daughter in Vancouver reading her father's Winnipeg schedule needs the time the caregiver will knock on his door.
2026 made this harder. British Columbia, Alberta and Manitoba (along with Inuvik) stopped changing their clocks, and the change takes effect at 2:00 a.m. on Sunday, November 1, 2026, the night they would have fallen back. The IANA time zone data that records this arrived in releases 2026b to 2026e. Older tz data still falls back that night, and from then on shows every Winnipeg time an hour early: a 9:00 a.m. visit reads 8:00 a.m.
Our code doesn't control which tz data a family's browser carries, or exactly which Node build runs on the server. So the formatter asks the runtime first:
// Simplified. After the transition, does this runtime still answer the old winter offset?
const now = Date.now();
const stale = now >= row.fromMs && offsetMinutes(zone, now) === row.staleOffsetMin;
const zoneToUse = stale ? row.fixed : zone; // e.g. "Etc/GMT+5" for Manitoba's UTC-5
If the runtime already knows the new rule, we trust it. Only a runtime that still answers the old offset gets the fixed-offset zone instead. Once every runtime carries 2026e, the table does nothing.
How we build
Our development team is small. Claude Code writes the code, and our Chief Growth Officer reviews it. Every change goes through a pull request and an automated code review before it reaches production, and the repo carries about 80 test files, led by the authorization suite.
What made this possible is that home care is full of small, specific problems with clear owners. "A family can't tell whether tomorrow's visit is confirmed" is a problem a nurse care manager can explain in a minute, and we can ship a fix in a day.
If you build software for healthcare or for older adults, we'd like to hear what has worked for you. You can see the Family Room, and a short video tour of it, at integracarehomecare.ca/family-room.
來源:DEV Community · dev.to