Add self-hosted kids calendar app

Express + SQLite (node:sqlite, no native build step) family calendar:
parent accounts with household invites, per-child calendars with
save/duplicate/print, a token-gated read-only kiosk view for tablets,
and polling to keep parent and kiosk views in sync. Defaults to port 3007.
This commit is contained in:
ort
2026-08-15 13:48:01 -04:00
commit 66b8fc5d4d
42 changed files with 3567 additions and 0 deletions
+18
View File
@@ -0,0 +1,18 @@
# Copy to .env and fill in before running with docker-compose,
# or export these yourself before `node server.js`.
# Required: a long random string used to sign session cookies.
# Generate one with: node -e "console.log(require('node:crypto').randomBytes(32).toString('hex'))"
SESSION_SECRET=
# Set to true once this is served over HTTPS (e.g. behind a reverse proxy).
# Leave false for plain-HTTP LAN use, otherwise cookies won't be sent.
COOKIE_SECURE=false
# Set to true after your household(s) are created to stop accepting new signups.
DISABLE_PUBLIC_SIGNUP=false
# Only needed outside Docker if you want the SQLite file somewhere specific.
# DATA_DIR=./data
# PORT=3007
+4
View File
@@ -0,0 +1,4 @@
node_modules/
data/
.env
*.log
+16
View File
@@ -0,0 +1,16 @@
FROM node:22-alpine
WORKDIR /app
COPY package.json ./
RUN npm install --omit=dev
COPY . .
ENV PORT=3007
ENV DATA_DIR=/app/data
VOLUME ["/app/data"]
EXPOSE 3007
CMD ["node", "server.js"]
+73
View File
@@ -0,0 +1,73 @@
# Kids Calendar
A self-hosted weekly calendar for your family. Parents log in and edit; each
child gets a read-only "kiosk" link for a tablet, where they can only tick
off tasks that are already there.
- Parents: sign up, invite your spouse, create a calendar per child, edit
freely, print, duplicate a calendar for the next week.
- Kids: open their kiosk link on a tablet (add it to the home screen). They
can see the board and check boxes — nothing else.
- Everything a child checks off shows up on the parents' devices within a
few seconds, no reload needed.
## Running it
Requires Node.js 22.5+ (for the built-in `node:sqlite` module — nothing else
to compile, no native build tools needed).
```bash
npm install
export SESSION_SECRET=$(node -e "console.log(require('node:crypto').randomBytes(32).toString('hex'))")
node server.js
```
Plain `node` doesn't read `.env` files — export the variables from
`.env.example` yourself (as above), or use a process manager (pm2, systemd)
that does it for you. Docker (below) reads `.env` automatically. Then open
http://localhost:3007/signup.html.
### Docker (recommended for a NAS / home server)
```bash
cp .env.example .env # set SESSION_SECRET
docker compose up -d --build
```
Data lives in a named volume (`kids-calendar-data`), so it survives
container rebuilds. To back it up, back up that volume (or bind-mount
`./data:/app/data` in `docker-compose.yml` instead and back up that folder).
### Environment variables
| Variable | Default | Notes |
|---|---|---|
| `SESSION_SECRET` | *(insecure dev default)* | Required. A long random string — see `.env.example` for how to generate one. |
| `PORT` | `3007` | |
| `DATA_DIR` | `./data` | Where the SQLite file lives. |
| `COOKIE_SECURE` | `false` | Set `true` once this is behind HTTPS (e.g. a reverse proxy with a real certificate), so cookies are marked secure. Leave `false` for plain-HTTP LAN access, or login cookies won't be sent. |
| `DISABLE_PUBLIC_SIGNUP` | `false` | Set `true` once your household(s) exist, to stop the `/signup.html` page from creating new ones. Existing invite links still work. |
## How access works
- **Parents** have real accounts (email + password) tied to a household.
The first person to sign up creates the household; from the dashboard
they can generate an invite link (valid 7 days, one-time use) for a
spouse to join the same household with their own login.
- **Kids** never get an account. Each child has an unguessable link
(`/k/<token>`) that shows only their *active* calendar (set from the
dashboard) and lets them toggle checkboxes — the server has no route at
all for a kiosk link to edit text, add/delete tasks, or see other
children's data. If a tablet is lost, regenerate that child's link from
the dashboard to invalidate the old one.
## Exposing this beyond your home network
This is built for LAN use by default (plain HTTP, cookies not marked
secure). If you want access from outside your home:
- Put it behind a reverse proxy (e.g. Caddy, nginx, Traefik) that terminates
HTTPS, then set `COOKIE_SECURE=true`.
- Set `SESSION_SECRET` to a real random value (never the dev default).
- Consider setting `DISABLE_PUBLIC_SIGNUP=true` once your family's
household(s) are created.
+15
View File
@@ -0,0 +1,15 @@
services:
kids-calendar:
build: .
ports:
- "3007:3007"
volumes:
- kids-calendar-data:/app/data
environment:
- SESSION_SECRET=${SESSION_SECRET:?set a long random value in .env}
- COOKIE_SECURE=${COOKIE_SECURE:-false}
- DISABLE_PUBLIC_SIGNUP=${DISABLE_PUBLIC_SIGNUP:-false}
restart: unless-stopped
volumes:
kids-calendar-data:
+908
View File
@@ -0,0 +1,908 @@
{
"name": "kids-calendar",
"version": "1.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "kids-calendar",
"version": "1.0.0",
"dependencies": {
"bcryptjs": "^2.4.3",
"express": "^4.19.2",
"express-rate-limit": "^7.4.0",
"express-session": "^1.18.0"
},
"engines": {
"node": ">=22.5.0"
}
},
"node_modules/accepts": {
"version": "1.3.8",
"resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz",
"integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==",
"license": "MIT",
"dependencies": {
"mime-types": "~2.1.34",
"negotiator": "0.6.3"
},
"engines": {
"node": ">= 0.6"
}
},
"node_modules/array-flatten": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz",
"integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==",
"license": "MIT"
},
"node_modules/bcryptjs": {
"version": "2.4.3",
"resolved": "https://registry.npmjs.org/bcryptjs/-/bcryptjs-2.4.3.tgz",
"integrity": "sha512-V/Hy/X9Vt7f3BbPJEi8BdVFMByHi+jNXrYkW3huaybV/kQ0KJg0Y6PkEMbn+zeT+i+SiKZ/HMqJGIIt4LZDqNQ==",
"license": "MIT"
},
"node_modules/body-parser": {
"version": "1.20.6",
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.6.tgz",
"integrity": "sha512-p5tAzS57i5MV9fZFDj9LeIiTZEufbSe2eDozP+ElheSUq1m74CRq1jI4mYNDdVs9vQztXFLuk/Gd6BWTdwRJ5g==",
"license": "MIT",
"dependencies": {
"bytes": "~3.1.2",
"content-type": "~1.0.5",
"debug": "2.6.9",
"depd": "2.0.0",
"destroy": "~1.2.0",
"http-errors": "~2.0.1",
"iconv-lite": "~0.4.24",
"on-finished": "~2.4.1",
"qs": "~6.15.1",
"raw-body": "~2.5.3",
"type-is": "~1.6.18",
"unpipe": "~1.0.0"
},
"engines": {
"node": ">= 0.8",
"npm": "1.2.8000 || >= 1.4.16"
}
},
"node_modules/bytes": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
"integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/call-bind-apply-helpers": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
"integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
"function-bind": "^1.1.2"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/call-bound": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz",
"integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==",
"license": "MIT",
"dependencies": {
"call-bind-apply-helpers": "^1.0.2",
"get-intrinsic": "^1.3.0"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/content-disposition": {
"version": "0.5.4",
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz",
"integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==",
"license": "MIT",
"dependencies": {
"safe-buffer": "5.2.1"
},
"engines": {
"node": ">= 0.6"
}
},
"node_modules/content-type": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz",
"integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/cookie": {
"version": "0.7.2",
"resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz",
"integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/cookie-signature": {
"version": "1.0.7",
"resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz",
"integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==",
"license": "MIT"
},
"node_modules/debug": {
"version": "2.6.9",
"resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
"integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==",
"license": "MIT",
"dependencies": {
"ms": "2.0.0"
}
},
"node_modules/depd": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
"integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/destroy": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz",
"integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==",
"license": "MIT",
"engines": {
"node": ">= 0.8",
"npm": "1.2.8000 || >= 1.4.16"
}
},
"node_modules/dunder-proto": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
"integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
"license": "MIT",
"dependencies": {
"call-bind-apply-helpers": "^1.0.1",
"es-errors": "^1.3.0",
"gopd": "^1.2.0"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/ee-first": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz",
"integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==",
"license": "MIT"
},
"node_modules/encodeurl": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz",
"integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/es-define-property": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
"integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
}
},
"node_modules/es-errors": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
"integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
}
},
"node_modules/es-object-atoms": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz",
"integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==",
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/escape-html": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz",
"integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==",
"license": "MIT"
},
"node_modules/etag": {
"version": "1.8.1",
"resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz",
"integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/express": {
"version": "4.22.2",
"resolved": "https://registry.npmjs.org/express/-/express-4.22.2.tgz",
"integrity": "sha512-IuL+Elrou2ZvCFHs18/CIzy2Nzvo25nZ1/D2eIZlz7c+QUayAcYoiM2BthCjs+EBHVpjYjcuLDAiCWgeIX3X1Q==",
"license": "MIT",
"dependencies": {
"accepts": "~1.3.8",
"array-flatten": "1.1.1",
"body-parser": "~1.20.5",
"content-disposition": "~0.5.4",
"content-type": "~1.0.4",
"cookie": "~0.7.1",
"cookie-signature": "~1.0.6",
"debug": "2.6.9",
"depd": "2.0.0",
"encodeurl": "~2.0.0",
"escape-html": "~1.0.3",
"etag": "~1.8.1",
"finalhandler": "~1.3.1",
"fresh": "~0.5.2",
"http-errors": "~2.0.0",
"merge-descriptors": "1.0.3",
"methods": "~1.1.2",
"on-finished": "~2.4.1",
"parseurl": "~1.3.3",
"path-to-regexp": "~0.1.12",
"proxy-addr": "~2.0.7",
"qs": "~6.15.1",
"range-parser": "~1.2.1",
"safe-buffer": "5.2.1",
"send": "~0.19.0",
"serve-static": "~1.16.2",
"setprototypeof": "1.2.0",
"statuses": "~2.0.1",
"type-is": "~1.6.18",
"utils-merge": "1.0.1",
"vary": "~1.1.2"
},
"engines": {
"node": ">= 0.10.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/express-rate-limit": {
"version": "7.5.1",
"resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-7.5.1.tgz",
"integrity": "sha512-7iN8iPMDzOMHPUYllBEsQdWVB6fPDMPqwjBaFrgr4Jgr/+okjvzAy+UHlYYL/Vs0OsOrMkwS6PJDkFlJwoxUnw==",
"license": "MIT",
"engines": {
"node": ">= 16"
},
"funding": {
"url": "https://github.com/sponsors/express-rate-limit"
},
"peerDependencies": {
"express": ">= 4.11"
}
},
"node_modules/express-session": {
"version": "1.19.0",
"resolved": "https://registry.npmjs.org/express-session/-/express-session-1.19.0.tgz",
"integrity": "sha512-0csaMkGq+vaiZTmSMMGkfdCOabYv192VbytFypcvI0MANrp+4i/7yEkJ0sbAEhycQjntaKGzYfjfXQyVb7BHMA==",
"license": "MIT",
"dependencies": {
"cookie": "~0.7.2",
"cookie-signature": "~1.0.7",
"debug": "~2.6.9",
"depd": "~2.0.0",
"on-headers": "~1.1.0",
"parseurl": "~1.3.3",
"safe-buffer": "~5.2.1",
"uid-safe": "~2.1.5"
},
"engines": {
"node": ">= 0.8.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/finalhandler": {
"version": "1.3.2",
"resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz",
"integrity": "sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==",
"license": "MIT",
"dependencies": {
"debug": "2.6.9",
"encodeurl": "~2.0.0",
"escape-html": "~1.0.3",
"on-finished": "~2.4.1",
"parseurl": "~1.3.3",
"statuses": "~2.0.2",
"unpipe": "~1.0.0"
},
"engines": {
"node": ">= 0.8"
}
},
"node_modules/forwarded": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz",
"integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/fresh": {
"version": "0.5.2",
"resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz",
"integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/function-bind": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
"integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/get-intrinsic": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
"integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
"license": "MIT",
"dependencies": {
"call-bind-apply-helpers": "^1.0.2",
"es-define-property": "^1.0.1",
"es-errors": "^1.3.0",
"es-object-atoms": "^1.1.1",
"function-bind": "^1.1.2",
"get-proto": "^1.0.1",
"gopd": "^1.2.0",
"has-symbols": "^1.1.0",
"hasown": "^2.0.2",
"math-intrinsics": "^1.1.0"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/get-proto": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
"integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
"license": "MIT",
"dependencies": {
"dunder-proto": "^1.0.1",
"es-object-atoms": "^1.0.0"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/gopd": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
"integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/has-symbols": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
"integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/hasown": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz",
"integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==",
"license": "MIT",
"dependencies": {
"function-bind": "^1.1.2"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/http-errors": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz",
"integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==",
"license": "MIT",
"dependencies": {
"depd": "~2.0.0",
"inherits": "~2.0.4",
"setprototypeof": "~1.2.0",
"statuses": "~2.0.2",
"toidentifier": "~1.0.1"
},
"engines": {
"node": ">= 0.8"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/iconv-lite": {
"version": "0.4.24",
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz",
"integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==",
"license": "MIT",
"dependencies": {
"safer-buffer": ">= 2.1.2 < 3"
},
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/inherits": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
"license": "ISC"
},
"node_modules/ipaddr.js": {
"version": "1.9.1",
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
"integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==",
"license": "MIT",
"engines": {
"node": ">= 0.10"
}
},
"node_modules/math-intrinsics": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
"integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
}
},
"node_modules/media-typer": {
"version": "0.3.0",
"resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz",
"integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/merge-descriptors": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz",
"integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/methods": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz",
"integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/mime": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz",
"integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==",
"license": "MIT",
"bin": {
"mime": "cli.js"
},
"engines": {
"node": ">=4"
}
},
"node_modules/mime-db": {
"version": "1.52.0",
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
"integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/mime-types": {
"version": "2.1.35",
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
"integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
"license": "MIT",
"dependencies": {
"mime-db": "1.52.0"
},
"engines": {
"node": ">= 0.6"
}
},
"node_modules/ms": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
"integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
"license": "MIT"
},
"node_modules/negotiator": {
"version": "0.6.3",
"resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz",
"integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/object-inspect": {
"version": "1.13.4",
"resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
"integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/on-finished": {
"version": "2.4.1",
"resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz",
"integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==",
"license": "MIT",
"dependencies": {
"ee-first": "1.1.1"
},
"engines": {
"node": ">= 0.8"
}
},
"node_modules/on-headers": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/on-headers/-/on-headers-1.1.0.tgz",
"integrity": "sha512-737ZY3yNnXy37FHkQxPzt4UZ2UWPWiCZWLvFZ4fu5cueciegX0zGPnrlY6bwRg4FdQOe9YU8MkmJwGhoMybl8A==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/parseurl": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
"integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/path-to-regexp": {
"version": "0.1.13",
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.13.tgz",
"integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==",
"license": "MIT"
},
"node_modules/proxy-addr": {
"version": "2.0.7",
"resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz",
"integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==",
"license": "MIT",
"dependencies": {
"forwarded": "0.2.0",
"ipaddr.js": "1.9.1"
},
"engines": {
"node": ">= 0.10"
}
},
"node_modules/qs": {
"version": "6.15.3",
"resolved": "https://registry.npmjs.org/qs/-/qs-6.15.3.tgz",
"integrity": "sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A==",
"license": "BSD-3-Clause",
"dependencies": {
"es-define-property": "^1.0.1",
"side-channel": "^1.1.1"
},
"engines": {
"node": ">=0.6"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/random-bytes": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/random-bytes/-/random-bytes-1.0.0.tgz",
"integrity": "sha512-iv7LhNVO047HzYR3InF6pUcUsPQiHTM1Qal51DcGSuZFBil1aBBWG5eHPNek7bvILMaYJ/8RU1e8w1AMdHmLQQ==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/range-parser": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz",
"integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/raw-body": {
"version": "2.5.3",
"resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.3.tgz",
"integrity": "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==",
"license": "MIT",
"dependencies": {
"bytes": "~3.1.2",
"http-errors": "~2.0.1",
"iconv-lite": "~0.4.24",
"unpipe": "~1.0.0"
},
"engines": {
"node": ">= 0.8"
}
},
"node_modules/safe-buffer": {
"version": "5.2.1",
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
"integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT"
},
"node_modules/safer-buffer": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
"license": "MIT"
},
"node_modules/send": {
"version": "0.19.2",
"resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz",
"integrity": "sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==",
"license": "MIT",
"dependencies": {
"debug": "2.6.9",
"depd": "2.0.0",
"destroy": "1.2.0",
"encodeurl": "~2.0.0",
"escape-html": "~1.0.3",
"etag": "~1.8.1",
"fresh": "~0.5.2",
"http-errors": "~2.0.1",
"mime": "1.6.0",
"ms": "2.1.3",
"on-finished": "~2.4.1",
"range-parser": "~1.2.1",
"statuses": "~2.0.2"
},
"engines": {
"node": ">= 0.8.0"
}
},
"node_modules/send/node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"license": "MIT"
},
"node_modules/serve-static": {
"version": "1.16.3",
"resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.3.tgz",
"integrity": "sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==",
"license": "MIT",
"dependencies": {
"encodeurl": "~2.0.0",
"escape-html": "~1.0.3",
"parseurl": "~1.3.3",
"send": "~0.19.1"
},
"engines": {
"node": ">= 0.8.0"
}
},
"node_modules/setprototypeof": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz",
"integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==",
"license": "ISC"
},
"node_modules/side-channel": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz",
"integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==",
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
"object-inspect": "^1.13.4",
"side-channel-list": "^1.0.1",
"side-channel-map": "^1.0.1",
"side-channel-weakmap": "^1.0.2"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/side-channel-list": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz",
"integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==",
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
"object-inspect": "^1.13.4"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/side-channel-map": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz",
"integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==",
"license": "MIT",
"dependencies": {
"call-bound": "^1.0.2",
"es-errors": "^1.3.0",
"get-intrinsic": "^1.2.5",
"object-inspect": "^1.13.3"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/side-channel-weakmap": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz",
"integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==",
"license": "MIT",
"dependencies": {
"call-bound": "^1.0.2",
"es-errors": "^1.3.0",
"get-intrinsic": "^1.2.5",
"object-inspect": "^1.13.3",
"side-channel-map": "^1.0.1"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/statuses": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
"integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/toidentifier": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz",
"integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==",
"license": "MIT",
"engines": {
"node": ">=0.6"
}
},
"node_modules/type-is": {
"version": "1.6.18",
"resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz",
"integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==",
"license": "MIT",
"dependencies": {
"media-typer": "0.3.0",
"mime-types": "~2.1.24"
},
"engines": {
"node": ">= 0.6"
}
},
"node_modules/uid-safe": {
"version": "2.1.5",
"resolved": "https://registry.npmjs.org/uid-safe/-/uid-safe-2.1.5.tgz",
"integrity": "sha512-KPHm4VL5dDXKz01UuEd88Df+KzynaohSL9fBh096KWAxSKZQDI2uBrVqtvRM4rwrIrRRKsdLNML/lnaaVSRioA==",
"license": "MIT",
"dependencies": {
"random-bytes": "~1.0.0"
},
"engines": {
"node": ">= 0.8"
}
},
"node_modules/unpipe": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz",
"integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/utils-merge": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz",
"integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==",
"license": "MIT",
"engines": {
"node": ">= 0.4.0"
}
},
"node_modules/vary": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz",
"integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
}
}
}
+19
View File
@@ -0,0 +1,19 @@
{
"name": "kids-calendar",
"version": "1.0.0",
"description": "Self-hosted family weekly calendar with parent editing and a read-only kid kiosk view.",
"main": "server.js",
"private": true,
"engines": {
"node": ">=22.5.0"
},
"scripts": {
"start": "node server.js"
},
"dependencies": {
"bcryptjs": "^2.4.3",
"express": "^4.19.2",
"express-rate-limit": "^7.4.0",
"express-session": "^1.18.0"
}
}
+44
View File
@@ -0,0 +1,44 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>My Week</title>
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link href="https://fonts.googleapis.com/css2?family=Baloo+2:wght@500;700;800&family=Nunito:wght@500;700;800&display=swap" rel="stylesheet">
<link rel="stylesheet" href="/css/shared.css">
</head>
<body>
<nav class="crumbs"><a href="/dashboard.html">&larr; Dashboard</a></nav>
<header>
<div>
<h1><span id="childName" contenteditable="true"></span>'s <span id="calTitle" contenteditable="true">Week</span></h1>
<div class="subtitle">Tap a box to check it off. Click any word to edit it.</div>
</div>
<div class="toolbar">
<button class="tool" id="toggleWeekend">Show weekend</button>
<button class="tool" id="resetChecks">Reset checkboxes</button>
<button class="tool primary" id="printBtn">Print this week</button>
</div>
</header>
<div class="period-toggles" id="periodToggles">
<span class="label">Show / print:</span>
</div>
<div class="board" id="weekdayBoard"></div>
<div class="weekend-toggle-wrap hidden" id="weekendWrap"></div>
<div class="legend" id="legend"></div>
<div class="hint">Tip: the colors stay the same every day on purpose — same color always means same kind of task, so she can recognize it at a glance.</div>
<div class="toast" id="toast"></div>
<script src="/js/api.js"></script>
<script src="/js/calendarRender.js"></script>
<script src="/js/calendar.js"></script>
</body>
</html>
+451
View File
@@ -0,0 +1,451 @@
:root{
--bg: #F1F5FB;
--card: #FFFFFF;
--ink: #2E3446;
--ink-soft: #6B7280;
--line: #E3E8F0;
--morning: #FF8C64;
--morning-bg: #FFF0E7;
--school: #4A90D9;
--school-bg: #E9F2FC;
--after: #4FAE72;
--after-bg: #E9F7EE;
--evening: #8B7FD1;
--evening-bg: #F0EDFB;
--today-ring: #FFC94A;
--danger: #D14343;
}
*{ box-sizing: border-box; }
body{
margin:0;
background: var(--bg);
font-family: 'Nunito', sans-serif;
color: var(--ink);
padding: 24px 16px 60px;
}
a{ color: var(--school); }
h1{
font-family: 'Baloo 2', sans-serif;
font-size: 2rem;
margin: 0;
color: var(--ink);
}
h1 span{ color: var(--morning); }
header{
max-width: 1400px;
margin: 0 auto 20px;
display:flex;
align-items:center;
justify-content: space-between;
flex-wrap: wrap;
gap: 12px;
}
.subtitle{
font-size: 0.95rem;
color: var(--ink-soft);
margin-top: 2px;
}
.toolbar{
display:flex;
gap: 10px;
flex-wrap: wrap;
}
button.tool, a.tool{
font-family: 'Nunito', sans-serif;
font-weight: 800;
font-size: 0.85rem;
border: none;
border-radius: 999px;
padding: 10px 18px;
cursor: pointer;
background: var(--card);
color: var(--ink);
border: 2px solid var(--line);
transition: transform .12s ease, background .12s ease;
text-decoration: none;
display: inline-block;
}
button.tool:hover, a.tool:hover{ transform: translateY(-1px); background: #F7F9FC; }
button.tool.primary, a.tool.primary{ background: var(--school); color: white; border-color: var(--school); }
button.tool.primary:hover, a.tool.primary:hover{ background: #3d7fc4; }
button.tool.danger{ background: var(--card); color: var(--danger); border-color: #F3D0D0; }
button.tool.danger:hover{ background: #FDF1F1; }
button.tool:disabled{ opacity: .5; cursor: default; transform:none; }
.board{
max-width: 1400px;
margin: 0 auto;
display: grid;
grid-template-columns: repeat(5, 1fr);
gap: 14px;
}
.weekend-toggle-wrap{
max-width: 1400px;
margin: 14px auto 0;
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 14px;
}
.day-card{
background: var(--card);
border-radius: 20px;
padding: 14px;
display:flex;
flex-direction:column;
gap: 10px;
border: 3px solid transparent;
box-shadow: 0 2px 10px rgba(46,52,70,0.06);
}
.day-card.today{
border-color: var(--today-ring);
box-shadow: 0 4px 18px rgba(255,201,74,0.35);
}
.day-head{
display:flex;
align-items:baseline;
justify-content: space-between;
}
.day-name{
font-family: 'Baloo 2', sans-serif;
font-weight: 700;
font-size: 1.15rem;
}
.today-badge{
font-size: 0.65rem;
font-weight: 800;
background: var(--today-ring);
color: #6B4A00;
padding: 3px 9px;
border-radius: 999px;
letter-spacing: .03em;
}
.block{
border-radius: 14px;
padding: 10px 10px 8px;
}
.block.morning{ background: var(--morning-bg); }
.block.school{ background: var(--school-bg); }
.block.after{ background: var(--after-bg); }
.block.evening{ background: var(--evening-bg); }
.block-title{
font-family: 'Baloo 2', sans-serif;
font-weight: 700;
font-size: 0.85rem;
display:flex;
align-items:center;
gap: 6px;
margin-bottom: 6px;
}
.morning .block-title{ color: #C0501F; }
.school .block-title{ color: #2C6BAF; }
.after .block-title{ color: #2D7B4C; }
.evening .block-title{ color: #5F51B0; }
.task{
display:flex;
align-items:flex-start;
gap: 8px;
padding: 5px 4px;
border-radius: 8px;
}
.task:hover{ background: rgba(255,255,255,0.6); }
.task:hover .del{ opacity: 1; }
.task input[type=checkbox]{
margin-top: 3px;
width: 18px;
height: 18px;
flex: none;
accent-color: var(--ink);
cursor: pointer;
}
.task.kiosk input[type=checkbox]{
width: 24px;
height: 24px;
}
.task-text{
flex: 1;
font-size: 0.92rem;
font-weight: 700;
outline: none;
line-height: 1.25;
padding: 1px 2px;
border-radius: 4px;
}
.task-text:focus{ background: rgba(255,255,255,0.85); }
.task.done .task-text{
text-decoration: line-through;
opacity: 0.45;
}
.del{
opacity: 0;
border: none;
background: none;
color: var(--ink-soft);
font-weight: 800;
cursor: pointer;
font-size: 0.85rem;
padding: 0 3px;
transition: opacity .12s ease;
}
.del:hover{ color: var(--danger); }
.add-task{
background: none;
border: none;
color: var(--ink-soft);
font-size: 0.82rem;
font-weight: 800;
cursor: pointer;
padding: 4px 4px 2px;
opacity: 0.75;
}
.add-task:hover{ opacity: 1; text-decoration: underline; }
.legend{
max-width: 1400px;
margin: 22px auto 0;
display:flex;
gap: 16px;
flex-wrap: wrap;
font-size: 0.82rem;
color: var(--ink-soft);
align-items:center;
}
.legend .dot{
display:inline-block;
width: 10px; height:10px;
border-radius: 50%;
margin-right: 5px;
}
.legend .item{ display:flex; align-items:center; }
.hint{
max-width: 1400px;
margin: 6px auto 0;
font-size: 0.78rem;
color: var(--ink-soft);
}
@media (max-width: 1100px){
.board{ grid-template-columns: repeat(2, 1fr); }
.weekend-toggle-wrap{ grid-template-columns: 1fr; }
}
@media (max-width: 600px){
.board{ grid-template-columns: 1fr; }
}
@media print{
body{ background: white; padding: 0; }
.toolbar, .add-task, .del, .hint, .period-toggles, nav.crumbs{ display:none !important; }
.board{ grid-template-columns: repeat(5, 1fr); gap: 8px; }
.day-card{ box-shadow:none; border: 2px solid var(--line) !important; break-inside: avoid; }
.weekend-toggle-wrap{ margin-top: 8px; grid-template-columns: repeat(2,1fr); }
}
#weekendWrap.hidden{ display:none; }
body.hide-morning .block.morning{ display:none; }
body.hide-school .block.school{ display:none; }
body.hide-after .block.after{ display:none; }
body.hide-evening .block.evening{ display:none; }
[contenteditable="true"]{
outline: none;
border-radius: 4px;
padding: 0 2px;
}
[contenteditable="true"]:focus{ background: rgba(255,255,255,0.7); }
.period-toggles{
max-width: 1400px;
margin: 0 auto 18px;
display:flex;
gap: 18px;
flex-wrap: wrap;
align-items:center;
background: var(--card);
border: 2px solid var(--line);
border-radius: 14px;
padding: 10px 16px;
}
.period-toggles .label{
font-size: 0.8rem;
font-weight: 800;
color: var(--ink-soft);
}
.period-toggles label.opt{
display:flex;
align-items:center;
gap: 6px;
font-size: 0.85rem;
font-weight: 700;
cursor: pointer;
}
.period-toggles input[type=checkbox]{
width: 16px; height:16px;
cursor: pointer;
}
.block-title .icon{ margin-right: 2px; }
.block-title .label{
outline: none;
border-radius: 4px;
padding: 0 2px;
}
.block-title .label:focus{ background: rgba(255,255,255,0.75); }
/* --- App chrome shared across auth/dashboard pages --- */
nav.crumbs{
max-width: 1400px;
margin: 0 auto 14px;
font-size: 0.85rem;
color: var(--ink-soft);
}
nav.crumbs a{ text-decoration: none; font-weight: 700; }
.auth-wrap{
max-width: 420px;
margin: 60px auto;
background: var(--card);
border-radius: 20px;
padding: 32px;
box-shadow: 0 2px 10px rgba(46,52,70,0.06);
}
.auth-wrap h1{ font-size: 1.5rem; margin-bottom: 4px; }
.auth-wrap p.lead{ color: var(--ink-soft); margin-top: 0; margin-bottom: 20px; font-size: 0.9rem; }
.field{ margin-bottom: 14px; display:flex; flex-direction:column; gap: 6px; }
.field label{ font-size: 0.82rem; font-weight: 800; color: var(--ink-soft); }
.field input{
font-family: 'Nunito', sans-serif;
font-size: 0.95rem;
padding: 10px 12px;
border-radius: 10px;
border: 2px solid var(--line);
outline: none;
}
.field input:focus{ border-color: var(--school); }
.error-msg{
background: #FDF1F1;
border: 2px solid #F3D0D0;
color: var(--danger);
border-radius: 10px;
padding: 10px 12px;
font-size: 0.85rem;
font-weight: 700;
margin-bottom: 14px;
}
.form-actions{ margin-top: 18px; display:flex; flex-direction:column; gap: 10px; }
.form-actions button.tool{ width: 100%; text-align:center; }
.switch-link{ font-size: 0.85rem; color: var(--ink-soft); text-align:center; }
.section{
max-width: 1400px;
margin: 0 auto 28px;
}
.section h2{
font-family: 'Baloo 2', sans-serif;
font-size: 1.3rem;
margin: 0 0 12px;
}
.card-grid{
display: grid;
grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
gap: 14px;
}
.entity-card{
background: var(--card);
border-radius: 16px;
padding: 16px;
border: 2px solid var(--line);
display:flex;
flex-direction: column;
gap: 10px;
}
.entity-card h3{ margin: 0; font-family: 'Baloo 2', sans-serif; font-size: 1.1rem; }
.entity-card .meta{ font-size: 0.8rem; color: var(--ink-soft); }
.entity-card .row{ display:flex; gap: 8px; flex-wrap: wrap; }
.entity-card .row button.tool, .entity-card .row a.tool{ padding: 7px 12px; font-size: 0.78rem; }
.empty-state{
color: var(--ink-soft);
font-size: 0.9rem;
padding: 20px;
text-align: center;
background: var(--card);
border: 2px dashed var(--line);
border-radius: 16px;
}
.kiosk-lock-note{
max-width: 1400px;
margin: 0 auto 14px;
font-size: 0.85rem;
color: var(--ink-soft);
display:flex;
align-items:center;
gap: 8px;
}
.modal-backdrop{
position: fixed; inset: 0;
background: rgba(46,52,70,0.35);
display:flex; align-items:center; justify-content:center;
padding: 20px;
z-index: 50;
}
.modal-backdrop.hidden{ display:none; }
.modal{
background: var(--card);
border-radius: 20px;
padding: 24px;
max-width: 420px;
width: 100%;
}
.modal h2{ margin-top:0; font-family:'Baloo 2', sans-serif; }
.modal .form-actions{ flex-direction: row; }
.modal .form-actions button{ flex: 1; }
.toast{
position: fixed;
bottom: 20px; left: 50%;
transform: translateX(-50%);
background: var(--ink);
color: white;
padding: 10px 18px;
border-radius: 999px;
font-size: 0.85rem;
font-weight: 700;
opacity: 0;
pointer-events: none;
transition: opacity .2s ease;
z-index: 100;
}
.toast.show{ opacity: 1; }
+115
View File
@@ -0,0 +1,115 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Dashboard — Kids Calendar</title>
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link href="https://fonts.googleapis.com/css2?family=Baloo+2:wght@500;700;800&family=Nunito:wght@500;700;800&display=swap" rel="stylesheet">
<link rel="stylesheet" href="/css/shared.css">
</head>
<body>
<header>
<div>
<h1 id="householdName">Loading…</h1>
<div class="subtitle" id="whoami"></div>
</div>
<div class="toolbar">
<button class="tool" id="inviteBtn">Invite spouse</button>
<button class="tool danger" id="logoutBtn">Log out</button>
</div>
</header>
<div class="section">
<h2>Household</h2>
<div id="parentsList" class="card-grid"></div>
</div>
<div class="section">
<div style="display:flex; align-items:center; justify-content:space-between; margin-bottom:12px;">
<h2 style="margin:0;">Children</h2>
<button class="tool primary" id="addChildBtn">+ Add child</button>
</div>
<div id="childrenList" class="card-grid"></div>
</div>
<!-- Invite modal -->
<div class="modal-backdrop hidden" id="inviteModal">
<div class="modal">
<h2>Invite your spouse</h2>
<p id="inviteLeadText">Share this link — it lets them create a login joined to your household. Expires in 7 days.</p>
<div class="field">
<input type="text" id="inviteLinkField" readonly>
</div>
<div class="form-actions">
<button class="tool primary" id="copyInviteBtn">Copy link</button>
<button class="tool" id="closeInviteBtn">Close</button>
</div>
</div>
</div>
<!-- Add child modal -->
<div class="modal-backdrop hidden" id="addChildModal">
<div class="modal">
<h2>Add a child</h2>
<form id="addChildForm">
<div class="field">
<label for="childNameInput">Name</label>
<input type="text" id="childNameInput" required>
</div>
<div class="form-actions">
<button type="submit" class="tool primary">Add</button>
<button type="button" class="tool" id="cancelAddChildBtn">Cancel</button>
</div>
</form>
</div>
</div>
<!-- New calendar modal -->
<div class="modal-backdrop hidden" id="newCalendarModal">
<div class="modal">
<h2>New calendar</h2>
<form id="newCalendarForm">
<input type="hidden" id="newCalChildId">
<div class="field">
<label for="newCalTitle">Title</label>
<input type="text" id="newCalTitle" placeholder="e.g. Week of Aug 24" required>
</div>
<div class="field">
<label class="opt" style="display:flex; align-items:center; gap:8px; font-weight:700;">
<input type="checkbox" id="newCalDuplicate">
Duplicate tasks from most recent calendar (checkmarks reset)
</label>
</div>
<div class="form-actions">
<button type="submit" class="tool primary">Create</button>
<button type="button" class="tool" id="cancelNewCalBtn">Cancel</button>
</div>
</form>
</div>
</div>
<!-- Kiosk link modal -->
<div class="modal-backdrop hidden" id="kioskModal">
<div class="modal">
<h2>Tablet link for <span id="kioskChildName"></span></h2>
<p>Open this link on the tablet's browser and add it to the home screen. It only shows the active calendar and lets your child check items off — nothing else.</p>
<div class="field">
<input type="text" id="kioskLinkField" readonly>
</div>
<div class="form-actions">
<button class="tool primary" id="copyKioskBtn">Copy link</button>
<button class="tool danger" id="regenKioskBtn">Regenerate link</button>
<button class="tool" id="closeKioskBtn">Close</button>
</div>
</div>
</div>
<div class="toast" id="toast"></div>
<script src="/js/api.js"></script>
<script src="/js/dashboard.js"></script>
</body>
</html>
+77
View File
@@ -0,0 +1,77 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Join family — Kids Calendar</title>
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link href="https://fonts.googleapis.com/css2?family=Baloo+2:wght@500;700;800&family=Nunito:wght@500;700;800&display=swap" rel="stylesheet">
<link rel="stylesheet" href="/css/shared.css">
</head>
<body>
<div class="auth-wrap">
<h1 id="heading">Join family</h1>
<p class="lead" id="lead">Checking your invite link…</p>
<div class="error-msg" id="errorMsg" style="display:none;"></div>
<form id="joinForm" style="display:none;">
<div class="field">
<label for="name">Your name</label>
<input type="text" id="name" autocomplete="name" required>
</div>
<div class="field">
<label for="email">Email</label>
<input type="email" id="email" autocomplete="email" required>
</div>
<div class="field">
<label for="password">Password</label>
<input type="password" id="password" autocomplete="new-password" minlength="8" required>
</div>
<div class="form-actions">
<button type="submit" class="tool primary">Join</button>
</div>
</form>
</div>
<script src="/js/api.js"></script>
<script>
const token = new URLSearchParams(window.location.search).get('token');
const errorMsg = document.getElementById('errorMsg');
const lead = document.getElementById('lead');
const joinForm = document.getElementById('joinForm');
function showError(msg) {
errorMsg.textContent = msg;
errorMsg.style.display = 'block';
lead.style.display = 'none';
}
(async () => {
if (!token) return showError('This link is missing an invite token.');
try {
const data = await api.get(`/api/invites/${encodeURIComponent(token)}`);
lead.textContent = `You've been invited to join "${data.householdName}". Create your login below.`;
joinForm.style.display = 'block';
} catch (err) {
showError(err.message);
}
})();
joinForm.addEventListener('submit', async (e) => {
e.preventDefault();
errorMsg.style.display = 'none';
try {
await api.post(`/api/invites/${encodeURIComponent(token)}/accept`, {
name: document.getElementById('name').value,
email: document.getElementById('email').value,
password: document.getElementById('password').value,
});
window.location.href = '/dashboard.html';
} catch (err) {
showError(err.message);
}
});
</script>
</body>
</html>
+29
View File
@@ -0,0 +1,29 @@
const api = {
async request(method, path, body) {
const res = await fetch(path, {
method,
credentials: 'include',
headers: body !== undefined ? { 'Content-Type': 'application/json' } : undefined,
body: body !== undefined ? JSON.stringify(body) : undefined,
});
if (res.status === 204) return null;
let data = null;
const text = await res.text();
if (text) {
try { data = JSON.parse(text); } catch { data = null; }
}
if (!res.ok) {
const err = new Error((data && data.error) || `Request failed (${res.status})`);
err.status = res.status;
throw err;
}
return data;
},
get(path) { return this.request('GET', path); },
post(path, body) { return this.request('POST', path, body === undefined ? {} : body); },
patch(path, body) { return this.request('PATCH', path, body === undefined ? {} : body); },
del(path) { return this.request('DELETE', path); },
};
+151
View File
@@ -0,0 +1,151 @@
const calendarId = new URLSearchParams(window.location.search).get('calendarId');
let calendar = null;
const weekdayBoard = document.getElementById('weekdayBoard');
const weekendWrap = document.getElementById('weekendWrap');
const legend = document.getElementById('legend');
const toast = document.getElementById('toast');
function showToast(msg) {
toast.textContent = msg;
toast.classList.add('show');
setTimeout(() => toast.classList.remove('show'), 1800);
}
function debounce(fn, ms) {
let t;
return (...args) => { clearTimeout(t); t = setTimeout(() => fn(...args), ms); };
}
const handlers = {
onToggleDone: (taskId, done) => api.patch(`/api/calendars/${calendarId}/tasks/${taskId}`, { done }).catch(() => showToast('Could not save')),
onTextEdit: (taskId, text) => api.patch(`/api/calendars/${calendarId}/tasks/${taskId}`, { text }).catch(() => showToast('Could not save')),
onLabelEdit: (blockKey, label) => api.patch(`/api/calendars/${calendarId}/blocks/${blockKey}`, { label }).catch(() => showToast('Could not save')),
onAddTask: async (dayOfWeek, blockKey, text) => {
const { task } = await api.post(`/api/calendars/${calendarId}/tasks`, { dayOfWeek, blockKey, text });
return task;
},
onDeleteTask: (taskId) => api.del(`/api/calendars/${calendarId}/tasks/${taskId}`).catch(() => showToast('Could not delete')),
};
function todayName() {
return new Date().toLocaleDateString('en-US', { weekday: 'long' });
}
function draw() {
const childNameEl = document.getElementById('childName');
if (document.activeElement !== childNameEl) childNameEl.textContent = calendar.childName || '';
const titleEl = document.getElementById('calTitle');
if (document.activeElement !== titleEl) titleEl.textContent = calendar.title;
renderCalendar({
calendar,
editable: true,
weekdayBoard,
weekendWrap,
handlers,
todayName: todayName(),
});
renderLegend(legend, calendar);
weekendWrap.classList.toggle('hidden', !calendar.showWeekend);
document.getElementById('toggleWeekend').textContent = calendar.showWeekend ? 'Hide weekend' : 'Show weekend';
buildPeriodToggles();
}
function buildPeriodToggles() {
const el = document.getElementById('periodToggles');
el.querySelectorAll('label.opt').forEach((n) => n.remove());
calendar.blocks.forEach((block) => {
const label = document.createElement('label');
label.className = 'opt';
const cb = document.createElement('input');
cb.type = 'checkbox';
cb.checked = !document.body.classList.contains('hide-' + block.key);
cb.onchange = () => document.body.classList.toggle('hide-' + block.key, !cb.checked);
const span = document.createElement('span');
span.textContent = block.label;
label.appendChild(cb);
label.appendChild(span);
el.appendChild(label);
});
}
async function load() {
if (!calendarId) {
document.body.innerHTML = '<p style="padding:40px;">No calendar selected. <a href="/dashboard.html">Back to dashboard</a></p>';
return;
}
try {
const data = await api.get(`/api/calendars/${calendarId}`);
calendar = data.calendar;
} catch (err) {
if (err.status === 401) { window.location.href = '/login.html'; return; }
document.body.innerHTML = `<p style="padding:40px;">${err.message}. <a href="/dashboard.html">Back to dashboard</a></p>`;
return;
}
draw();
}
document.getElementById('toggleWeekend').addEventListener('click', async () => {
calendar.showWeekend = !calendar.showWeekend;
weekendWrap.classList.toggle('hidden', !calendar.showWeekend);
document.getElementById('toggleWeekend').textContent = calendar.showWeekend ? 'Hide weekend' : 'Show weekend';
await api.patch(`/api/calendars/${calendarId}`, { showWeekend: calendar.showWeekend });
});
document.getElementById('resetChecks').addEventListener('click', async () => {
const data = await api.post(`/api/calendars/${calendarId}/reset-checks`);
calendar = data.calendar;
draw();
showToast('Checkboxes reset');
});
document.getElementById('printBtn').addEventListener('click', () => window.print());
const titleEl = document.getElementById('calTitle');
titleEl.addEventListener('keydown', (e) => { if (e.key === 'Enter') { e.preventDefault(); titleEl.blur(); } });
const debouncedTitleSave = debounce(() => {
if (titleEl.textContent.trim()) api.patch(`/api/calendars/${calendarId}`, { title: titleEl.textContent.trim() });
}, 500);
titleEl.addEventListener('input', debouncedTitleSave);
const childNameEl = document.getElementById('childName');
childNameEl.addEventListener('keydown', (e) => { if (e.key === 'Enter') { e.preventDefault(); childNameEl.blur(); } });
const debouncedChildNameSave = debounce(() => {
if (childNameEl.textContent.trim()) api.patch(`/api/children/${calendar.childId}`, { name: childNameEl.textContent.trim() });
}, 500);
childNameEl.addEventListener('input', debouncedChildNameSave);
// Polling keeps this view in sync with kiosk checkbox taps (and the other
// parent's edits) without a manual reload. Paused while the parent is mid-edit
// in any contenteditable field, so an incoming poll can't yank their cursor.
let isEditingFocused = false;
document.addEventListener('focusin', (e) => {
if (e.target.isContentEditable) isEditingFocused = true;
});
document.addEventListener('focusout', (e) => {
if (e.target.isContentEditable) {
setTimeout(() => {
isEditingFocused = !!(document.activeElement && document.activeElement.isContentEditable);
}, 0);
}
});
async function poll() {
if (!calendar || isEditingFocused || document.hidden) return;
try {
const data = await api.get(`/api/calendars/${calendarId}/poll?since=${encodeURIComponent(calendar.updatedAt)}`);
if (data.changed) {
calendar = data.calendar;
draw();
}
} catch (err) {
if (err.status === 401) window.location.href = '/login.html';
}
}
setInterval(poll, 4000);
document.addEventListener('visibilitychange', () => { if (!document.hidden) poll(); });
load();
+165
View File
@@ -0,0 +1,165 @@
const BLOCK_ICONS = { morning: '🌅', school: '🎒', after: '⚽', evening: '🌙' };
const WEEKDAYS = ['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday'];
const WEEKEND = ['Saturday', 'Sunday'];
function debounce(fn, ms) {
let t;
return (...args) => {
clearTimeout(t);
t = setTimeout(() => fn(...args), ms);
};
}
// Renders the weekly board. `editable` gates every mutation affordance
// (contenteditable, delete buttons, add-task buttons) — the kiosk view passes
// editable:false and gets a board with nothing but working checkboxes.
function renderCalendar({ calendar, editable, weekdayBoard, weekendWrap, handlers, todayName }) {
weekdayBoard.innerHTML = '';
weekendWrap.innerHTML = '';
const currentLabels = {};
calendar.blocks.forEach((b) => { currentLabels[b.key] = b.label; });
function syncLabel(key, sourceEl) {
document.querySelectorAll(`.label[data-block="${key}"]`).forEach((el) => {
if (el !== sourceEl) el.textContent = sourceEl.textContent;
});
document.querySelectorAll(`.legend-label[data-block="${key}"]`).forEach((el) => {
el.textContent = sourceEl.textContent;
});
}
function makeTask(dayName, blockKey, task) {
const row = document.createElement('div');
row.className = 'task' + (task.done ? ' done' : '') + (editable ? '' : ' kiosk');
row.dataset.taskId = task.id;
const cb = document.createElement('input');
cb.type = 'checkbox';
cb.checked = task.done;
cb.onchange = () => {
row.classList.toggle('done', cb.checked);
handlers.onToggleDone(task.id, cb.checked);
};
const span = document.createElement('div');
span.className = 'task-text';
span.textContent = task.text;
row.appendChild(cb);
row.appendChild(span);
if (editable) {
span.contentEditable = 'true';
span.addEventListener('keydown', (e) => {
if (e.key === 'Enter') { e.preventDefault(); span.blur(); }
});
const debouncedEdit = debounce(() => {
if (span.textContent.trim()) handlers.onTextEdit(task.id, span.textContent.trim());
}, 500);
span.addEventListener('input', debouncedEdit);
span.addEventListener('blur', () => {
if (!span.textContent.trim()) span.textContent = task.text;
});
const del = document.createElement('button');
del.className = 'del';
del.textContent = '✕';
del.onclick = () => {
row.remove();
handlers.onDeleteTask(task.id);
};
row.appendChild(del);
}
return row;
}
function makeDayCard(dayName) {
const card = document.createElement('div');
card.className = 'day-card' + (dayName === todayName ? ' today' : '');
const head = document.createElement('div');
head.className = 'day-head';
head.innerHTML = `<div class="day-name">${dayName}</div>` +
(dayName === todayName ? `<div class="today-badge">TODAY</div>` : '');
card.appendChild(head);
calendar.blocks.forEach((block) => {
const blockEl = document.createElement('div');
blockEl.className = 'block ' + block.key;
const title = document.createElement('div');
title.className = 'block-title';
const iconSpan = document.createElement('span');
iconSpan.className = 'icon';
iconSpan.textContent = BLOCK_ICONS[block.key] || '';
const labelSpan = document.createElement('span');
labelSpan.className = 'label';
labelSpan.dataset.block = block.key;
labelSpan.textContent = currentLabels[block.key];
if (editable) {
labelSpan.contentEditable = 'true';
labelSpan.addEventListener('keydown', (e) => {
if (e.key === 'Enter') { e.preventDefault(); labelSpan.blur(); }
});
const debouncedLabel = debounce(() => {
if (labelSpan.textContent.trim()) {
currentLabels[block.key] = labelSpan.textContent.trim();
handlers.onLabelEdit(block.key, labelSpan.textContent.trim());
}
}, 500);
labelSpan.addEventListener('input', () => { syncLabel(block.key, labelSpan); debouncedLabel(); });
}
title.appendChild(iconSpan);
title.appendChild(labelSpan);
blockEl.appendChild(title);
const list = document.createElement('div');
list.className = 'task-list';
(calendar.days[dayName][block.key] || []).forEach((task) => {
list.appendChild(makeTask(dayName, block.key, task));
});
blockEl.appendChild(list);
if (editable) {
const addBtn = document.createElement('button');
addBtn.className = 'add-task';
addBtn.textContent = '+ add task';
addBtn.onclick = async () => {
const newTask = await handlers.onAddTask(dayName, block.key, 'New task');
const row = makeTask(dayName, block.key, newTask);
list.appendChild(row);
const span = row.querySelector('.task-text');
span.focus();
document.execCommand('selectAll', false, null);
};
blockEl.appendChild(addBtn);
}
card.appendChild(blockEl);
});
return card;
}
WEEKDAYS.forEach((d) => weekdayBoard.appendChild(makeDayCard(d)));
WEEKEND.forEach((d) => weekendWrap.appendChild(makeDayCard(d)));
return { currentLabels };
}
function renderLegend(legendEl, calendar) {
legendEl.innerHTML = '';
calendar.blocks.forEach((block) => {
const item = document.createElement('span');
item.className = 'item';
item.innerHTML = `<span class="dot" style="background:var(--${block.key})"></span>` +
`<span class="legend-label" data-block="${block.key}">${block.label}</span>`;
legendEl.appendChild(item);
});
}
+213
View File
@@ -0,0 +1,213 @@
let children = [];
function showToast(msg) {
const toast = document.getElementById('toast');
toast.textContent = msg;
toast.classList.add('show');
setTimeout(() => toast.classList.remove('show'), 2200);
}
function fmtDate(iso) {
if (!iso) return '';
return new Date(iso).toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' });
}
async function boot() {
try {
const me = await api.get('/api/auth/me');
document.getElementById('householdName').textContent = me.household.name;
document.getElementById('whoami').textContent = `Logged in as ${me.parent.name} (${me.parent.email})`;
} catch {
window.location.href = '/login.html';
return;
}
await Promise.all([loadParents(), loadChildren()]);
}
async function loadParents() {
const { parents } = await api.get('/api/household');
const el = document.getElementById('parentsList');
el.innerHTML = '';
parents.forEach((p) => {
const card = document.createElement('div');
card.className = 'entity-card';
card.innerHTML = `<h3>${escapeHtml(p.name)}</h3><div class="meta">${escapeHtml(p.email)}</div>`;
el.appendChild(card);
});
}
async function loadChildren() {
const data = await api.get('/api/children');
children = data.children;
const el = document.getElementById('childrenList');
el.innerHTML = '';
if (children.length === 0) {
el.innerHTML = '<div class="empty-state">No children yet — add one to create their first calendar.</div>';
return;
}
for (const child of children) {
const card = await renderChildCard(child);
el.appendChild(card);
}
}
async function renderChildCard(child) {
const { calendars } = await api.get(`/api/children/${child.id}/calendars`);
const card = document.createElement('div');
card.className = 'entity-card';
const calendarsHtml = calendars.length
? calendars.map((c) => calendarRowHtml(child, c)).join('')
: '<div class="meta">No calendars yet.</div>';
card.innerHTML = `
<h3>${escapeHtml(child.name)}</h3>
<div class="meta">${calendars.length} calendar${calendars.length === 1 ? '' : 's'}</div>
<div>${calendarsHtml}</div>
<div class="row">
<button class="tool primary" data-action="new-cal" data-child="${child.id}">+ New calendar</button>
<button class="tool" data-action="kiosk-link" data-child="${child.id}">Tablet link</button>
<button class="tool danger" data-action="delete-child" data-child="${child.id}">Delete child</button>
</div>
`;
card.querySelectorAll('[data-action]').forEach((btn) => {
btn.addEventListener('click', () => handleCardAction(btn.dataset.action, btn.dataset));
});
return card;
}
function calendarRowHtml(child, cal) {
const isActive = child.activeCalendarId === cal.id;
return `
<div class="row" style="justify-content:space-between; align-items:center; padding:6px 0; border-top:1px solid var(--line);">
<div>
<strong>${escapeHtml(cal.title)}</strong>
<div class="meta">Updated ${fmtDate(cal.updated_at)}${isActive ? ' · on tablet' : ''}</div>
</div>
<div class="row">
<a class="tool" href="/calendar.html?calendarId=${cal.id}">Open</a>
<button class="tool" data-action="duplicate-cal" data-child="${child.id}" data-cal="${cal.id}" data-title="${escapeHtml(cal.title)}">Duplicate</button>
${isActive ? '' : `<button class="tool" data-action="set-active" data-child="${child.id}" data-cal="${cal.id}">Set on tablet</button>`}
<button class="tool danger" data-action="delete-cal" data-child="${child.id}" data-cal="${cal.id}">Delete</button>
</div>
</div>
`;
}
function escapeHtml(str) {
const div = document.createElement('div');
div.textContent = str;
return div.innerHTML;
}
async function handleCardAction(action, ds) {
if (action === 'new-cal') {
document.getElementById('newCalChildId').value = ds.child;
document.getElementById('newCalTitle').value = '';
document.getElementById('newCalDuplicate').checked = false;
openModal('newCalendarModal');
} else if (action === 'duplicate-cal') {
const title = prompt('Title for the duplicated calendar:', `Copy of ${ds.title}`);
if (!title) return;
await api.post(`/api/children/${ds.child}/calendars`, { title, duplicateFromCalendarId: Number(ds.cal) });
showToast('Calendar duplicated');
await loadChildren();
} else if (action === 'set-active') {
await api.patch(`/api/children/${ds.child}`, { activeCalendarId: Number(ds.cal) });
showToast('Tablet will now show this calendar');
await loadChildren();
} else if (action === 'delete-cal') {
if (!confirm('Delete this calendar? This cannot be undone.')) return;
await api.del(`/api/calendars/${ds.cal}`);
showToast('Calendar deleted');
await loadChildren();
} else if (action === 'kiosk-link') {
const child = children.find((c) => c.id === Number(ds.child));
openKioskModal(child);
} else if (action === 'delete-child') {
if (!confirm('Delete this child and all their calendars? This cannot be undone.')) return;
await api.del(`/api/children/${ds.child}`);
showToast('Child deleted');
await loadChildren();
}
}
function openModal(id) { document.getElementById(id).classList.remove('hidden'); }
function closeModal(id) { document.getElementById(id).classList.add('hidden'); }
document.getElementById('addChildBtn').addEventListener('click', () => {
document.getElementById('childNameInput').value = '';
openModal('addChildModal');
});
document.getElementById('cancelAddChildBtn').addEventListener('click', () => closeModal('addChildModal'));
document.getElementById('addChildForm').addEventListener('submit', async (e) => {
e.preventDefault();
await api.post('/api/children', { name: document.getElementById('childNameInput').value });
closeModal('addChildModal');
showToast('Child added');
await loadChildren();
});
document.getElementById('cancelNewCalBtn').addEventListener('click', () => closeModal('newCalendarModal'));
document.getElementById('newCalendarForm').addEventListener('submit', async (e) => {
e.preventDefault();
const childId = document.getElementById('newCalChildId').value;
const title = document.getElementById('newCalTitle').value;
const duplicate = document.getElementById('newCalDuplicate').checked;
let body = { title };
if (duplicate) {
const { calendars } = await api.get(`/api/children/${childId}/calendars`);
if (calendars.length) body.duplicateFromCalendarId = calendars[0].id;
}
const { calendar } = await api.post(`/api/children/${childId}/calendars`, body);
closeModal('newCalendarModal');
window.location.href = `/calendar.html?calendarId=${calendar.id}`;
});
document.getElementById('inviteBtn').addEventListener('click', async () => {
const { invite } = await api.post('/api/household/invites');
const link = `${window.location.origin}${invite.joinPath}`;
document.getElementById('inviteLinkField').value = link;
openModal('inviteModal');
});
document.getElementById('closeInviteBtn').addEventListener('click', () => closeModal('inviteModal'));
document.getElementById('copyInviteBtn').addEventListener('click', () => {
document.getElementById('inviteLinkField').select();
navigator.clipboard.writeText(document.getElementById('inviteLinkField').value);
showToast('Link copied');
});
let kioskModalChild = null;
function openKioskModal(child) {
kioskModalChild = child;
document.getElementById('kioskChildName').textContent = child.name;
document.getElementById('kioskLinkField').value = `${window.location.origin}${child.kioskPath}`;
openModal('kioskModal');
}
document.getElementById('closeKioskBtn').addEventListener('click', () => closeModal('kioskModal'));
document.getElementById('copyKioskBtn').addEventListener('click', () => {
document.getElementById('kioskLinkField').select();
navigator.clipboard.writeText(document.getElementById('kioskLinkField').value);
showToast('Link copied');
});
document.getElementById('regenKioskBtn').addEventListener('click', async () => {
if (!confirm('Regenerate this tablet link? The old link will stop working.')) return;
const data = await api.post(`/api/children/${kioskModalChild.id}/kiosk-token/regenerate`);
document.getElementById('kioskLinkField').value = `${window.location.origin}${data.kioskPath}`;
showToast('Link regenerated');
await loadChildren();
});
document.getElementById('logoutBtn').addEventListener('click', async () => {
await api.post('/api/auth/logout');
window.location.href = '/login.html';
});
boot();
+59
View File
@@ -0,0 +1,59 @@
const token = window.location.pathname.split('/').filter(Boolean).pop();
const weekdayBoard = document.getElementById('weekdayBoard');
const weekendWrap = document.getElementById('weekendWrap');
async function apiGet(path) {
const res = await fetch(path, { credentials: 'omit' });
if (!res.ok) throw new Error('request failed');
return res.json();
}
async function apiPatch(path, body) {
const res = await fetch(path, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
if (!res.ok) throw new Error('request failed');
return res.json();
}
const handlers = {
onToggleDone: (taskId, done) => apiPatch(`/api/kiosk/${token}/tasks/${taskId}`, { done }).catch(() => {}),
};
function todayName() {
return new Date().toLocaleDateString('en-US', { weekday: 'long' });
}
function draw(calendar) {
document.getElementById('childName').textContent = calendar.childName || '';
document.getElementById('calTitle').textContent = calendar.title || '';
renderCalendar({
calendar,
editable: false,
weekdayBoard,
weekendWrap,
handlers,
todayName: todayName(),
});
}
async function poll() {
try {
const data = await apiGet(`/api/kiosk/${token}/calendar`);
if (!data.calendar) {
document.getElementById('emptyState').style.display = 'block';
document.getElementById('childName').textContent = data.child ? data.child.name : '';
return;
}
document.getElementById('emptyState').style.display = 'none';
draw(data.calendar);
} catch {
// transient network error — try again next tick
}
}
poll();
setInterval(poll, 4000);
+38
View File
@@ -0,0 +1,38 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>My Week</title>
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link href="https://fonts.googleapis.com/css2?family=Baloo+2:wght@500;700;800&family=Nunito:wght@500;700;800&display=swap" rel="stylesheet">
<link rel="stylesheet" href="/css/shared.css">
<style>
/* Kiosk is touch-first and has nothing to edit — bigger targets, no toolbar chrome. */
header{ justify-content: center; text-align: center; }
.toolbar{ display:none; }
.board{ grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); }
.task-text{ font-size: 1.02rem; }
</style>
</head>
<body>
<header>
<div>
<h1 id="childName"></h1>
<div class="subtitle" id="calTitle"></div>
</div>
</header>
<div class="board" id="weekdayBoard"></div>
<div class="weekend-toggle-wrap" id="weekendWrap"></div>
<div id="emptyState" class="empty-state" style="display:none; max-width:600px; margin:60px auto;">
No calendar has been set up yet. Ask a parent to create one.
</div>
<script src="/js/calendarRender.js"></script>
<script src="/js/kiosk.js"></script>
</body>
</html>
+53
View File
@@ -0,0 +1,53 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Log in — Kids Calendar</title>
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link href="https://fonts.googleapis.com/css2?family=Baloo+2:wght@500;700;800&family=Nunito:wght@500;700;800&display=swap" rel="stylesheet">
<link rel="stylesheet" href="/css/shared.css">
</head>
<body>
<div class="auth-wrap">
<h1>Welcome back</h1>
<p class="lead">Log in to see and edit your family's calendars.</p>
<div class="error-msg" id="errorMsg" style="display:none;"></div>
<form id="loginForm">
<div class="field">
<label for="email">Email</label>
<input type="email" id="email" autocomplete="email" required>
</div>
<div class="field">
<label for="password">Password</label>
<input type="password" id="password" autocomplete="current-password" required>
</div>
<div class="form-actions">
<button type="submit" class="tool primary">Log in</button>
<div class="switch-link">No account yet? <a href="/signup.html">Sign up</a></div>
</div>
</form>
</div>
<script src="/js/api.js"></script>
<script>
document.getElementById('loginForm').addEventListener('submit', async (e) => {
e.preventDefault();
const errorMsg = document.getElementById('errorMsg');
errorMsg.style.display = 'none';
try {
await api.post('/api/auth/login', {
email: document.getElementById('email').value,
password: document.getElementById('password').value,
});
window.location.href = '/dashboard.html';
} catch (err) {
errorMsg.textContent = err.message;
errorMsg.style.display = 'block';
}
});
</script>
</body>
</html>
+63
View File
@@ -0,0 +1,63 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Sign up — Kids Calendar</title>
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link href="https://fonts.googleapis.com/css2?family=Baloo+2:wght@500;700;800&family=Nunito:wght@500;700;800&display=swap" rel="stylesheet">
<link rel="stylesheet" href="/css/shared.css">
</head>
<body>
<div class="auth-wrap">
<h1>Create your family</h1>
<p class="lead">This creates a new household. Your spouse can join it afterward with an invite link.</p>
<div class="error-msg" id="errorMsg" style="display:none;"></div>
<form id="signupForm">
<div class="field">
<label for="name">Your name</label>
<input type="text" id="name" autocomplete="name" required>
</div>
<div class="field">
<label for="householdName">Family name (optional)</label>
<input type="text" id="householdName" placeholder="e.g. The Smiths">
</div>
<div class="field">
<label for="email">Email</label>
<input type="email" id="email" autocomplete="email" required>
</div>
<div class="field">
<label for="password">Password</label>
<input type="password" id="password" autocomplete="new-password" minlength="8" required>
</div>
<div class="form-actions">
<button type="submit" class="tool primary">Sign up</button>
<div class="switch-link">Already have an account? <a href="/login.html">Log in</a></div>
</div>
</form>
</div>
<script src="/js/api.js"></script>
<script>
document.getElementById('signupForm').addEventListener('submit', async (e) => {
e.preventDefault();
const errorMsg = document.getElementById('errorMsg');
errorMsg.style.display = 'none';
try {
await api.post('/api/auth/signup', {
name: document.getElementById('name').value,
householdName: document.getElementById('householdName').value,
email: document.getElementById('email').value,
password: document.getElementById('password').value,
});
window.location.href = '/dashboard.html';
} catch (err) {
errorMsg.textContent = err.message;
errorMsg.style.display = 'block';
}
});
</script>
</body>
</html>
+13
View File
@@ -0,0 +1,13 @@
const app = require('./src/app');
const config = require('./src/config');
if (config.sessionSecret === 'dev-secret-change-me') {
console.warn(
'WARNING: SESSION_SECRET is not set — using an insecure default. ' +
'Set SESSION_SECRET to a long random value before exposing this beyond localhost.'
);
}
app.listen(config.port, () => {
console.log(`Kids Calendar listening on http://localhost:${config.port}`);
});
+62
View File
@@ -0,0 +1,62 @@
const path = require('node:path');
const express = require('express');
const session = require('express-session');
const rateLimit = require('express-rate-limit');
const config = require('./config');
const SqliteSessionStore = require('./lib/sqliteSessionStore');
const csrfCheck = require('./middleware/csrf');
const authRoutes = require('./routes/auth');
const householdRoutes = require('./routes/household');
const { manageRouter: inviteManageRoutes, publicRouter: invitePublicRoutes } = require('./routes/invites');
const childrenRoutes = require('./routes/children');
const calendarRoutes = require('./routes/calendars');
const kioskRoutes = require('./routes/kiosk');
const app = express();
app.disable('x-powered-by');
app.use(express.json({ limit: '256kb' }));
app.use(session({
store: new SqliteSessionStore(),
name: 'kc.sid',
secret: config.sessionSecret,
resave: false,
saveUninitialized: false,
cookie: {
httpOnly: true,
sameSite: 'lax',
secure: config.cookieSecure,
maxAge: 30 * 24 * 60 * 60 * 1000,
},
}));
app.use('/api', csrfCheck);
const authLimiter = rateLimit({ windowMs: 15 * 60 * 1000, max: 30, standardHeaders: true, legacyHeaders: false });
app.use('/api/auth/login', authLimiter);
app.use('/api/auth/signup', authLimiter);
app.use('/api/invites', authLimiter);
app.use('/api/auth', authRoutes);
app.use('/api/household/invites', inviteManageRoutes);
app.use('/api/household', householdRoutes);
app.use('/api/invites', invitePublicRoutes);
app.use('/api/children', childrenRoutes);
app.use('/api/calendars', calendarRoutes);
app.use('/api/kiosk/:token', kioskRoutes);
// Short, bookmarkable kiosk URL for the tablet: redirect to the static kiosk page,
// which reads the token back out of the path client-side.
app.get('/k/:token', (req, res) => {
res.sendFile(path.join(__dirname, '..', 'public', 'kiosk.html'));
});
app.use(express.static(path.join(__dirname, '..', 'public')));
app.use('/api', (req, res) => {
res.status(404).json({ error: 'Not found' });
});
module.exports = app;
+12
View File
@@ -0,0 +1,12 @@
const path = require('node:path');
const DATA_DIR = process.env.DATA_DIR || path.join(__dirname, '..', 'data');
module.exports = {
port: parseInt(process.env.PORT || '3007', 10),
dataDir: DATA_DIR,
dbPath: path.join(DATA_DIR, 'kids-calendar.sqlite'),
sessionSecret: process.env.SESSION_SECRET || 'dev-secret-change-me',
cookieSecure: process.env.COOKIE_SECURE === 'true',
disablePublicSignup: process.env.DISABLE_PUBLIC_SIGNUP === 'true',
};
+14
View File
@@ -0,0 +1,14 @@
const fs = require('node:fs');
const { DatabaseSync } = require('node:sqlite');
const config = require('../config');
const { migrate } = require('./migrate');
fs.mkdirSync(config.dataDir, { recursive: true });
const db = new DatabaseSync(config.dbPath);
db.exec('PRAGMA foreign_keys = ON;');
db.exec('PRAGMA journal_mode = WAL;');
migrate(db);
module.exports = db;
+9
View File
@@ -0,0 +1,9 @@
const fs = require('node:fs');
const path = require('node:path');
function migrate(db) {
const schema = fs.readFileSync(path.join(__dirname, 'schema.sql'), 'utf8');
db.exec(schema);
}
module.exports = { migrate };
+76
View File
@@ -0,0 +1,76 @@
CREATE TABLE IF NOT EXISTS households (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
);
CREATE TABLE IF NOT EXISTS parents (
id INTEGER PRIMARY KEY AUTOINCREMENT,
household_id INTEGER NOT NULL REFERENCES households(id) ON DELETE CASCADE,
email TEXT NOT NULL UNIQUE COLLATE NOCASE,
password_hash TEXT NOT NULL,
name TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
);
CREATE INDEX IF NOT EXISTS idx_parents_household ON parents(household_id);
CREATE TABLE IF NOT EXISTS household_invites (
id INTEGER PRIMARY KEY AUTOINCREMENT,
household_id INTEGER NOT NULL REFERENCES households(id) ON DELETE CASCADE,
token TEXT NOT NULL UNIQUE,
created_by_parent_id INTEGER NOT NULL REFERENCES parents(id) ON DELETE CASCADE,
expires_at TEXT NOT NULL,
used_at TEXT,
used_by_parent_id INTEGER REFERENCES parents(id),
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
);
CREATE INDEX IF NOT EXISTS idx_invites_household ON household_invites(household_id);
CREATE TABLE IF NOT EXISTS children (
id INTEGER PRIMARY KEY AUTOINCREMENT,
household_id INTEGER NOT NULL REFERENCES households(id) ON DELETE CASCADE,
name TEXT NOT NULL,
kiosk_token TEXT NOT NULL UNIQUE,
active_calendar_id INTEGER REFERENCES calendars(id) ON DELETE SET NULL,
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
);
CREATE INDEX IF NOT EXISTS idx_children_household ON children(household_id);
CREATE TABLE IF NOT EXISTS calendars (
id INTEGER PRIMARY KEY AUTOINCREMENT,
child_id INTEGER NOT NULL REFERENCES children(id) ON DELETE CASCADE,
title TEXT NOT NULL,
week_start_date TEXT,
show_weekend INTEGER NOT NULL DEFAULT 0,
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')),
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
);
CREATE INDEX IF NOT EXISTS idx_calendars_child ON calendars(child_id);
CREATE TABLE IF NOT EXISTS calendar_blocks (
calendar_id INTEGER NOT NULL REFERENCES calendars(id) ON DELETE CASCADE,
block_key TEXT NOT NULL CHECK (block_key IN ('morning','school','after','evening')),
label TEXT NOT NULL,
PRIMARY KEY (calendar_id, block_key)
);
CREATE TABLE IF NOT EXISTS calendar_tasks (
id INTEGER PRIMARY KEY AUTOINCREMENT,
calendar_id INTEGER NOT NULL REFERENCES calendars(id) ON DELETE CASCADE,
day_of_week TEXT NOT NULL CHECK (day_of_week IN
('Monday','Tuesday','Wednesday','Thursday','Friday','Saturday','Sunday')),
block_key TEXT NOT NULL CHECK (block_key IN ('morning','school','after','evening')),
text TEXT NOT NULL,
done INTEGER NOT NULL DEFAULT 0,
sort_order INTEGER NOT NULL DEFAULT 0,
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')),
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
);
CREATE INDEX IF NOT EXISTS idx_tasks_calendar ON calendar_tasks(calendar_id, day_of_week, block_key);
CREATE TABLE IF NOT EXISTS sessions (
sid TEXT PRIMARY KEY,
sess TEXT NOT NULL,
expires_at INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_sessions_expires ON sessions(expires_at);
+52
View File
@@ -0,0 +1,52 @@
const db = require('../db');
const { BLOCK_KEYS, ALL_DAYS } = require('./defaults');
const getCalendarStmt = db.prepare('SELECT * FROM calendars WHERE id = ?');
const getChildStmt = db.prepare('SELECT id, name FROM children WHERE id = ?');
const getBlocksStmt = db.prepare('SELECT block_key, label FROM calendar_blocks WHERE calendar_id = ?');
const getTasksStmt = db.prepare(
'SELECT id, day_of_week, block_key, text, done, sort_order FROM calendar_tasks WHERE calendar_id = ? ORDER BY sort_order, id'
);
// Shared shape consumed by the parent editor, the kiosk view, and the poll endpoint.
function hydrateCalendar(calendarId) {
const calendar = getCalendarStmt.get(calendarId);
if (!calendar) return null;
const child = getChildStmt.get(calendar.child_id);
const blockRows = getBlocksStmt.all(calendarId);
const blockLabels = Object.fromEntries(blockRows.map((b) => [b.block_key, b.label]));
const days = {};
ALL_DAYS.forEach((day) => {
days[day] = {};
BLOCK_KEYS.forEach((key) => {
days[day][key] = [];
});
});
getTasksStmt.all(calendarId).forEach((t) => {
if (!days[t.day_of_week]) return;
days[t.day_of_week][t.block_key].push({
id: t.id,
text: t.text,
done: !!t.done,
sortOrder: t.sort_order,
});
});
return {
id: calendar.id,
childId: calendar.child_id,
childName: child ? child.name : null,
title: calendar.title,
weekStartDate: calendar.week_start_date,
showWeekend: !!calendar.show_weekend,
updatedAt: calendar.updated_at,
createdAt: calendar.created_at,
blocks: BLOCK_KEYS.map((key) => ({ key, label: blockLabels[key] || key })),
days,
};
}
module.exports = { hydrateCalendar };
+52
View File
@@ -0,0 +1,52 @@
const db = require('../db');
const { BLOCK_KEYS, DEFAULT_BLOCK_LABELS, DEFAULT_TASKS, ALL_DAYS } = require('./defaults');
const { hydrateCalendar } = require('./calendarHydrate');
const insertCalendarStmt = db.prepare(
'INSERT INTO calendars (child_id, title, week_start_date) VALUES (?, ?, ?)'
);
const setShowWeekendStmt = db.prepare('UPDATE calendars SET show_weekend = ? WHERE id = ?');
const insertBlockStmt = db.prepare(
'INSERT INTO calendar_blocks (calendar_id, block_key, label) VALUES (?, ?, ?)'
);
const insertTaskStmt = db.prepare(
'INSERT INTO calendar_tasks (calendar_id, day_of_week, block_key, text, sort_order) VALUES (?, ?, ?, ?, ?)'
);
const getBlocksStmt = db.prepare('SELECT block_key, label FROM calendar_blocks WHERE calendar_id = ?');
const getTasksStmt = db.prepare(
'SELECT day_of_week, block_key, text, sort_order FROM calendar_tasks WHERE calendar_id = ? ORDER BY sort_order'
);
const getCalendarStmt = db.prepare('SELECT * FROM calendars WHERE id = ?');
function createBlankCalendar(childId, title, weekStartDate) {
const calendarId = Number(insertCalendarStmt.run(childId, title, weekStartDate).lastInsertRowid);
BLOCK_KEYS.forEach((key) => {
insertBlockStmt.run(calendarId, key, DEFAULT_BLOCK_LABELS[key]);
DEFAULT_TASKS[key].forEach((text, i) => {
ALL_DAYS.forEach((day) => {
insertTaskStmt.run(calendarId, day, key, text, i);
});
});
});
return hydrateCalendar(calendarId);
}
function createDuplicateCalendar(sourceCalendarId, childId, title, weekStartDate) {
const source = getCalendarStmt.get(sourceCalendarId);
const calendarId = Number(insertCalendarStmt.run(childId, title, weekStartDate).lastInsertRowid);
setShowWeekendStmt.run(source.show_weekend, calendarId);
getBlocksStmt.all(sourceCalendarId).forEach((b) => {
insertBlockStmt.run(calendarId, b.block_key, b.label);
});
// done intentionally not copied — new week starts unchecked (column default is 0)
getTasksStmt.all(sourceCalendarId).forEach((t) => {
insertTaskStmt.run(calendarId, t.day_of_week, t.block_key, t.text, t.sort_order);
});
return hydrateCalendar(calendarId);
}
module.exports = { createBlankCalendar, createDuplicateCalendar };
+21
View File
@@ -0,0 +1,21 @@
const BLOCK_KEYS = ['morning', 'school', 'after', 'evening'];
const DEFAULT_BLOCK_LABELS = {
morning: 'Morning',
school: 'School',
after: 'After School',
evening: 'Evening',
};
const DEFAULT_TASKS = {
morning: ['Wake up & get dressed', 'Brush teeth', 'Eat breakfast', 'Pack backpack'],
school: ['Reading time', 'Math practice', 'Lunch & recess'],
after: ['Snack', '20 min free play', 'Homework'],
evening: ['Dinner', 'Bath', "Pick tomorrow's clothes", 'Read a book', 'Lights out'],
};
const WEEKDAYS = ['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday'];
const WEEKEND = ['Saturday', 'Sunday'];
const ALL_DAYS = [...WEEKDAYS, ...WEEKEND];
module.exports = { BLOCK_KEYS, DEFAULT_BLOCK_LABELS, DEFAULT_TASKS, WEEKDAYS, WEEKEND, ALL_DAYS };
+59
View File
@@ -0,0 +1,59 @@
const session = require('express-session');
const db = require('../db');
const insertStmt = db.prepare(
'INSERT INTO sessions (sid, sess, expires_at) VALUES (?, ?, ?) ' +
'ON CONFLICT(sid) DO UPDATE SET sess = excluded.sess, expires_at = excluded.expires_at'
);
const selectStmt = db.prepare('SELECT sess, expires_at FROM sessions WHERE sid = ?');
const deleteStmt = db.prepare('DELETE FROM sessions WHERE sid = ?');
const pruneStmt = db.prepare('DELETE FROM sessions WHERE expires_at < ?');
const DEFAULT_TTL_MS = 30 * 24 * 60 * 60 * 1000; // 30 days
class SqliteSessionStore extends session.Store {
constructor() {
super();
this._pruneInterval = setInterval(() => {
pruneStmt.run(Date.now());
}, 60 * 60 * 1000);
this._pruneInterval.unref();
}
get(sid, cb) {
try {
const row = selectStmt.get(sid);
if (!row || row.expires_at < Date.now()) return cb(null, null);
cb(null, JSON.parse(row.sess));
} catch (err) {
cb(err);
}
}
set(sid, sessionData, cb) {
try {
const ttl = sessionData.cookie && sessionData.cookie.maxAge
? sessionData.cookie.maxAge
: DEFAULT_TTL_MS;
insertStmt.run(sid, JSON.stringify(sessionData), Date.now() + ttl);
cb(null);
} catch (err) {
cb(err);
}
}
destroy(sid, cb) {
try {
deleteStmt.run(sid);
cb(null);
} catch (err) {
cb(err);
}
}
touch(sid, sessionData, cb) {
this.set(sid, sessionData, cb || (() => {}));
}
}
module.exports = SqliteSessionStore;
+7
View File
@@ -0,0 +1,7 @@
const crypto = require('node:crypto');
function randomToken(bytes = 24) {
return crypto.randomBytes(bytes).toString('base64url');
}
module.exports = { randomToken };
+13
View File
@@ -0,0 +1,13 @@
const db = require('../db');
const touchStmt = db.prepare(
"UPDATE calendars SET updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE id = ?"
);
// Bumped inside the same synchronous call as any task/block write so the
// poll endpoint's since= comparison sees every mutation.
function touchCalendar(calendarId) {
touchStmt.run(calendarId);
}
module.exports = touchCalendar;
+15
View File
@@ -0,0 +1,15 @@
function isNonEmptyString(value, maxLength) {
return typeof value === 'string' && value.trim().length > 0 && value.length <= maxLength;
}
const LIMITS = {
NAME: 100,
EMAIL: 254,
PASSWORD: 200,
HOUSEHOLD_NAME: 100,
TITLE: 200,
LABEL: 60,
TASK_TEXT: 300,
};
module.exports = { isNonEmptyString, LIMITS };
+28
View File
@@ -0,0 +1,28 @@
const SAFE_METHODS = new Set(['GET', 'HEAD', 'OPTIONS']);
// Cookie-based sessions + same-origin fetch calls: block cross-site state
// changes by checking Origin (falling back to Referer) against the request's
// own Host. Simpler than a full CSRF-token library for a small LAN-scale app.
function csrfCheck(req, res, next) {
if (SAFE_METHODS.has(req.method)) return next();
const origin = req.get('origin');
const referer = req.get('referer');
const source = origin || referer;
if (!source) return res.status(403).json({ error: 'Missing origin' });
let sourceHost;
try {
sourceHost = new URL(source).host;
} catch {
return res.status(403).json({ error: 'Invalid origin' });
}
if (sourceHost !== req.get('host')) {
return res.status(403).json({ error: 'Cross-origin request blocked' });
}
next();
}
module.exports = csrfCheck;
+19
View File
@@ -0,0 +1,19 @@
const db = require('../db');
const getParentStmt = db.prepare('SELECT id, household_id, email, name FROM parents WHERE id = ?');
function requireAuth(req, res, next) {
const parentId = req.session && req.session.parentId;
if (!parentId) return res.status(401).json({ error: 'Not signed in' });
const parent = getParentStmt.get(parentId);
if (!parent) {
req.session.destroy(() => {});
return res.status(401).json({ error: 'Not signed in' });
}
req.parent = parent;
next();
}
module.exports = requireAuth;
+33
View File
@@ -0,0 +1,33 @@
const db = require('../db');
const getChildStmt = db.prepare('SELECT * FROM children WHERE id = ?');
const getCalendarStmt = db.prepare('SELECT * FROM calendars WHERE id = ?');
// Resource nesting differs per route (child vs. calendar vs. task-under-calendar),
// so ownership checks are exposed as helpers route handlers call directly,
// rather than a one-size-fits-all param middleware.
function loadOwnedChild(req, res, childId) {
const child = getChildStmt.get(childId);
if (!child || child.household_id !== req.parent.household_id) {
res.status(404).json({ error: 'Child not found' });
return null;
}
return child;
}
function loadOwnedCalendar(req, res, calendarId) {
const calendar = getCalendarStmt.get(calendarId);
if (!calendar) {
res.status(404).json({ error: 'Calendar not found' });
return null;
}
const child = getChildStmt.get(calendar.child_id);
if (!child || child.household_id !== req.parent.household_id) {
res.status(404).json({ error: 'Calendar not found' });
return null;
}
return calendar;
}
module.exports = { loadOwnedChild, loadOwnedCalendar };
+15
View File
@@ -0,0 +1,15 @@
const db = require('../db');
const getChildByTokenStmt = db.prepare('SELECT * FROM children WHERE kiosk_token = ?');
function resolveKiosk(req, res, next) {
if (typeof req.params.token !== 'string' || !req.params.token) {
return res.status(404).json({ error: 'Invalid kiosk link' });
}
const child = getChildByTokenStmt.get(req.params.token);
if (!child) return res.status(404).json({ error: 'Invalid kiosk link' });
req.child = child;
next();
}
module.exports = resolveKiosk;
+84
View File
@@ -0,0 +1,84 @@
const express = require('express');
const bcrypt = require('bcryptjs');
const db = require('../db');
const config = require('../config');
const requireAuth = require('../middleware/requireAuth');
const { isNonEmptyString, LIMITS } = require('../lib/validate');
const router = express.Router();
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
const insertHouseholdStmt = db.prepare('INSERT INTO households (name) VALUES (?)');
const insertParentStmt = db.prepare(
'INSERT INTO parents (household_id, email, password_hash, name) VALUES (?, ?, ?, ?)'
);
const getParentByEmailStmt = db.prepare('SELECT * FROM parents WHERE email = ?');
const getHouseholdStmt = db.prepare('SELECT * FROM households WHERE id = ?');
function publicParent(parent) {
return { id: parent.id, email: parent.email, name: parent.name, householdId: parent.household_id };
}
router.post('/signup', (req, res) => {
if (config.disablePublicSignup) {
return res.status(403).json({ error: 'New signups are disabled on this server' });
}
const { email, password, name, householdName } = req.body || {};
if (!isNonEmptyString(email, LIMITS.EMAIL) || !EMAIL_RE.test(email)) {
return res.status(400).json({ error: 'A valid email is required' });
}
if (typeof password !== 'string' || password.length < 8 || password.length > LIMITS.PASSWORD) {
return res.status(400).json({ error: `Password must be 8-${LIMITS.PASSWORD} characters` });
}
if (!isNonEmptyString(name, LIMITS.NAME)) {
return res.status(400).json({ error: 'Name is required' });
}
if (householdName !== undefined && householdName !== '' && !isNonEmptyString(householdName, LIMITS.HOUSEHOLD_NAME)) {
return res.status(400).json({ error: 'Family name is too long' });
}
if (getParentByEmailStmt.get(email)) {
return res.status(409).json({ error: 'An account with that email already exists' });
}
const hhName = (typeof householdName === 'string' && householdName.trim())
? householdName.trim()
: `${name.trim()}'s Family`;
const householdId = insertHouseholdStmt.run(hhName).lastInsertRowid;
const passwordHash = bcrypt.hashSync(password, 12);
const parentId = insertParentStmt.run(householdId, email.toLowerCase(), passwordHash, name.trim()).lastInsertRowid;
req.session.parentId = Number(parentId);
res.status(201).json({ parent: publicParent({ id: parentId, email, name: name.trim(), household_id: householdId }) });
});
router.post('/login', (req, res) => {
const { email, password } = req.body || {};
if (!isNonEmptyString(email, LIMITS.EMAIL) || typeof password !== 'string' || password.length > LIMITS.PASSWORD) {
return res.status(400).json({ error: 'Email and password are required' });
}
const parent = getParentByEmailStmt.get(email.toLowerCase());
if (!parent || !bcrypt.compareSync(password, parent.password_hash)) {
return res.status(401).json({ error: 'Invalid email or password' });
}
req.session.parentId = parent.id;
res.json({ parent: publicParent(parent) });
});
router.post('/logout', (req, res) => {
req.session.destroy(() => {
res.clearCookie('kc.sid');
res.status(204).end();
});
});
router.get('/me', requireAuth, (req, res) => {
const household = getHouseholdStmt.get(req.parent.household_id);
res.json({ parent: publicParent(req.parent), household: { id: household.id, name: household.name } });
});
module.exports = router;
+164
View File
@@ -0,0 +1,164 @@
const express = require('express');
const db = require('../db');
const requireAuth = require('../middleware/requireAuth');
const { loadOwnedCalendar } = require('../middleware/requireHousehold');
const { hydrateCalendar } = require('../lib/calendarHydrate');
const touchCalendar = require('../lib/touchCalendar');
const { BLOCK_KEYS, ALL_DAYS } = require('../lib/defaults');
const { isNonEmptyString, LIMITS } = require('../lib/validate');
const router = express.Router();
router.use(requireAuth);
const updateCalendarStmt = db.prepare(
'UPDATE calendars SET title = COALESCE(?, title), week_start_date = COALESCE(?, week_start_date), show_weekend = COALESCE(?, show_weekend) WHERE id = ?'
);
const deleteCalendarStmt = db.prepare('DELETE FROM calendars WHERE id = ?');
const upsertBlockStmt = db.prepare(
'INSERT INTO calendar_blocks (calendar_id, block_key, label) VALUES (?, ?, ?) ' +
'ON CONFLICT(calendar_id, block_key) DO UPDATE SET label = excluded.label'
);
const insertTaskStmt = db.prepare(
'INSERT INTO calendar_tasks (calendar_id, day_of_week, block_key, text, sort_order) VALUES (?, ?, ?, ?, ?)'
);
const maxSortOrderStmt = db.prepare(
'SELECT COALESCE(MAX(sort_order), -1) AS maxOrder FROM calendar_tasks WHERE calendar_id = ? AND day_of_week = ? AND block_key = ?'
);
const getTaskStmt = db.prepare('SELECT * FROM calendar_tasks WHERE id = ?');
const updateTaskStmt = db.prepare(
'UPDATE calendar_tasks SET text = COALESCE(?, text), done = COALESCE(?, done), sort_order = COALESCE(?, sort_order), ' +
"updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE id = ?"
);
const deleteTaskStmt = db.prepare('DELETE FROM calendar_tasks WHERE id = ?');
const resetChecksStmt = db.prepare('UPDATE calendar_tasks SET done = 0 WHERE calendar_id = ?');
router.get('/:id', (req, res) => {
const calendar = loadOwnedCalendar(req, res, req.params.id);
if (!calendar) return;
res.json({ calendar: hydrateCalendar(calendar.id) });
});
router.get('/:id/poll', (req, res) => {
const calendar = loadOwnedCalendar(req, res, req.params.id);
if (!calendar) return;
const since = req.query.since;
if (since && calendar.updated_at <= since) {
return res.json({ changed: false });
}
res.json({ changed: true, calendar: hydrateCalendar(calendar.id) });
});
router.patch('/:id', (req, res) => {
const calendar = loadOwnedCalendar(req, res, req.params.id);
if (!calendar) return;
const { title, weekStartDate, showWeekend } = req.body || {};
if (title !== undefined && !isNonEmptyString(title, LIMITS.TITLE)) {
return res.status(400).json({ error: 'Title cannot be empty' });
}
updateCalendarStmt.run(
title !== undefined ? title.trim() : null,
weekStartDate !== undefined ? weekStartDate : null,
showWeekend !== undefined ? (showWeekend ? 1 : 0) : null,
calendar.id
);
touchCalendar(calendar.id);
res.json({ calendar: hydrateCalendar(calendar.id) });
});
router.delete('/:id', (req, res) => {
const calendar = loadOwnedCalendar(req, res, req.params.id);
if (!calendar) return;
deleteCalendarStmt.run(calendar.id);
res.status(204).end();
});
router.patch('/:id/blocks/:blockKey', (req, res) => {
const calendar = loadOwnedCalendar(req, res, req.params.id);
if (!calendar) return;
const { blockKey } = req.params;
if (!BLOCK_KEYS.includes(blockKey)) {
return res.status(400).json({ error: 'Unknown block' });
}
const { label } = req.body || {};
if (!isNonEmptyString(label, LIMITS.LABEL)) {
return res.status(400).json({ error: 'Label cannot be empty' });
}
upsertBlockStmt.run(calendar.id, blockKey, label.trim());
touchCalendar(calendar.id);
res.json({ calendar: hydrateCalendar(calendar.id) });
});
router.post('/:id/tasks', (req, res) => {
const calendar = loadOwnedCalendar(req, res, req.params.id);
if (!calendar) return;
const { dayOfWeek, blockKey, text } = req.body || {};
if (!ALL_DAYS.includes(dayOfWeek)) return res.status(400).json({ error: 'Invalid day' });
if (!BLOCK_KEYS.includes(blockKey)) return res.status(400).json({ error: 'Invalid block' });
if (!isNonEmptyString(text, LIMITS.TASK_TEXT)) return res.status(400).json({ error: 'Text is required' });
const nextOrder = maxSortOrderStmt.get(calendar.id, dayOfWeek, blockKey).maxOrder + 1;
const taskId = insertTaskStmt.run(calendar.id, dayOfWeek, blockKey, text.trim(), nextOrder).lastInsertRowid;
touchCalendar(calendar.id);
const task = getTaskStmt.get(taskId);
res.status(201).json({
task: { id: task.id, text: task.text, done: !!task.done, sortOrder: task.sort_order, dayOfWeek, blockKey },
});
});
router.patch('/:id/tasks/:taskId', (req, res) => {
const calendar = loadOwnedCalendar(req, res, req.params.id);
if (!calendar) return;
const task = getTaskStmt.get(req.params.taskId);
if (!task || task.calendar_id !== calendar.id) {
return res.status(404).json({ error: 'Task not found' });
}
const { text, done, sortOrder } = req.body || {};
if (text !== undefined && !isNonEmptyString(text, LIMITS.TASK_TEXT)) {
return res.status(400).json({ error: 'Text cannot be empty' });
}
updateTaskStmt.run(
text !== undefined ? text.trim() : null,
done !== undefined ? (done ? 1 : 0) : null,
sortOrder !== undefined ? sortOrder : null,
task.id
);
touchCalendar(calendar.id);
const updated = getTaskStmt.get(task.id);
res.json({
task: { id: updated.id, text: updated.text, done: !!updated.done, sortOrder: updated.sort_order },
});
});
router.delete('/:id/tasks/:taskId', (req, res) => {
const calendar = loadOwnedCalendar(req, res, req.params.id);
if (!calendar) return;
const task = getTaskStmt.get(req.params.taskId);
if (!task || task.calendar_id !== calendar.id) {
return res.status(404).json({ error: 'Task not found' });
}
deleteTaskStmt.run(task.id);
touchCalendar(calendar.id);
res.status(204).end();
});
router.post('/:id/reset-checks', (req, res) => {
const calendar = loadOwnedCalendar(req, res, req.params.id);
if (!calendar) return;
resetChecksStmt.run(calendar.id);
touchCalendar(calendar.id);
res.json({ calendar: hydrateCalendar(calendar.id) });
});
module.exports = router;
+128
View File
@@ -0,0 +1,128 @@
const express = require('express');
const db = require('../db');
const requireAuth = require('../middleware/requireAuth');
const { loadOwnedChild, loadOwnedCalendar } = require('../middleware/requireHousehold');
const { randomToken } = require('../lib/tokens');
const { createBlankCalendar, createDuplicateCalendar } = require('../lib/calendarSeed');
const { isNonEmptyString, LIMITS } = require('../lib/validate');
const router = express.Router();
router.use(requireAuth);
const listChildrenStmt = db.prepare('SELECT * FROM children WHERE household_id = ? ORDER BY created_at');
const insertChildStmt = db.prepare(
'INSERT INTO children (household_id, name, kiosk_token) VALUES (?, ?, ?)'
);
const updateChildNameStmt = db.prepare('UPDATE children SET name = ? WHERE id = ?');
const updateChildActiveCalendarStmt = db.prepare('UPDATE children SET active_calendar_id = ? WHERE id = ?');
const deleteChildStmt = db.prepare('DELETE FROM children WHERE id = ?');
const regenKioskTokenStmt = db.prepare('UPDATE children SET kiosk_token = ? WHERE id = ?');
const listCalendarsForChildStmt = db.prepare(
'SELECT id, title, week_start_date, show_weekend, updated_at, created_at FROM calendars WHERE child_id = ? ORDER BY created_at DESC'
);
function publicChild(child) {
return {
id: child.id,
name: child.name,
kioskToken: child.kiosk_token,
kioskPath: `/k/${child.kiosk_token}`,
activeCalendarId: child.active_calendar_id,
};
}
router.get('/', (req, res) => {
const children = listChildrenStmt.all(req.parent.household_id).map(publicChild);
res.json({ children });
});
router.post('/', (req, res) => {
const { name } = req.body || {};
if (!isNonEmptyString(name, LIMITS.NAME)) {
return res.status(400).json({ error: 'Name is required' });
}
const kioskToken = randomToken();
const id = insertChildStmt.run(req.parent.household_id, name.trim(), kioskToken).lastInsertRowid;
const child = { id: Number(id), name: name.trim(), kiosk_token: kioskToken, active_calendar_id: null };
res.status(201).json({ child: publicChild(child) });
});
router.get('/:id', (req, res) => {
const child = loadOwnedChild(req, res, req.params.id);
if (!child) return;
res.json({ child: publicChild(child) });
});
router.patch('/:id', (req, res) => {
const child = loadOwnedChild(req, res, req.params.id);
if (!child) return;
const { name, activeCalendarId } = req.body || {};
if (name !== undefined) {
if (!isNonEmptyString(name, LIMITS.NAME)) {
return res.status(400).json({ error: 'Name cannot be empty' });
}
updateChildNameStmt.run(name.trim(), child.id);
}
if (activeCalendarId !== undefined) {
if (activeCalendarId !== null) {
const cal = loadOwnedCalendar(req, res, activeCalendarId);
if (!cal) return;
if (cal.child_id !== child.id) {
return res.status(400).json({ error: 'That calendar does not belong to this child' });
}
}
updateChildActiveCalendarStmt.run(activeCalendarId, child.id);
}
const updated = loadOwnedChild(req, res, child.id);
res.json({ child: publicChild(updated) });
});
router.delete('/:id', (req, res) => {
const child = loadOwnedChild(req, res, req.params.id);
if (!child) return;
deleteChildStmt.run(child.id);
res.status(204).end();
});
router.post('/:id/kiosk-token/regenerate', (req, res) => {
const child = loadOwnedChild(req, res, req.params.id);
if (!child) return;
const kioskToken = randomToken();
regenKioskTokenStmt.run(kioskToken, child.id);
res.json({ kioskToken, kioskPath: `/k/${kioskToken}` });
});
router.get('/:childId/calendars', (req, res) => {
const child = loadOwnedChild(req, res, req.params.childId);
if (!child) return;
const calendars = listCalendarsForChildStmt.all(child.id);
res.json({ calendars });
});
router.post('/:childId/calendars', (req, res) => {
const child = loadOwnedChild(req, res, req.params.childId);
if (!child) return;
const { title, weekStartDate, duplicateFromCalendarId } = req.body || {};
if (!isNonEmptyString(title, LIMITS.TITLE)) {
return res.status(400).json({ error: 'Title is required' });
}
let calendar;
if (duplicateFromCalendarId) {
const source = loadOwnedCalendar(req, res, duplicateFromCalendarId);
if (!source) return;
if (source.child_id !== child.id) {
return res.status(400).json({ error: 'That calendar does not belong to this child' });
}
calendar = createDuplicateCalendar(source.id, child.id, title.trim(), weekStartDate || null);
} else {
calendar = createBlankCalendar(child.id, title.trim(), weekStartDate || null);
}
res.status(201).json({ calendar });
});
module.exports = router;
+29
View File
@@ -0,0 +1,29 @@
const express = require('express');
const db = require('../db');
const requireAuth = require('../middleware/requireAuth');
const { isNonEmptyString, LIMITS } = require('../lib/validate');
const router = express.Router();
const getHouseholdStmt = db.prepare('SELECT * FROM households WHERE id = ?');
const listParentsStmt = db.prepare('SELECT id, email, name, created_at FROM parents WHERE household_id = ? ORDER BY created_at');
const renameHouseholdStmt = db.prepare('UPDATE households SET name = ? WHERE id = ?');
router.use(requireAuth);
router.get('/', (req, res) => {
const household = getHouseholdStmt.get(req.parent.household_id);
const parents = listParentsStmt.all(req.parent.household_id);
res.json({ household: { id: household.id, name: household.name }, parents });
});
router.patch('/', (req, res) => {
const { name } = req.body || {};
if (!isNonEmptyString(name, LIMITS.HOUSEHOLD_NAME)) {
return res.status(400).json({ error: 'Name is required' });
}
renameHouseholdStmt.run(name.trim(), req.parent.household_id);
res.json({ household: { id: req.parent.household_id, name: name.trim() } });
});
module.exports = router;
+103
View File
@@ -0,0 +1,103 @@
const express = require('express');
const bcrypt = require('bcryptjs');
const db = require('../db');
const requireAuth = require('../middleware/requireAuth');
const { randomToken } = require('../lib/tokens');
const { isNonEmptyString, LIMITS } = require('../lib/validate');
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
const INVITE_TTL_MS = 7 * 24 * 60 * 60 * 1000; // 7 days
const insertInviteStmt = db.prepare(
'INSERT INTO household_invites (household_id, token, created_by_parent_id, expires_at) VALUES (?, ?, ?, ?)'
);
const listInvitesStmt = db.prepare(
'SELECT id, token, expires_at, used_at, created_at FROM household_invites WHERE household_id = ? ORDER BY created_at DESC'
);
const getInviteForRevokeStmt = db.prepare('SELECT * FROM household_invites WHERE id = ?');
const deleteInviteStmt = db.prepare('DELETE FROM household_invites WHERE id = ?');
const getInviteByTokenStmt = db.prepare('SELECT * FROM household_invites WHERE token = ?');
const getHouseholdStmt = db.prepare('SELECT * FROM households WHERE id = ?');
const markInviteUsedStmt = db.prepare('UPDATE household_invites SET used_at = ?, used_by_parent_id = ? WHERE id = ?');
const getParentByEmailStmt = db.prepare('SELECT * FROM parents WHERE email = ?');
const insertParentStmt = db.prepare(
'INSERT INTO parents (household_id, email, password_hash, name) VALUES (?, ?, ?, ?)'
);
function publicParent(parent) {
return { id: parent.id, email: parent.email, name: parent.name, householdId: parent.household_id };
}
// Session-authenticated: manage invites for the caller's own household.
const manageRouter = express.Router();
manageRouter.use(requireAuth);
manageRouter.post('/', (req, res) => {
const token = randomToken();
const expiresAt = new Date(Date.now() + INVITE_TTL_MS).toISOString();
const id = insertInviteStmt.run(req.parent.household_id, token, req.parent.id, expiresAt).lastInsertRowid;
res.status(201).json({
invite: { id: Number(id), token, expiresAt, joinPath: `/join.html?token=${token}` },
});
});
manageRouter.get('/', (req, res) => {
const invites = listInvitesStmt.all(req.parent.household_id);
res.json({ invites });
});
manageRouter.delete('/:id', (req, res) => {
const invite = getInviteForRevokeStmt.get(req.params.id);
if (!invite || invite.household_id !== req.parent.household_id) {
return res.status(404).json({ error: 'Invite not found' });
}
deleteInviteStmt.run(req.params.id);
res.status(204).end();
});
// Public: view + accept an invite (no session yet — the whole point is to create one).
const publicRouter = express.Router();
publicRouter.get('/:token', (req, res) => {
const invite = getInviteByTokenStmt.get(req.params.token);
if (!invite || invite.used_at || invite.expires_at < new Date().toISOString()) {
return res.status(404).json({ error: 'This invite link is invalid or has expired' });
}
const household = getHouseholdStmt.get(invite.household_id);
res.json({ householdName: household.name });
});
publicRouter.post('/:token/accept', (req, res) => {
const invite = getInviteByTokenStmt.get(req.params.token);
if (!invite || invite.used_at || invite.expires_at < new Date().toISOString()) {
return res.status(404).json({ error: 'This invite link is invalid or has expired' });
}
const { email, password, name } = req.body || {};
if (!isNonEmptyString(email, LIMITS.EMAIL) || !EMAIL_RE.test(email)) {
return res.status(400).json({ error: 'A valid email is required' });
}
if (typeof password !== 'string' || password.length < 8 || password.length > LIMITS.PASSWORD) {
return res.status(400).json({ error: `Password must be 8-${LIMITS.PASSWORD} characters` });
}
if (!isNonEmptyString(name, LIMITS.NAME)) {
return res.status(400).json({ error: 'Name is required' });
}
if (getParentByEmailStmt.get(email.toLowerCase())) {
return res.status(409).json({ error: 'An account with that email already exists' });
}
const passwordHash = bcrypt.hashSync(password, 12);
const parentId = insertParentStmt.run(
invite.household_id, email.toLowerCase(), passwordHash, name.trim()
).lastInsertRowid;
markInviteUsedStmt.run(new Date().toISOString(), Number(parentId), invite.id);
req.session.parentId = Number(parentId);
res.status(201).json({
parent: publicParent({ id: parentId, email, name: name.trim(), household_id: invite.household_id }),
});
});
module.exports = { manageRouter, publicRouter };
+48
View File
@@ -0,0 +1,48 @@
const express = require('express');
const db = require('../db');
const resolveKiosk = require('../middleware/resolveKiosk');
const { hydrateCalendar } = require('../lib/calendarHydrate');
const touchCalendar = require('../lib/touchCalendar');
const router = express.Router({ mergeParams: true });
router.use(resolveKiosk);
const getTaskStmt = db.prepare('SELECT * FROM calendar_tasks WHERE id = ?');
const setDoneStmt = db.prepare(
"UPDATE calendar_tasks SET done = ?, updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE id = ?"
);
// Deliberately only two routes exist on this router: read the active calendar,
// and toggle a task's `done` flag. No create/delete/text-edit/label routes are
// wired up here at all — the access boundary is what routes exist, not UI hiding.
router.get('/calendar', (req, res) => {
if (!req.child.active_calendar_id) {
return res.json({ child: { id: req.child.id, name: req.child.name }, calendar: null });
}
const calendar = hydrateCalendar(req.child.active_calendar_id);
res.json({ child: { id: req.child.id, name: req.child.name }, calendar });
});
router.patch('/tasks/:taskId', (req, res) => {
if (!req.child.active_calendar_id) {
return res.status(404).json({ error: 'No active calendar for this child' });
}
const task = getTaskStmt.get(req.params.taskId);
if (!task || task.calendar_id !== req.child.active_calendar_id) {
return res.status(404).json({ error: 'Task not found' });
}
const { done } = req.body || {};
if (typeof done !== 'boolean') {
return res.status(400).json({ error: 'done must be a boolean' });
}
setDoneStmt.run(done ? 1 : 0, task.id);
touchCalendar(task.calendar_id);
res.json({ task: { id: task.id, done } });
});
module.exports = router;