Browse businesses Browse shifters How it works Pricing For businesses For shifters Help About
Sign in Sign up free

What changed · RSS · Blog

Release notes.

A factual log of what we shipped and when. Written for the people who want the structured changelog. For the user-benefit narrative, read the blog.

  1. Reviews v2, two-factor authentication, plus stuck-charge resume (3DS / SCA)

    • feature
    • security
    • payments
    • reviews

    Reviews can be per-shift now, the reviewee can post a response under any review, and there's a dispute path moderated by admins. Two-factor authentication is live in account settings. Plus the off-session 3DS / SCA stuck-charge surface from earlier in the day.

    Wage transparency proper: market-rate hints in the flows that decide pay

    Tier-3 strategic moat #1. The morning's rate-badge embed seeded this (per-business + opt-in surface). This batch brings the cross-business going-rate signal inline, in the two places where a rate is actually being decided: the business posting a shift, and the shifter looking at an open broadcast.

    • New aggregation endpoint GET /v1/going_rate. Takes a geographic filter (city_id, state_id, country_id) and an optional shiftrole_id. Returns min, p25, median, p75, max, mean, sample size, currency, and the time window (default 90 days, env-tuneable). Privacy guard returns sufficient_data=false when fewer than 10 rows in the slice (env-tuneable), so a thin (city, role) combination can't reverse-engineer a single business's number.
    • Business-side hint in the Send Shift Request modal. Pick a role, see "Market $X-Y/hr (median $Z, n=N, last 90d)" under the rate input. Type a rate below p25 and an amber warning appears: "Below market, broadcasts may not fill." Type above p75 and a green hint appears: "Above market, likely to fill fast." Operator data, in the moment they're deciding what to offer.
    • Shifter-side badge on /shifter/open-shifts. Every broadcast row gets a "Market $X-Y" line under the offered rate, with a icon when the offer is below market p25 and a when it's above market p75. So a shifter can scan the page and see at a glance which offers are competitive. Cached client-side per (city, role) so the page fires unique combos in parallel, not one call per row.
    • Why this is a moat, not a feature. No competitor has this data because no competitor lets workers negotiate rates at scale. We do, every accepted counter is a market datapoint, and the longer ShiftSee runs the deeper the dataset gets. Surfacing it inline (vs hiding it in an analytics dashboard) is what turns it from "nice number" to "the wage-transparency layer for shift work."

    Embed economy round 3: rate badge, partner landings, Shopify

    Tier-3 strategic moat. Three more shipments on top of the morning's two embed batches. The catalog is now 6 widgets, with dedicated landings for the two biggest no-code platforms.

    • Typical-pay badge embed. Sixth primitive. Compact pill: "Typical pay at this business: $X-Y/hr." Computed from the last 90 days of broadcast shifts (excludes rows with no rate, falls back to "rate data not available yet" when fewer than 3 rows). Optional role filter via data-role. First surface of the wage-transparency moat (per-business + opt-in, so it sidesteps the harder cross-business-aggregation question for v1). Drop-in: <script src="/embed/v1/rate.js"></script> + <div data-shiftsee-rate data-slug="acme-bar"></div>.
    • /for/wordpress and /for/shopify landing pages. Marketing surfaces targeting partner-platform searches. Each walks the install in 4 steps, covers all the relevant widgets, and lists use cases. Registered before the /for/{slug} catch-all so they take precedence over the data-driven category-page system. Cross-linked from the gallery, and the gallery is cross-linked from each landing.
    • Shopify Liquid snippet. Single shiftsee.liquid snippet that renders any of the four no-auth widgets via one {% raw %}{% render 'shiftsee', widget: 'profile', slug: '...' %}{% endraw %} include. No app install, no app review queue, works on every Shopify plan including Basic. Downloadable via /embeds/shopify.liquid straight from the gallery or the dedicated /for/shopify landing. Origin overridable via the snippet's origin parameter for staging or self-hosted ShiftSee deploys.
    • Companion polish. Embeds gallery now lists 6 widgets (rate badge tile slots in after Reviews) and grows a dedicated Shopify section above the WordPress one. WordPress plugin gains [shiftsee_rate] shortcode so WP partners can use the new primitive on day one too. Build pipeline grows from 5 to 6 IIFE bundles in bin/build-embeds.mjs.

    Embed economy expansion: review wall, public gallery, WordPress plugin

    Tier-3 strategic moat. Three more shipments on top of this morning's profile + shifts + configurator landing. The catalog grows from 4 widgets to 5, with a public-facing gallery and a literal WordPress plugin so a partner can ship in 60 seconds.

    • Review wall embed. Drop-in social proof for any business or shifter. Server-side fetch of up to 6 most-recent reviews (filters out dispute_status='upheld' to match the public list endpoint), average rating headline, total count, reviewer name and photo, plus the reviewee's response if posted. Same iframe + auto-resize + light/dark themes as the others. Available at <script src="/embed/v1/reviews.js"></script> plus <div data-shiftsee-reviews data-kind="b" data-slug="acme-bar"></div>.
    • Public embed gallery at /embeds. Marketing surface that walks through all 5 widgets with live preview iframes alongside copy-able snippets. Footer link under "Developers." Default demo slugs configurable via GALLERY_DEMO_BUSINESS_SLUG / GALLERY_DEMO_SHIFTER_SLUG env so staging and prod each show their own demo accounts. The gallery is the front door; the developer portal's configurator is what you reach for after you've decided.
    • WordPress plugin scaffold. Live at resources/wp-plugin/shiftsee/ in the repo. Registers three shortcodes: [shiftsee_profile], [shiftsee_shifts], [shiftsee_reviews]. Each emits a deduped script tag plus the matching mount <div>. Multiple shortcodes of the same type on a page reuse the loaded script (one network request per widget type). Origin overridable via shiftsee_embed_origin filter for staging or self-hosted deploys. Downloadable ZIP via /embeds/wordpress.zip built on demand from the source files using ZipArchive; the gallery has a "Download ZIP" button right next to the install steps.

    Embed economy: profile + shifts widgets and a live configurator

    Tier-3 strategic moat. Second AI-adjacent investment of the day. Building on the Partner API + Connect button + Schedule widget that shipped earlier, we now have two simpler embeds that need no auth and a live snippet generator that turns the partner's "ship in an afternoon" loop into "click dropdown, see preview."

    • Profile embed. Drop a brand-styled card for any business or shifter onto any website. One script tag plus a <div data-shiftsee-profile data-kind="b" data-slug="acme-bar"></div> and you get the card with logo, name, short bio, "Connect with us on ShiftSee" CTA linking back to the public profile. Iframe boundary keeps the partner's CSS from clobbering our typography. Light + dark themes via data-theme. Auto-resizes to content via postMessage so the iframe never gets an internal scrollbar.
    • Shifts embed. Drop a list of a single business's open broadcasts onto any website. Server-side fetches up to 8 future-dated is_broadcast=1, request_status=1 rows joined to shiftroles for the role label. Per-row date / time / hourly rate, "View business" CTA at the footer. Same iframe + theme + auto-resize pattern as the profile embed.
    • Live configurator. New /developers/configurator tab. Pick a widget from the dropdown (Profile, Shifts, Connect button, Schedule widget), fill in the minimum params, see the rendered iframe alongside a copy-able <script> snippet. Profile and Shifts preview live; Connect and Schedule show snippet-only because their preview requires the partner's actual OAuth client.
    • ShareProfileModal upgrade. End users (not just partners) now see the embed snippet inside the share modal as a collapsed "Embed on your site" disclosure. One copy gets the partner-grade <script> tag plus the matching <div>. Business-mode shows both profile + shifts snippets; shifter-mode shows the profile snippet only.
    • Build pipeline. bin/build-embeds.mjs entries list extended to four (connect, schedule, profile, shifts). Each builds as its own self-contained IIFE to public/embed/v1/<name>.js. Same versioning model as before (path-segment /v1/), so v2 ships parallel without breaking pinned partners.

    AI scheduling copilot v1 (business)

    Tier-3 strategic moat. First AI feature in the product. A per-business chat assistant that knows your operational state and can suggest one-click actions.

    • New SPA page /business/copilot. Dashboard link added next to Labor costs.
    • Backend assembles a fresh operational snapshot on every turn (last-30d issued / filled / fill rate, pending broadcasts, upcoming next-7d filled shifts, connected crew preview, business config like weekly labor budget and auto-fill setting) and sends it to Claude as system context. The model is told never to invent shifters, dates, or rates that aren't in the snapshot.
    • Suggested actions appear as one-click chips on the assistant bubble. Three types in v1:
      • invite_shifter (fires copilot:invite-shifter event for the existing invite flow to subscribe to next iteration)
      • open_shift_form (links to dashboard with ?openShift=1&date=&in=&out= prefill)
      • open_url (relative paths use spaNavigate; full URLs open in a new tab)
      • capped at 3 per turn, malformed entries dropped silently.
    • New ai_chat_messages table persists the full thread per business with token usage (prompt + completion) on each assistant row for cost attribution. Indexed on (business_id, id) so thread render is one keyed range scan.
    • Two new endpoints under /v1/ai/chat/business/{business_id}: GET returns recent thread (50 most recent in chronological order); POST accepts {message} and returns {user_message, assistant_message}.
    • Auth: business owner OR any active business_colab teammate (status=1) OR admin. So an admin teammate added via Settings → Team has copilot access too.
    • Default model claude-haiku-4-5-20251001 (override via AI_COPILOT_MODEL env). Anthropic call goes through a shared keep-alive httpx client (10 max connections, 30s timeout).
    • Graceful degradation: when ANTHROPIC_API_KEY is missing, endpoints still work and the assistant replies "Copilot is not configured on this environment yet." A faded notice appears at the top of the page until the secret is wired. Lets the UI ship before the key is provisioned.
    • Starter prompts on the empty state seed the operator on what to ask: fill rate, recommended shifters for the weekend, last week's cost spike, plan-next-week.
    • Optimistic UX on send: the user bubble appears immediately and clears the input; the assistant bubble follows when the model returns.

    Cleanup pile: invoice CSV, teammate notifications, template auto-generate

    Three quality-of-life items that close out the Tier-2 roadmap before the AI scheduling work begins.

    • Invoice CSV export. A "Download CSV" button on /business/payments next to "Payment methods" emits a flat CSV (invoice_id, shift_request_id, shift_date, shift_in, shift_out, shifter_id, shifter_name, amount, currency, conversion_amount, conversion_currency, status, paid_at) for the current filter (All / Pending / Paid / Failed). One-shot fetch up to 5000 matching rows so a year of history downloads in one click; bookkeeping use case wants the whole year in one file. Filename is shiftsee-payments-YYYY-MM-DD.csv. No new endpoint, just a wider read against the existing /v1/invoices/list.
    • Per-team teammate notifications. Operational push notifications targeting a business now fan out to every active business_colab.user_id in addition to the owner. Connection requests, shift requests, change requests, accept / decline / broadcast claim, clock in, and clock out are all teammate-aware. Card creation and payout notifications stay owner-only by design (financial events live in fewer hands). Owner is included even if the colab table doesn't list them, and recipients are deduped so legacy rows don't double-push. New helper send_notify_to_business(db, owner_user_id, business_id, notify_data) in push_notify_crud.py; if the colab read fails the original owner-only path still fires.
    • Template auto-generate cron. New templates:auto-generate artisan walks every shift_templates row with is_active=1 and inserts pending shift_requests for the rolling next 4 weeks. Idempotent (matches the manual "Generate next 4 weeks" button's dedup: same business + date + start time, plus shifter when the template targets a default shifter), so re-running daily only inserts the newly-needed tail day. Scheduled in routes/console.php for 03:15 Pacific. --dry-run and --weeks=N flags for ops.

    Auto-fill cancelled shifts (business)

    • Tier-2 roadmap. New business.auto_fill_on_cancel column (bool default false). When on, a shifter-cancelled accepted shift is automatically re-broadcast to the connected crew the moment the cancellation lands, provided there's enough lead time before the start (default 2h, configurable via SHIFT_AUTO_FILL_MIN_LEAD_HOURS).
    • The cancelled row stays cancelled (audit trail). A NEW shift_request row is created mirroring the original's role / time / rate / note / is_flexible, marked is_broadcast=1 + request_status=1 + parent_id linking back to the cancelled row so the chain is traceable.
    • Push notification fires the same way a manual broadcast does, so the connected crew sees the new opening immediately.
    • Operator toggle on Business Settings: "Auto-fill cancelled shifts." Off by default.
    • Companion FastAPI lift: BusinessSettings + BusinessFullOut schemas grow optional weekly_labor_budget + auto_fill_on_cancel, so the existing update_settings endpoint persists both alongside the others (exclude_unset=True already in place from the clock-in v2 work).

    Labor cost dashboard (business)

    • Tier-2 roadmap. Operator analog of the shifter earnings page. New SPA route /business/labor shows weekly running labor cost with vs-last-week comparison, optional weekly budget + over/under signal, top shifters and roles for the active week, and an 8-week trend sparkline.
    • New business.weekly_labor_budget column (decimal, nullable). NULL = no budget; the dashboard skips over/under visuals.
    • Two new endpoints under /v1/business/{id}/: GET /labor_costs?week=YYYY-MM-DD (defaults to current Monday) returns {this_week_total, last_week_total, pct_change_vs_last_week, weekly_budget, over_under_budget, by_shifter[], by_role[], last_8_weeks[]}. PUT /weekly_labor_budget accepts {weekly_labor_budget: number|null}. Auth: business owner or admin only.
    • Same earnings model as the shifter side: scheduled duration × hourly_rate over completed shifts. Numbers are gross; payment fees aren't deducted. Future iteration may pull from payment_invoice for the precise figure.
    • Week navigation: prev / This week / next on the page header.
    • Budget edit modal hangs off the budget tile. Set or clear a weekly target; the over/under tile turns amber when over.
    • "Labor costs →" link added to the business dashboard's Upcoming Shifts header alongside View all / Calendar / Templates / Bulk paste.

    Auto-counter rules (shifter)

    • Tier-2 roadmap. Shifter sets rules like "always counter +$5 on weekend shifts" or "floor my rate at $25/hr." When a pending shift_request matches, a green Counter at $X (rule: name) button appears on the row in /shifter/requests next to Accept / Decline. One click opens the existing ProposeChangesModal pre-filled with the suggested rate.
    • v1 ships suggest mode. The shifter still confirms before the counter goes out. Auto-apply mode (rule fires the counter without confirmation) is reserved on the schema (is_auto_apply column) for a follow-up.
    • Condition types: weekend (Sat/Sun), rate_below (offer below threshold), role (specific shiftrole id), always. Action: counter rate is the larger of (offered + delta) and floor; never counters DOWN.
    • Five new endpoints under /v1/shifter/{id}/counter_rules/*: list, create, update (PATCH-style), delete. Auth: own shifter or admin only. New error code RULE-NOT-FND-ERR.
    • New "Auto-counter rules" panel on the shifter Settings tab. Self-contained: list, add, edit, delete, pause/activate inline.
    • Pure-function evaluateCounterRules(rules, shift) exported from the panel for any other surface that wants to consume the same logic.

    Mileage tracking (shifter financial OS, IRS-compliant log)

    • Tier-2 roadmap. New shifter_mileage table: free-form log (log_date, miles, rate_per_mile_cents, purpose) deliberately NOT coupled to specific shift_requests so the shifter can record any work-related drive (uniform pickup, multi-gig trip, etc.).
    • rate_per_mile_cents captured at write time so the historical figure persists when the IRS standard rate changes year over year. Default rate read from SHIFT_MILEAGE_RATE_CENTS env var, fallback 67¢ (US IRS 2024 standard).
    • Four new endpoints under /v1/shifter/{id}/mileage/*: list (with year rollup totals), create, update, delete. Auth: own shifter or admin only (financial data is private).
    • New "Mileage log" section on /shifter/earnings between the trend and by-business breakdown. Shows year-total miles + deductible value at the default rate, full table of entries with Edit / Delete actions, "Log miles" button opens an inline modal.
    • CSV export now includes a Mileage log section + total. The exported CSV is what your accountant or CRA / IRS-prep software wants.

    Earnings forecast + tax-time export (shifter financial OS)

    • Tier-2 roadmap. New monthly_earnings_goal column on shifters. New endpoint GET /v1/shifter/{id}/earnings?year=&month= returns this-month gross + projected end-of-month (linear extrapolation when ≥3 days have elapsed) + typical hourly rate (mean over last 90 days) + typical shift hours + by-business breakdown for the year + 12-month trend + shifts-to-goal calc when the goal is set. Goal-set endpoint at PUT /v1/shifter/{id}/earnings_goal. Both auth-gated to the owning shifter; earnings data is sensitive enough that we don't expose it on public surfaces.
    • Dashboard widget (ShifterEarningsWidget): self-contained card on the shifter dashboard. Shows current month gross + projected month-end + goal progress bar + "X more shifts at your typical rate to hit it." Hidden when no shift data exists yet so brand-new shifters don't see an empty surface.
    • /shifter/earnings page: full breakdown. Four headline tiles (this-month / projected / typical rate / goal), inline-SVG 12-month sparkline trend, by-business table for the selected year (year filter dropdown), CSV export with year-specific filename for tax time, goal edit dialog.
    • Earnings model (v1): scheduled duration (shift_in → shift_out) × hourly_rate summed across completed shifts (request_status=2 + shift_end_at + !is_noshow). Numbers are gross before platform fees; CSV export labels this clearly so the shifter knows their bank statement is the source of truth for net.
    • Tax-time export is the same surface (the page IS the tax-summary view). Future passes will add net-of-fees + per-business 1099 helper, but for v1 the CSV is operationally complete.

    Skills / cert vault for shifters

    • Tier-2 roadmap. New shifter_credentials table: shifters upload food handler cards, bartender licenses, CPR, driver's licenses, anything they want connected businesses to see. Each row has a label, optional issuer, optional issued / expires dates, optional notes, optional uploaded document (PDF or image up to 8 MB), and an is_visible toggle.
    • New endpoints under /v1/shifter/{id}/credentials/*: list (visible-only for non-owner reads, full list for owner / admin), create, update (PATCH-style), delete, document upload (multipart). Auth: only the owning shifter can write.
    • Shifter Settings tab gets a new "Skills & certifications" panel: list, add, edit, delete, upload, toggle visibility, all inline. Built as a self-contained component (ShifterCredentialsPanel).
    • Public profile (/s/{slug}) lazy-loads the visible credentials and renders them under the work-experience section. Expired credentials get a red "expired" pill so businesses see at a glance which certs need a refresh. Section stays hidden when the shifter has no visible credentials.
    • Uploads stored under /shifter_credentials/{shifter_id}/. Same upload pattern as the shifter photo + clock-in photo (see business_routes / shifter_routes for the pattern).
    • Schema-owned-by-Laravel; SQLAlchemy mirror in app/models/shifter_credential.py.

    Smart broadcasts

    • POST /v1/shift_request/list_of_broadcast_requests accepts a new sort_by_relevance flag. When set (and the caller provides shifter_id), the server scores every matching row per-(broadcast, shifter) and returns the result ranked best-fit first instead of newest-first.
    • Score formula (0-100, capped):
      • +40 past completed shifts at THIS business in last 90 days (1 = 10, 4+ = 40)
      • +25 reliability score (proportional to platform-wide score)
      • +20 the broadcast's shiftrole has been done by this shifter at THIS business
      • +15 most recent completed shift at THIS business within the last 7 days
    • ShiftRequestOut gains an Optional relevance_score (0-100) populated when relevance sort is on; absent otherwise. Mobile clients on the v1 contract ignore unknown fields.
    • /shifter/open-shifts page sets the flag automatically; high-relevance rows (score ≥ 70) get a green BEST MATCH badge with a hover-tip explaining the inputs.
    • SendShiftRequestModal (broadcast mode) callout updated to explain "Smart sorting on: shifters see your broadcasts ranked by best fit." So operators know the algorithm is behind the marketing claim.
    • Staggered fan-out (top tier first, expand if unclaimed) is deferred; v1 ships the ranking only. The notification fan-out itself stays "everyone connected gets pinged at once," but each shifter sees the most relevant broadcasts at the top of their feed.

    Calendar conflict detection

    • New endpoint GET /v1/shift_request/conflicts?shifter_id=&date=&shift_in=&shift_out=&exclude_shift_request_id= returns the accepted shifts for a shifter on a single day that overlap a proposed time window. Same overlap rule both server- and client-side: [a_in, a_out) and [b_in, b_out) overlap iff NOT (a_out <= b_in OR a_in >= b_out).
    • Business-side warning: SendShiftRequestModal (targeted mode) now debounce-fetches conflicts when shifter + date + shift_in + shift_out are all set. An amber "Possible double-booking on Saturday, May 11" banner lists the conflicting shifts (other business name + role + time) above the form. Doesn't block submit; the operator can still proceed if they've worked it out with the shifter.
    • Shifter-side overlap badge: both CalendarView (month) and CalendarTimeAxisView (day / week) compute same-day overlaps client-side from the loaded shifts and render an amber OVERLAP badge + inset glow on every conflicting block. Hover-title says "OVERLAPS another shift on this day." Cross-midnight wrappers (rare) are conservatively treated as overlapping the rest of the day.
    • Cross-business consolidated calendar view + ICS export are deferred to a fuller pass; both are bigger pieces of the original Tier-2 cell.

    Send-shift modal: From-template + Save-as-template

    • The SendShiftRequestModal (the dashboard's primary "post a shift" surface, used by both targeted and broadcast modes) gets two template-aware controls.
    • From template… picker at the top of the form pre-fills shift_in / shift_out / role / hourly_rate / is_flexible / note from any active template. Date stays whatever the operator picked. Renders only when the business has at least one active template, so first-time users don't see clutter.
    • Save as recurring template inline button below the form. Tap → tiny inline label prompt → save. Day-of-week comes from the form's shift_date. Saving doesn't replace or block Submit, so the operator can still send the one-off shift in the same flow.
    • Both buttons hit the same /v1/shift_templates/* endpoints the standalone Templates page uses; nothing new on the backend.

    Bulk shift posting

    • New SPA route /business/bulk (Tier-2 roadmap). Paste a CSV (or TSV: Excel / Google Sheets paste works directly), preview the parsed rows with per-row validation, send the valid ones.
    • Columns: date, shift_in, shift_out, role, hourly_rate, shifter, note, is_broadcast. Header row optional. Leave shifter blank for a broadcast.
    • Pre-flight validation matches role names against the business's existing shiftroles and shifter names against the connected crew (case-insensitive). Bad values get a per-row error message and the row stays editable.
    • Submit hits the existing /v1/shift_request/send_request per row at concurrency 5 (caps the burst). Per-row status updates as each request resolves; failed rows show the server's error message and stay queued for retry.
    • "Bulk paste →" link added to the dashboard's Upcoming Shifts header next to View all → / Calendar → / Templates →.
    • No new backend; pure consumer of the existing send-request endpoint.

    Messaging "coming soon" stub fix

    • ShifterConnectionCard.tsx had a Message button permanently disabled with "Messaging is coming soon," but messaging shipped on 2026-05-06. The button is now wired to /messages#new=shifter:<user_id>, the same deep-link convention the Crew tab on /b/{slug} uses. Disabled only when the shifter row has no user_id.

    Recurring shift templates

    • New SPA route /business/templates (Tier-2 roadmap). Operators describe a recurring slot once ("Saturday brunch crew · Sat · 9-1 · $25/hr · broadcast") and click Generate 4 weeks to materialize a batch of pending shift_requests. Re-running Generate skips slots that already exist (same business + shifter + date + start time), so it's safe to run weekly.
    • Schema: new shift_templates table with business_id, label, days_of_week (CSV), shift_in, shift_out, shiftrole_id, hourly_rate, currency_id, is_flexible, is_broadcast, default_shifter_id, note, is_active. Recurrence model is intentionally simple in v1 (weekly + days-of-week); a frequency column is reserved for a future biweekly / monthly expansion.
    • Endpoints (FastAPI, net-new surface): GET /v1/shift_templates/by_business/{id}, POST /v1/shift_templates/create, PUT /v1/shift_templates/{id}, DELETE /v1/shift_templates/{id}, POST /v1/shift_templates/{id}/generate?weeks=4 (clamped 1-12).
    • Pause / activate per template without deleting (keeps the configuration around when an operator wants to stop generation temporarily).
    • "Templates →" link added to the business dashboard's Upcoming Shifts header next to the existing "View all →" / "Calendar →" entries.
    • Auto-generate cron (so a daily job tops up a rolling window without operator clicks) is on the next-pass list.

    Public-profile share + QR

    • New resources/views/partials/_share_qr_modal.blade.php partial. Floating share button on every public profile (/s/{slug} and /b/{slug}) opens a single modal with the share-options grid + the QR code.
    • Native Web Share API (navigator.share) when the browser supports it (iOS Safari, Android Chrome, Edge); otherwise a per-platform button row: Copy link, WhatsApp, X, Facebook, LinkedIn, Email. Copy uses navigator.clipboard.writeText with a textarea fallback for older Safari, and shows a tiny "Link copied" toast.
    • QR is rendered via the existing simplesoftwareio/simple-qrcode library that the controllers already produce; the partial drops the legacy WhatsApp-only QR popup that lived inline on those pages.
    • Download QR link points at the saved SVG under /qrcodes/. Useful for printing the code (counter posters, check-in signage).
    • Embed widget for third-party sites (the third leg of the original "share/QR/embed" Tier-1 cell) is deferred. Its scope (cross-origin caching, iframe sandboxing, and overlap with the Partner API v1 plan) is its own focused commit.

    Connected crew analytics

    • New GET /v1/business/{business_id}/crew/analytics endpoint. Single GROUP BY query over request_authorizations joined with shift_requests produces the whole crew's per-shifter breakdown in one round trip (no N+1).
    • Per-shifter: 90-day completed / late / no-show / cancelled counts, last shift date, and a reliability score + tier computed with the same formula as the public reliability endpoint so the numbers line up exactly with what the shifter sees on their own profile.
    • Aggregates: connected count, active vs paused (driven off the accept_shift_req flag), 90-day total completed shifts, repeat rate (% of crew with more than one shift in the window), and the avg reliability score across rated crew.
    • Top performers: ranked by completed count, with late + no-show count as the tiebreaker. Capped at top_n (default 5, max 20).
    • New analytics panel renders at the top of /business/connected: stat-tile row + Top Performers list. Uses the existing flat-card visual style. Hidden when there are no connected shifters yet, so empty-state surfaces don't get cluttered.
    • The roadmap "Connected Crew" cell ("connection graph mechanically works; no analytics surface") is closed. The directory is no longer a flat list.

    Tablet kiosk page

    • New SPA route /business/kiosk. Full-screen, chrome-hidden layout designed for a tablet mounted at a worksite. Polls the active business's today-shifts every 30s.
    • Each scheduled shifter gets a card. Tap the card → modal: take photo, confirm. Photo is mandatory in kiosk mode (the photo is the proof of identity since shifters don't sign in individually).
    • Idle / clocked-in / done states are color-coded (white → green → blue) so the operator can see at a glance who's working right now.
    • Geolocation captured automatically (the tablet's location IS the worksite, which makes the geofence audit clean).
    • Lock with PIN: tap "Lock kiosk", set a 4-digit PIN, walk away. The PIN is required to leave the page (back button, manual nav, refresh prompt). Stored as SHA-256 in localStorage; never sent over the wire. Unlock at any time with the PIN.
    • New "Launch kiosk in new tab" button on the business Settings page so operators have a clear path to the feature.
    • No new backend or schema. The kiosk uses the operator's existing session and the existing shift_start / shift_end endpoints; passes the shifter's id as the actor (shift_start_by_id).
    • Completes the third leg of the original "built-in clock-in" claim alongside geofencing and the photo capture. Marketing-copy credibility gap closed for this item.

    Operator clock-in policy + accept-shift-req toggle + audit trail rendering

    • Business Settings tab gets two new prefs rows: Clock-in geofence (Off / 100m / 250m / 500m / 1km / 2km dropdown) and Require clock-in / clock-out photo (toggle). Both persist immediately via the existing /v1/business/update_settings endpoint, so an operator can flip them on without leaving the page. Replaces the curl-only configuration path that landed earlier today.
    • Shifter Settings tab gets an Accepting shift requests toggle. Backed by the long-standing accept_shift_req flag (shifters.accept_shift_req); a "Paused" badge already shows on the public profile when off, but until now there was no UI to actually toggle the flag. Roadmap cleanup item closed.
    • The shift detail modal (the one opened from the dashboard / shifts list / calendar / notification deep links) renders a Clock-in audit panel when any clock-in / clock-out audit data is present: distance to worksite, geofence-override badge in amber when the shifter overrode, lat/lon (5 decimals), and clickable photo thumbnails. Renders nothing when neither clock-in nor clock-out has any audit data, so nothing changes for legacy shifts.

    Clock-in v2 (geofencing + optional photo)

    • Compare-page claim of "built-in clock-in" was a bare timestamp. Now there's actual policy.
    • Per-business geofence radius: businesses opt in by setting geofence_radius_m. NULL = no enforcement (legacy default). Shifter's reported lat/lon at clock-in is compared via Haversine to the business's stored lat/lon. Two modes: soft (default; distance is recorded, no block) and hard (SHIFT_CLOCK_GEOFENCE_HARD_FAIL=true; out-of-radius is rejected unless the shifter taps "I'm here" to override).
    • Per-business require_clock_photo toggle. When on, the shifter must capture a photo at clock-in (and clock-out). Front-end opens the camera; backend returns SHFT-PHOTO-REQ-ERR if missing.
    • Audit trail on every clock-in / clock-out: lat, lon, distance to worksite, geofence-override flag, photo URL. Visible in the shift detail for the business.
    • New endpoint POST /v1/shift_request/{id}/clock_photo?side=in|out (multipart). Returns {url} the front-end passes back to shift_start / shift_end.
    • New getClockGeo / uploadClockPhoto / pickClockPhoto helpers in resources/js/utils/clockIn.ts.
    • ShifterDashboardComponent's clock-in / clock-out flows automatically request geolocation, prompt for a photo when the server says one is required, and show a confirm dialog when the geofence rejects.
    • Both new fields are additive on BusinessSettings and ShiftStart / ShiftEnd; legacy mobile clients on the v1 contract continue to work unchanged (soft-mode, no enforcement).
    • Operator UI for the geofence-radius slider + require-photo toggle reaches via the existing PUT /v1/business/update_settings; dedicated dashboard control ships in a follow-up.

    Reviews v2

    • Per-shift reviews. A (business, shifter) pair can now have one review per direction PER shift. Old rows keep the "one per pair" rule (they're tagged shift_request_id=NULL); new reviews can pin to a specific completed shift via the new shift_request_id column. Schema 4-col unique constraint dropped; app-level dedup in is_review_exist enforces the conditional rule.
    • Response feature. The person being reviewed can post a response. Displayed publicly under the review. Editable. Backed by new response_text + response_at columns + PUT /v1/review/respond?review_id=.
    • Dispute path. The reviewee can dispute a review they think is unfair. Status flips to 'pending'; the review stays publicly visible (so disputing isn't a silent mute button). An admin resolves to 'upheld' (review hidden from public lists going forward) or 'rejected' (review stays). Backed by dispute_status / dispute_reason / disputed_at / dispute_resolved_at / dispute_resolved_by_user_id columns + POST /v1/review/dispute?review_id= + PUT /v1/review/dispute/resolve?review_id=&decision= (admin) + GET /v1/review/disputes?status_filter=pending|all (admin queue).
    • Trend graph. New GET /v1/review/trend?review_on=&review_on_id=&days= returns bucketed averages (daily for windows < 30d, weekly otherwise). Web UI: a small inline-SVG sparkline appears next to the avg-rating block on /s/{slug} for the last 90 days. No chart library; pure SVG.
    • Hidden when upheld. All public list endpoints (/v1/review/list, get_avg_review, batch averages, rating histogram) exclude dispute_status='upheld' rows so an admin's decision actually hides the review. Pending and rejected stay visible.
    • Web UI. /s/{slug} reviews tab gets the full v2 surface: response display, "Disputed" pill, Respond / Dispute affordances for the reviewee (only the person being reviewed sees them), and the 90-day sparkline. /b/{slug} gets the read-only display additions (response + pill); actionable buttons + sparkline on the business surface ship in a follow-up.
    • ReviewOut schema gains the new fields as Optional. Mobile clients on the v1 contract ignore unknown fields, so this is safely additive on the read side.
    • Schema-owned-by-Laravel; SQLAlchemy mirror added in app/models/review.py.

    Two-factor authentication

    Two-factor authentication

    • New settings page at /settings/2fa. Reachable from the avatar dropdown ("Two-factor authentication"). Self-service enable / disable / regenerate-backup-codes / trusted-devices.
    • TOTP is the default and only fully-wired method for v1: scan a QR with any authenticator app (Google Authenticator, Authy, 1Password, Bitwarden, Apple Passwords, Microsoft Authenticator, etc.), confirm with a 6-digit code, done.
    • 10 single-use backup codes generated on enable. Hashed at rest (SHA-256). Display once, never again. Regeneratable from settings (requires a fresh authenticator code to confirm).
    • Login interception lives in Laravel: it sits between primary auth and \Auth::login(). After password verifies, if the user has 2FA on, the session parks the FastAPI access/refresh tokens under pending_* keys and bounces to /2fa/challenge. Only a successful TOTP / backup-code verify promotes them. Cancel = logout, no partial state.
    • Disable requires a fresh code so a stolen browser session can't quietly turn 2FA off.
    • TOTP secrets encrypted at rest via Laravel's Crypt::encryptString (AES-256-CBC over APP_KEY). DB compromise alone doesn't leak the second factor.
    • Trusted devices. The challenge page has a "Trust this device for 30 days" checkbox. Tick it, and that browser skips the 2FA prompt for 30 days on subsequent sign-ins. Backed by a new user_2fa_trusted_devices table; the cookie carries <row_id>.<token> and only the SHA-256 hash is stored. HttpOnly, Secure-on-HTTPS, SameSite=Lax. The token rotates on every successful use (and the row's expires_at rolls forward), so a copied cookie loses its second life the moment the legitimate owner signs in next. Settings page lists every active trusted device with last-used + IP + per-device "Sign out" + global "Sign out everywhere". Disabling 2FA or regenerating backup codes auto-revokes every trusted device for the user (and clears the cookie on the current browser) since both signal "I think someone got in" or "starting clean." Daily artisan 2fa:prune-trusted-devices (03:30 Pacific) drops rows older than 7 days past expiration or revocation so storage stays bounded.
    • SMS path is sketched in the model but feature-flagged off (config/two_factor.phpTWO_FACTOR_SMS_ENABLED=false) until the Telnyx 10DLC 2FA campaign approves. When enabled, codes will route through Paradise (pm.shiftsee.com) like every other transactional message.
    • Schema: users.two_fa_enabled / two_fa_method / two_fa_secret / two_fa_phone / two_fa_enabled_at / two_fa_pending_secret, plus a new user_2fa_backup_codes table. SQLAlchemy mirror added in app/models/user.py (read-only on the FastAPI side; the v1 contract is frozen, so the 2FA flow lives entirely in Laravel for now).

    Off-session 3DS / SCA, end to end

    • New columns invoice_payments.requires_action (bool) and requires_action_at (timestamp). Set when Stripe returns a PaymentIntent in requires_action state on an off-session charge. Cleared by the existing payment_intent.succeeded webhook.
    • Customer::charge2 now switches saved-card charges to off_session=true so Stripe throws CardException(authentication_required) instead of silently returning a stuck PI. The exception is caught in ContactBase::initiatePayment and surfaced upstream as status='requires_action' with payment_intent_id and a fresh client_secret.
    • PaymentTransaction::pay() propagates the new status as REQUIRES_ACTION rather than incorrectly flagging the invoice as paid. The invoice stays STATUS_PENDING and transactions.paid stays 0, since the actual money hasn't moved yet.

    Customer surface for the stuck-charge flow

    • Email via Paradise (pm.shiftsee.com) on first-touch with a deep link to /finance/web/authenticate/{invoice_id}. Subject: "Action required: confirm your card payment on ShiftSee".
    • New /finance/web/authenticate/{invoice_id} page: re-fetches a fresh client_secret from Stripe, runs stripe.confirmCardPayment(client_secret) so the issuer presents 3DS, and bounces the customer back to the dashboard on success. Server-side guards verify the caller owns the invoice and it's still in requires_action.
    • Banner on /business/dashboard (<RequiresActionBanner />) lists every stuck invoice for the current user with a per-invoice "Confirm payment" link. Renders nothing when there's nothing stuck. Backed by GET /finance/web/requires-action (cookie-authed JSON).

    Webhook hygiene

    • callbackPaymentIntentSucceeded now also clears requires_action on the invoice when set, so a webhook delivered slightly before the user's redirect still hits the right state.
    • callbackPaymentIntentFailed clears requires_action (and rolls the invoice back to PENDING). After a 3DS attempt that ultimately failed, keeping the flag would point the user at a stale client_secret. Better to drop them into the standard "retry charge" UX from the dashboard.

    Heads-up for partner integrations

    • Marked **MUST READ** in /admin/api-docs#changelog and on the Breaking & action-required page for both items. The "is this invoice paid" predicate should be status == STATUS_PAID, not "PI was created". The latter has always been ambiguous, but is now ambiguous in a more visible way. The 2FA columns are read-only mirrors on the FastAPI side; existing API consumers see new optional fields on user-detail responses (mobile clients ignore unknown fields).
    Read the blog post for May 9 → Permalink
  2. Time-axis Day and Week views with drag-to-move, drag-to-resize, and cross-day drag

    • feature

    Calendar v2 and v3, shipped together. Day and Week now render as vertical time-axis grids with hour and half-hour gridlines. Drag a shift to move it within the day, across days, or resize it. Click any empty time slot to schedule on it. Plus accepted-shift drag, shifter open-shifts date filter, and Month-view cross-day drag.

    Day and Week views (new — both time-axis)

    • Both Day and Week now render as vertical time-axis grids. Day shows one column; Week shows seven side-by-side, with a sticky weekday + day-of-month header strip.
    • 24-hour grid (midnight to midnight) with hour and half-hour gridlines. Hour labels live in a 64px gutter on the left.
    • Live brand-emerald now line crosses the appropriate column when today is in the visible range.
    • Drag-to-move: grab a shift block (anywhere except the edges) and drag. Vertical movement changes start time (preserves duration). In Week mode, horizontal movement across columns changes the day. Both axes work together — drag diagonally to change day AND time in one gesture. Snaps to 15 minutes.
    • Drag-to-resize: grab the top edge to change shift_in; grab the bottom edge to change shift_out. Each handle is independent, so the duration changes by the dragged amount. Snaps to 15 minutes.
    • Click an empty time slot: bounces to the dashboard's broadcast send-shift modal pre-seeded with the date AND the start time you clicked. End time defaults to start + 1 hour. (Shifter side bounces to /shifter/open-shifts?date= since shifters don't author shifts.)
    • Overlapping shifts on the same day lay out side-by-side in proportional columns.
    • Cross-midnight shifts (those whose shift_out is earlier than shift_in) render with a "→ continues into the next day" indicator and aren't drag-mutable in v1; the date math gets gnarlier and the use case is rare.
    • Mouse-driven gestures, not HTML5 native drag — necessary for sub-day granularity. The grid catches mousedown/mousemove/mouseup at the window level so the drag survives leaving the column.
    • View choice (Day / Week / Month) is persisted per role.

    Drag-to-reschedule (all views)

    • Pending shifts (amber outline) and accepted shifts (green outline) are draggable in Day, Week, and Month. Cancelled, superseded, and completed shifts are not.
    • For pending shifts, the drop opens a confirmation modal that sends the counter-offer to the other party.
    • For accepted shifts, the modal shows a warning banner at the top: "This shift is currently accepted. Sending will return it to pending until [the other party] re-confirms the new terms." Same backend flow, but the consequence is now visible upfront.
    • Backed by the existing PUT /v1/shift_request/change_request endpoint (no contract change). The original row is marked request_status=4 (superseded) and a new pending child row is created with parent_id linking to it, so the negotiation history reads honestly.
    • Month view stays as a 7×6 day-cell grid for the high-level overview it gives; cross-day drag there is date-only (HTML5 native drag). Use Day or Week to also adjust time in the same gesture.

    Shifter-side calendar

    • New /shifter/calendar route. Same Day / Week / Month grid as the business side; data source is list_of_requests filtered by shifter_id.
    • Shifters can drag their pending or accepted requests in the same flow businesses use.
    • Shifter dashboard's "Upcoming Shifts" header gets a Full calendar → link.

    Calendar internals

    • Both sides share a single CalendarView (resources/js/components/ds/CalendarView.tsx); business and shifter wrappers are about ten lines each. Day and Week share a single time-axis component (CalendarTimeAxisView.tsx) parameterized by days: Date[] — same code path for the 1-column day grid and the 7-column week grid.
    • SendShiftRequestModal gained two optional props: initialShiftDate (YYYY-MM-DD) and initialShiftIn (HH:MM). When initialShiftIn is set, end time auto-fills to start + 1 hour. Both fields can be set independently or together.

    Empty-day click on Month view

    • Business side: bounces to the dashboard's broadcast send-shift modal, pre-seeded with the chosen date.
    • Shifter side: bounces to /shifter/open-shifts?date=YYYY-MM-DD filtered to that date. The page shows a banner ("Showing open shifts on Tuesday, May 12, 2026") with a one-click "Show all dates" reset. Backed by the existing BroadcastShiftRequestsFind.shift_date filter (no contract change).
    Read the blog post for May 8 → Permalink
  3. Calendar view for businesses

    • feature

    A week and month calendar at /business/calendar showing every shift on a business at a glance, color-coded by status. Click a shift to open it; click a day to schedule a new one.

    Calendar

    • New /business/calendar page. Renders the active business's shifts on a week or month grid.
    • Toggle between Week and Month views from the toolbar; the choice is remembered for the next visit.
    • Prev / Today / Next navigates by week or month depending on the active view.
    • Each shift block shows start time, the shifter's name (or "Open shift" when broadcasted), and the role.
    • Color coding by status: amber for pending, green for accepted, blue for completed, gray for cancelled or changed.
    • Click a shift block to open the existing detail page at /business/shifts?shift=<id>. Click an empty calendar cell to bounce to the dashboard's send-shift action for that day.
    • Month view shows up to three shifts per cell with a "+N more" summary; week view shows every shift on the day with extra detail (role appended).
    • Today's date is highlighted with a brand-emerald pill.
    • Available from the dashboard "Upcoming Shifts" header alongside the existing View all link.

    Performance

    • The calendar fetches up to 500 shifts in a single request and filters client-side by the active range. Results are cached locally for five minutes so paging back and forth between weeks is instant.

    Out of scope for v1

    • Drag-to-reschedule. Wanted, not yet wired; needs a new endpoint and a drag library, planned for v2.
    • Shifter-side calendar. The same component pattern applies; the shifter dashboard already has a per-day list view.
    Read the blog post for May 7 → Permalink
  4. Messaging Center and Help Center

    • feature

    Direct messages between any two parties on ShiftSee plus a full help center with searchable articles, tutorials, an AI helper, and seamless human escalation.

    Help Center

    • New /help page. Public, searchable, browseable. Twelve starter articles covering both shifter and business audiences, organized by category.
    • Floating AI helper widget on every authed page. Bottom-right corner. Talks to Claude Haiku, retrieves relevant articles via RAG, cites sources inline, and can hand off to a human when stuck.
    • AI helper conversations live in the Messaging center as kind='support_ai' threads, so the user has a record. On escalation the thread converts to kind='support' and an admin can join.
    • Question logging: when the AI's answer is low-confidence, the question lands in an admin review queue with PII scrubbed. Admins can convert frequently-asked questions into real articles.
    • Tutorial videos surface on /help and /help/videos; admin uploads via direct DB or the upcoming admin UI.
    • Mobile webview: /help renders cleanly in-app. Native devs can swap to native UI using /v1/help/* endpoints.

    Messaging

    • New /messages page. Single thread per pair of parties (a party is a shifter or a business). Three tabs: All, Pinned, Requests.
    • Conversations are role-aware. In Shifter view your inbox shows your shifter-side threads; in Business view it shows the active business's threads.
    • Per-message attribution. When a business teammate replies on behalf of the business, the message renders the teammate's name on a sub-line ("Pat from Joe's Cafe") so the human is always visible behind the brand.
    • Read receipts: ✓ on send, ✓✓ when the other side has read up to that message.
    • Pin and archive per-thread.
    • Real-time delivery via push notifications when the app is backgrounded; polling when active (8s focused, 20s blurred).
    • Request gate for messages from non-connected senders. First message from a stranger lands in the recipient's Requests inbox; the sender can send only one until accepted. Archive the request to silently block follow-ups.
    • New Crew tab on every business profile (/b/<slug>). Lists shifters connected to that business with a Message button on each row.
    • Body cap: 4000 characters per message. Soft-delete on messages (admin trust and safety can still see the original).

    Schema

    • New tables for messaging: conversations, conversation_participants, messages.
    • New tables for help center: help_articles, help_videos, help_questions.
    • All migrations run on deploy via php artisan migrate.

    API

    • New surface under /v1/messaging/*. Inbox, lookup, send, read, pin, archive, accept. New error codes MSG-NOT-FND-ERR, MSG-NOT-PARTICIPANT, MSG-BLOCKED-ERR, MSG-REQ-PENDING-ERR, MSG-BODY-ERR.
    • New surface under /v1/help/*. Article list/get/feedback/search, video list/get, ask (AI), escalate. New error code HELP-NOT-FND-ERR.
    • Requires env ANTHROPIC_API_KEY for the AI helper to function. Without it, the help widget falls back to a hand-written reply that nudges to articles.
    • Schema fields conversation_participants.is_automated + automation_kind were reserved for the AI-assisted reply layer; the help center is the first user of them.
    Read the blog post for May 6 → Permalink
  5. Global search, mobile overhaul, and dashboard fixes

    • feature
    • improvement
    • design

    Live search across people and businesses, a usable mobile experience, and several dashboard reliability fixes.

    Features

    • Global search across shifters and businesses, ranked by your connection (direct connection > worked together > one hop in your network > public). Live in the top bar with debounced typeahead, recent searches, and a Suggested-for-you block. Full results page at /search with type, connection, and sort filters.
    • New DS primitive: InputPhone. Country picker with type-to-search, paste-an-E.164 country detection, ~250 countries, no external library.
    • Public release-notes page at /release-notes and blog at /blog, with RSS feeds and footer links.

    Improvements

    • Avatar dropdown is now contextual: in shifter view it lists businesses you're connected to; in business view, businesses you own. Self-default sorts to top with a "You" pill.
    • Mobile drawer added: hamburger now opens a real navigation panel with role toggle, business CTAs (Broadcast, Send Shift, Schedule Myself), tier-2 nav, browse links, and an Account section (User Settings, Help, Logout).
    • Payment History on mobile transformed into a card list. Filters stack into one column. Empty state replaces the colspan placeholder.
    • Pagination across the app now hides itself when there's only one page of results.
    • Schedule Day modal expanded to its intended width so the Hourly Rate column no longer bleeds past the right edge.
    • Business Info form (and every settings page) fits the viewport on phones. Root cause was a missing box-sizing: border-box on the DS Input wrapper.

    Fixes

    • Business dashboard now refreshes its Shifts widget when you create a shift through the header (Send Shift Request, Broadcast Shift, Schedule Myself). Previously you had to reload the page.
    • Business dashboard's Upcoming Shifts calendar shows shifts again when the active business doesn't have a saved timezone. The countryTimezone guard was dead code blocked by the global axios interceptor's auto-attached Tz header.
    • Sign-in HTTPS guard added: a misconfigured staging env was leaking an HTTP API URL that the browser silently blocked under mixed-content policy.
    • Hamburger no longer toggles the desktop scrollbar (CSS rule order regression).
    Read the blog post for May 5 → Permalink
  6. External Partner API v1

    • feature
    • infrastructure

    A complete partner platform with OAuth, webhooks, embeddable Connect button and Schedule widget, and a self-serve developer portal.

    Features

    • New /v1/partner/* API surface for third-party integrations.
    • API key authentication via x-api-key header for server-to-server calls.
    • OAuth 2.0 with PKCE for user-permissioned access. Read-only and read-write scopes per resource family.
    • Webhooks for shift_request.created, broadcast.created, and other events. Stripe-style HMAC-SHA256 signing with a t=<unix>,v1=<hex> header.
    • Per-partner rate limiting at 1000 requests per hour. Stripe-style headers (X-RateLimit-Limit, X-RateLimit-Remaining, X-RateLimit-Reset).
    • Two embeddable widgets at /embed/v1/:
      • Connect button: walks visitors through "log in or sign up, then authorize this site to act on your account."
      • Schedule widget: lets a partner site post shifts directly into the ShiftSee marketplace.
      • Both ship as single <script> includes. Pop-up on desktop, full-page redirect on mobile.
    • Self-serve developer portal at /developers. Register an app, mint API keys, configure webhook endpoints, read the docs.
    • 10 new database tables backing the partner platform. Migrations, matching SQLAlchemy models in FastAPI, no Alembic.

    Deferred to v1.5

    • Partner analytics (traffic graphs, usage charts, billing).
    • Partner billing tiers (higher rate limits per paid tier).
    Read the blog post for May 3 → Permalink
  7. Admin SPA, business teammates, Telnyx SMS

    • feature
    • improvement
    • infrastructure

    A workflow-driven admin app, multi-user business accounts, and an SMS provider switch for faster delivery.

    Features

    • New admin app at /admin. Sidebar of resource groups (People, Activity, Catalog, Geo, Content, Access control, Devices & messaging, Detail tables). Top-bar global search across users, shifters, and businesses.
    • Per-entity timeline view in admin: pulls a row's full history (shift requests, connections, payments, notes) into one scrollable column.
    • Per-entity admin notes table for "I refunded the shifter, promised comp on next shift" context that was previously dying in someone's head.
    • Business teammates: businesses can have multiple user accounts with role-scoped permissions (owner, admin, scheduler, read-only).

    Improvements

    • SMS provider switched from Brevo to Telnyx. Same templates, faster delivery on US carrier prefixes, lower cost per message.
    • DS pass on marketing landing pages and the auth flow.

    Schema

    • New tables: admin_audit_log, admin_notes, business_colab extensions for multi-role support.
    Read the blog post for Apr 28 → Permalink
  8. Two-tier app header

    • improvement
    • design

    A persistent identity row above a contextual nav row, with role-aware tier-2 links and primary actions grouped on the right.

    Improvements

    • Authed app header redesigned with two tiers:
      • Tier 1: logo, role toggle (Shifter / Business), browse links, search, share / messages / notifications icons, avatar dropdown. Same on every page in every role.
      • Tier 2: role-contextual nav.
        • Shifter view: Dashboard / Schedule / Shift Requests / Open Shifts / Connected Businesses
        • Business view: Dashboard / Shifts / Connected Shifters / Payments
    • Three primary business actions on tier 2 right side: Schedule Myself, Send Shift Request, Broadcast Shift. Broadcast moved from tier 1 to tier 2.
    • Avatar dropdown gained a "MY BUSINESSES" section with the user's connected list and a "+ Create business" affordance.
    • Breadcrumb pattern formalized across every authed page: My account › <BusinessName or ShifterName> › <Page>.
    Read the blog post for Apr 25 → Permalink
  9. Brand refresh, Emerald and Amber

    • design

    New logo, new palette, updated tokens.

    Improvements

    • Brand primary set to Emerald #064E3B. Primary actions, focus rings, and active states across the app pick up the new value via --brand-primary.
    • Brand accent set to Amber #F59E0B. Used for Broadcast Shift, the "you" pill on default businesses, and inline highlights via --brand-accent.
    • New logo across every page, email template, and the favicon.
    • tokens.css remapped: --brand-primary, --brand-primary-tint, --brand-accent, --brand-accent-tint, --brand-accent-hover, plus existing ink/cream surface tokens.
    • Components that already referenced the brand tokens picked up the new palette automatically. Components with hard-coded greens or yellows convert as we touch them.
    Read the blog post for Apr 24 → Permalink
  10. Two-step login and shift modal redesign

    • feature
    • improvement
    • design

    A new login flow that figures out the right credential mode for you, plus a DS conversion of the highest-traffic shift modals.

    Features

    • Two-step login: identity (email or phone) on step 1, credential on step 2. Server lookup tells the client whether the account exists, has a password, or needs the SMS-code path.

    Improvements

    • DS conversion of SendShiftRequestModal and ProposeChangesModal. Every form field uses FormField + Input / Select / Textarea. Standard inline-pill error pattern. DS-grade buttons and chrome.
    • DS conversion of geo widgets (Country / State / City pickers).
    • Shifter tabs (Profile, Work history, Availability, Settings) DS pass.
    • ProfileForm "Verified" badge no longer overlaps the input on narrow viewports.
    Read the blog post for Apr 22 → Permalink