ShiftScan
Status: In Development · Source · Live demo
Problem
Shift workers get their week as a photo of a printed roster. Getting it into a phone calendar means typing each day by hand, and any plan for the hours in between — sport, study, seeing people — is made in the head and forgotten by Wednesday.
What it does
- Pick the week’s start date, upload the roster photo, crop it.
- OCR reads the days and hours; the result is editable text, and can be typed in directly instead of scanned.
- The app generates an
.icsfile with one event per shift, ready for any calendar.
An optional Advanced Mode adds planning. The user defines activities — a name, a weekly amount in hours or days, and a preferred time of day — and the app places them into the free slots between shifts and sleep, then writes those into the same calendar file.
Design decisions
- Stateless by construction. No accounts, no database. Everything the planner needs — the shifts, the activities, the timezone, the calendar labels — travels in each request; the browser keeps the activity list in
localStorage. An uploaded image lives in a temporary file for the length of one request and is deleted afterwards. - The model’s output is treated as untrusted. Gemini is asked for a plan, but every row it returns is validated: the activity id must exist in the request, the day index must be 0–6, numbers must be numbers. Invalid rows are dropped and logged; nothing raises. The model answers with ids and indices, never with names, so a user-defined activity called anything at all cannot confuse the match.
- Nothing the user types reaches the prompt. The preferred time is a fixed choice — morning, afternoon, evening, any — not free text. There is nothing to sanitise because there is nothing to inject.
- Works without a model. With no API key configured the same request goes to a rule-based planner that honours the preferred windows first and falls back to any free slot. The app is fully functional either way; the LLM is an upgrade, not a dependency.
- Generalised on purpose. The first version was one person’s planner: five hard-coded activities,
Europe/Istanbul, Turkish day names in the backend. A dedicated round removed all of it — activities are user-defined with an editable default template, the timezone comes from the browser, translations live in one file, and the backend carries no language. - Standards over convenience. ICS output follows RFC 5545 (escaping, line folding); the request contract is validated with explicit bounds (1–20 activities,
0 < amount ≤ 168, resolvable timezone) and unknown fields are rejected. - What did not fit is said out loud. Both planners return the goals they could not place, measured against what the user asked for rather than what the model proposed. The response carries it as
unplacedand the page shows it as a warning next to the download, so a 6-hour goal that became 4 hours is never a silent shortfall. Rule-based placement is bounded to 2-hour blocks, skips slivers under 30 minutes, and spreads a goal over fresh days before reusing one. - OCR output is anchored to day names. The first parser assigned every shift it found to Monday–Sunday in order, so one day the OCR missed shifted the rest of the week. Shifts are now attached to the day name that precedes them; unanchored shifts fill the remaining days and missing days are shown in place for the user to complete.
- The model name is configuration. Halfway through a deployment
gemini-2.0-flashstarted returning 404 — retired by the provider — and every plan had quietly been rule-based for some time. The model is now read fromGEMINI_MODEL, the response says which planner produced the plan, and the SDK is the currentgoogle-genaiclient.
Privacy posture
The README says it plainly: scanned images are sent to a third-party OCR service (a public HuggingFace Space not operated by this project), and a roster photo can reveal an employer, a name and working hours. Users who cannot accept that are pointed to manual entry or to self-hosting the OCR model. When the planner is enabled, only the free-time summary and activity goals go to Gemini — never the image.
Verified state
- 95 backend tests (pytest) and 35 browser tests (
node --test), run on every push and pull request by GitHub Actions together with a Docker build. - Live at vardiya.egebostanci.me, self-hosted on Coolify and deployed automatically from
mainthrough a GitHub webhook; the service worker’s cache is versioned from the deployed commit, so a release never leaves a stale bundle behind. Watched by the Quiet VPS Watchdog.
Known limitations
- OCR quality depends on the photo and on a third-party model; the editable text step exists because it will sometimes be wrong.
- The language picker is unaudited and the page’s
langattribute is fixed; a separate round. - Sessions for
days-unit activities are a fixed length; there is no notion of an activity that must not be split. - Single-user, single-week. No recurring rosters, no history.