Salon — Stylist Day Calendar
A single-stylist day calendar for a boutique salon, rendered as a scrollable vertical time axis from nine to six. Appointment blocks are sized by duration and colour-coded by service, with break slots and tappable open gaps. A swipeable date strip switches days while a live summary tracks booked hours, revenue and chair utilisation. Selecting a block reveals client, service, price and stylist notes in an editorial detail panel.
MCP
Code
:root {
--serif: "Cormorant Garamond", Georgia, serif;
--sans: "Inter", system-ui, -apple-system, sans-serif;
--gold: #b08d57;
--gold-d: #8c6d3f;
--gold-soft: #efe2cf;
--rose: #c9a78f;
--rose-soft: #f3e6dc;
--ink: #1c1814;
--ink-2: #3d362f;
--muted: #8a7d70;
--cream: #f7f1e8;
--bg: #faf6ef;
--white: #ffffff;
--line: rgba(28, 24, 20, 0.1);
--line-2: rgba(28, 24, 20, 0.18);
--ok: #5f8a6b;
--warn: #c08a3e;
--danger: #b3503e;
--r-sm: 8px;
--r-md: 14px;
--r-lg: 22px;
--sh-sm: 0 1px 2px rgba(28, 24, 20, 0.05), 0 1px 0 rgba(255, 255, 255, 0.6) inset;
--sh-md: 0 6px 22px -10px rgba(28, 24, 20, 0.28);
--sh-lg: 0 24px 60px -28px rgba(28, 24, 20, 0.4);
/* service colours */
--svc-color: #b07ca0;
--svc-color-soft: #f3e2ec;
--svc-cut: #6f93a8;
--svc-cut-soft: #e1ecf1;
--svc-treatment: #8aa57b;
--svc-treatment-soft: #e6efdf;
--svc-break: var(--muted);
--svc-break-soft: rgba(138, 125, 112, 0.12);
}
* {
box-sizing: border-box;
}
html {
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
text-rendering: optimizeLegibility;
}
body {
margin: 0;
font-family: var(--sans);
line-height: 1.5;
color: var(--ink);
background: var(--bg);
background-image:
radial-gradient(900px 500px at 88% -6%, rgba(176, 141, 87, 0.08), transparent 60%),
radial-gradient(700px 460px at -4% 4%, rgba(201, 167, 143, 0.1), transparent 62%);
}
h1, h2, h3 {
font-family: var(--serif);
margin: 0;
letter-spacing: 0.01em;
}
.app {
max-width: 1180px;
margin: 0 auto;
padding: clamp(16px, 3vw, 34px);
}
/* ---------- Top bar ---------- */
.topbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 20px;
flex-wrap: wrap;
padding-bottom: 20px;
border-bottom: 1px solid var(--line);
}
.brand {
display: flex;
align-items: center;
gap: 14px;
}
.brand__mark {
display: grid;
place-items: center;
width: 50px;
height: 50px;
border-radius: 50%;
font-family: var(--serif);
font-weight: 700;
font-size: 18px;
letter-spacing: 0.04em;
color: var(--white);
background: linear-gradient(150deg, var(--gold), var(--gold-d));
box-shadow: var(--sh-md), 0 0 0 4px var(--gold-soft);
}
.brand__eyebrow {
margin: 0;
font-size: 11px;
font-weight: 600;
letter-spacing: 0.18em;
text-transform: uppercase;
color: var(--gold-d);
}
.brand__title {
font-size: clamp(24px, 3.4vw, 32px);
font-weight: 600;
line-height: 1.1;
color: var(--ink);
}
.stylist {
display: flex;
align-items: center;
gap: 12px;
padding: 8px 14px 8px 8px;
background: var(--white);
border: 1px solid var(--line);
border-radius: 999px;
box-shadow: var(--sh-sm);
}
.stylist__avatar {
width: 42px;
height: 42px;
border-radius: 50%;
object-fit: cover;
border: 2px solid var(--gold-soft);
}
.stylist__name {
margin: 0;
font-weight: 600;
font-size: 14px;
}
.stylist__role {
margin: 0;
font-size: 11.5px;
color: var(--muted);
}
.stylist__status {
display: inline-flex;
align-items: center;
gap: 6px;
margin-left: 4px;
padding: 5px 11px;
font-size: 11px;
font-weight: 600;
letter-spacing: 0.04em;
color: var(--ok);
background: rgba(95, 138, 107, 0.1);
border-radius: 999px;
}
.stylist__status .dot {
width: 7px;
height: 7px;
border-radius: 50%;
background: var(--ok);
box-shadow: 0 0 0 3px rgba(95, 138, 107, 0.2);
}
/* ---------- Date strip ---------- */
.datestrip {
display: flex;
align-items: center;
gap: 8px;
margin: 22px 0 24px;
}
.datestrip__nav {
flex: none;
width: 38px;
height: 38px;
border-radius: 50%;
border: 1px solid var(--line-2);
background: var(--white);
color: var(--ink-2);
font-size: 20px;
line-height: 1;
cursor: pointer;
transition: background 0.18s, border-color 0.18s, transform 0.1s;
}
.datestrip__nav:hover {
background: var(--cream);
border-color: var(--gold);
color: var(--gold-d);
}
.datestrip__nav:active {
transform: scale(0.94);
}
.datestrip__list {
display: flex;
gap: 8px;
list-style: none;
margin: 0;
padding: 4px;
overflow-x: auto;
flex: 1;
scrollbar-width: thin;
}
.day {
flex: 1 0 auto;
min-width: 68px;
display: flex;
flex-direction: column;
align-items: center;
gap: 3px;
padding: 10px 6px;
border: 1px solid var(--line);
border-radius: var(--r-md);
background: var(--white);
cursor: pointer;
color: var(--ink-2);
font-family: var(--sans);
transition: border-color 0.18s, background 0.18s, transform 0.12s, box-shadow 0.18s;
}
.day:hover {
border-color: var(--rose);
transform: translateY(-2px);
box-shadow: var(--sh-sm);
}
.day__dow {
font-size: 11px;
font-weight: 600;
letter-spacing: 0.1em;
text-transform: uppercase;
color: var(--muted);
}
.day__num {
font-family: var(--serif);
font-size: 22px;
font-weight: 600;
line-height: 1;
}
.day__count {
font-size: 10.5px;
color: var(--gold-d);
font-weight: 600;
}
.day[aria-selected="true"] {
background: linear-gradient(160deg, var(--ink), var(--ink-2));
border-color: var(--ink);
color: var(--white);
box-shadow: var(--sh-md);
}
.day[aria-selected="true"] .day__dow,
.day[aria-selected="true"] .day__count {
color: var(--gold-soft);
}
.day--today .day__num::after {
content: "";
display: block;
width: 5px;
height: 5px;
margin: 3px auto 0;
border-radius: 50%;
background: var(--gold);
}
/* ---------- Layout ---------- */
.layout {
display: grid;
grid-template-columns: 1fr 340px;
gap: 22px;
align-items: start;
}
/* ---------- Timeline ---------- */
.timeline-wrap {
background: var(--white);
border: 1px solid var(--line);
border-radius: var(--r-lg);
box-shadow: var(--sh-md);
overflow: hidden;
}
.timeline-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
flex-wrap: wrap;
padding: 18px 22px;
border-bottom: 1px solid var(--line);
background: linear-gradient(var(--white), var(--cream));
}
.timeline-head__date {
font-size: 21px;
font-weight: 600;
}
.legend {
display: flex;
flex-wrap: wrap;
gap: 14px;
list-style: none;
margin: 0;
padding: 0;
font-size: 12px;
color: var(--ink-2);
}
.legend li {
display: inline-flex;
align-items: center;
gap: 6px;
}
.swatch {
width: 12px;
height: 12px;
border-radius: 4px;
}
.swatch[data-svc="color"] { background: var(--svc-color); }
.swatch[data-svc="cut"] { background: var(--svc-cut); }
.swatch[data-svc="treatment"] { background: var(--svc-treatment); }
.swatch[data-svc="break"] {
background: repeating-linear-gradient(45deg, var(--svc-break), var(--svc-break) 3px, transparent 3px, transparent 6px);
background-color: var(--svc-break-soft);
}
.timeline {
position: relative;
--axis-w: 64px;
--hour-h: 84px;
height: 560px;
overflow-y: auto;
padding: 8px 0;
scrollbar-width: thin;
}
.timeline__axis,
.timeline__grid,
.timeline__blocks {
position: relative;
}
.timeline__axis {
position: absolute;
inset: 8px auto 8px 0;
width: var(--axis-w);
}
.axis-hour {
position: absolute;
left: 0;
right: 0;
height: var(--hour-h);
padding-top: 2px;
text-align: right;
padding-right: 12px;
font-size: 11px;
font-weight: 600;
letter-spacing: 0.03em;
color: var(--muted);
transform: translateY(-7px);
}
.timeline__grid {
position: absolute;
inset: 8px 16px 8px var(--axis-w);
}
.grid-line {
position: absolute;
left: 0;
right: 0;
border-top: 1px solid var(--line);
}
.grid-line--half {
border-top: 1px dashed rgba(28, 24, 20, 0.06);
}
.timeline__blocks {
position: absolute;
inset: 8px 16px 8px var(--axis-w);
}
.block {
position: absolute;
left: 0;
right: 0;
border-radius: var(--r-md);
border: 1px solid var(--line);
padding: 9px 13px;
overflow: hidden;
cursor: pointer;
text-align: left;
font-family: var(--sans);
background: var(--white);
box-shadow: var(--sh-sm);
transition: transform 0.14s, box-shadow 0.18s, filter 0.18s;
}
.block::before {
content: "";
position: absolute;
left: 0;
top: 0;
bottom: 0;
width: 4px;
}
.block:hover {
transform: translateX(2px);
box-shadow: var(--sh-md);
filter: saturate(1.05);
z-index: 3;
}
.block:focus-visible {
outline: 2px solid var(--gold);
outline-offset: 2px;
}
.block.is-active {
box-shadow: 0 0 0 2px var(--gold), var(--sh-md);
z-index: 4;
}
.block__time {
font-size: 11px;
font-weight: 600;
letter-spacing: 0.02em;
color: var(--ink-2);
}
.block__client {
margin: 1px 0 0;
font-family: var(--serif);
font-size: 17px;
font-weight: 600;
line-height: 1.15;
color: var(--ink);
}
.block__svc {
margin: 1px 0 0;
font-size: 12px;
color: var(--ink-2);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.block[data-svc="color"] { background: var(--svc-color-soft); }
.block[data-svc="color"]::before { background: var(--svc-color); }
.block[data-svc="cut"] { background: var(--svc-cut-soft); }
.block[data-svc="cut"]::before { background: var(--svc-cut); }
.block[data-svc="treatment"] { background: var(--svc-treatment-soft); }
.block[data-svc="treatment"]::before { background: var(--svc-treatment); }
/* break / blocked */
.block--break {
background:
repeating-linear-gradient(45deg, rgba(138, 125, 112, 0.1), rgba(138, 125, 112, 0.1) 6px, transparent 6px, transparent 13px),
var(--svc-break-soft);
cursor: default;
}
.block--break::before { background: var(--muted); }
.block--break .block__client {
font-style: italic;
color: var(--ink-2);
font-size: 15px;
}
.block--compact {
padding: 6px 13px;
display: flex;
align-items: baseline;
gap: 8px;
}
.block--compact .block__client {
font-size: 14px;
margin: 0;
}
.block--compact .block__svc { display: none; }
/* open gap */
.gap {
position: absolute;
left: 0;
right: 0;
display: flex;
align-items: center;
justify-content: center;
border: 1px dashed var(--line-2);
border-radius: var(--r-md);
background: transparent;
color: var(--muted);
font-size: 12.5px;
font-weight: 500;
cursor: pointer;
font-family: var(--sans);
transition: background 0.18s, border-color 0.18s, color 0.18s;
}
.gap:hover {
background: var(--gold-soft);
border-color: var(--gold);
color: var(--gold-d);
}
.gap:focus-visible {
outline: 2px solid var(--gold);
outline-offset: 2px;
}
.gap__plus {
margin-right: 6px;
font-weight: 700;
font-size: 14px;
}
/* now line */
.nowline {
position: absolute;
left: var(--axis-w);
right: 16px;
height: 0;
border-top: 2px solid var(--danger);
z-index: 5;
pointer-events: none;
}
.nowline span {
position: absolute;
left: 0;
top: -9px;
transform: translateX(-100%);
margin-left: -6px;
padding: 1px 7px;
font-size: 9.5px;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--white);
background: var(--danger);
border-radius: 999px;
}
/* ---------- Side ---------- */
.side {
display: flex;
flex-direction: column;
gap: 18px;
position: sticky;
top: 18px;
}
.side__title {
font-size: 12px;
font-weight: 700;
letter-spacing: 0.16em;
text-transform: uppercase;
color: var(--gold-d);
margin-bottom: 14px;
font-family: var(--sans);
}
.summary,
.detail {
background: var(--white);
border: 1px solid var(--line);
border-radius: var(--r-lg);
box-shadow: var(--sh-md);
padding: 20px;
}
.summary__grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 14px;
}
.stat {
padding: 14px;
border: 1px solid var(--line);
border-radius: var(--r-md);
background: var(--cream);
}
.stat--wide {
grid-column: 1 / -1;
background: var(--white);
}
.stat__label {
margin: 0;
font-size: 11px;
font-weight: 600;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--muted);
}
.stat__value {
margin: 4px 0 0;
font-family: var(--serif);
font-size: 28px;
font-weight: 700;
line-height: 1;
color: var(--ink);
}
.stat__value span {
font-size: 16px;
color: var(--muted);
margin-left: 1px;
}
.stat__row {
display: flex;
align-items: baseline;
justify-content: space-between;
}
.stat__pct {
margin: 0;
font-family: var(--serif);
font-size: 22px;
font-weight: 700;
color: var(--gold-d);
}
.meter {
margin: 10px 0 8px;
height: 9px;
border-radius: 999px;
background: var(--gold-soft);
overflow: hidden;
}
.meter__fill {
display: block;
height: 100%;
width: 0;
border-radius: 999px;
background: linear-gradient(90deg, var(--rose), var(--gold));
transition: width 0.5s cubic-bezier(0.22, 1, 0.36, 1);
}
.stat__sub {
margin: 0;
font-size: 11.5px;
color: var(--muted);
}
/* detail card */
.detail__empty {
text-align: center;
color: var(--muted);
font-size: 13.5px;
padding: 18px 8px;
}
.detail__icon {
display: block;
font-size: 26px;
color: var(--gold);
margin-bottom: 10px;
}
.detail__card .dc-tag {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 4px 11px;
border-radius: 999px;
font-size: 11px;
font-weight: 600;
letter-spacing: 0.04em;
text-transform: capitalize;
}
.dc-tag[data-svc="color"] { background: var(--svc-color-soft); color: #7c4f6c; }
.dc-tag[data-svc="cut"] { background: var(--svc-cut-soft); color: #3f5f73; }
.dc-tag[data-svc="treatment"] { background: var(--svc-treatment-soft); color: #4c6640; }
.dc-client {
margin: 12px 0 2px;
font-family: var(--serif);
font-size: 26px;
font-weight: 600;
line-height: 1.1;
}
.dc-time {
margin: 0 0 16px;
font-size: 13px;
color: var(--muted);
}
.dc-meta {
list-style: none;
margin: 0 0 16px;
padding: 16px 0;
border-top: 1px solid var(--line);
border-bottom: 1px solid var(--line);
display: grid;
gap: 11px;
}
.dc-meta li {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 12px;
font-size: 13.5px;
}
.dc-meta .k {
color: var(--muted);
font-size: 12px;
letter-spacing: 0.03em;
}
.dc-meta .v {
font-weight: 600;
color: var(--ink);
text-align: right;
}
.dc-meta .v--price {
font-family: var(--serif);
font-size: 18px;
color: var(--gold-d);
}
.dc-notes {
font-size: 13px;
color: var(--ink-2);
line-height: 1.55;
}
.dc-notes strong {
display: block;
font-size: 11px;
font-weight: 600;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--muted);
margin-bottom: 4px;
}
.dc-actions {
display: flex;
gap: 10px;
margin-top: 18px;
}
.btn {
flex: 1;
padding: 10px 14px;
border-radius: var(--r-sm);
font-family: var(--sans);
font-size: 13px;
font-weight: 600;
cursor: pointer;
border: 1px solid var(--line-2);
background: var(--white);
color: var(--ink-2);
transition: background 0.16s, border-color 0.16s, transform 0.1s, color 0.16s;
}
.btn:hover {
background: var(--cream);
border-color: var(--gold);
}
.btn:active { transform: scale(0.97); }
.btn--primary {
background: linear-gradient(155deg, var(--gold), var(--gold-d));
border-color: var(--gold-d);
color: var(--white);
box-shadow: var(--sh-sm);
}
.btn--primary:hover {
filter: brightness(1.06);
background: linear-gradient(155deg, var(--gold), var(--gold-d));
}
.btn:focus-visible {
outline: 2px solid var(--gold);
outline-offset: 2px;
}
/* ---------- Toast ---------- */
.toast {
position: fixed;
left: 50%;
bottom: 26px;
transform: translate(-50%, 26px);
background: var(--ink);
color: var(--cream);
padding: 12px 20px;
border-radius: 999px;
font-size: 13.5px;
font-weight: 500;
box-shadow: var(--sh-lg);
opacity: 0;
pointer-events: none;
transition: opacity 0.28s, transform 0.28s;
z-index: 50;
max-width: 90vw;
}
.toast::before {
content: "✦";
color: var(--gold);
margin-right: 8px;
}
.toast.show {
opacity: 1;
transform: translate(-50%, 0);
}
/* ---------- Responsive ---------- */
@media (max-width: 900px) {
.layout {
grid-template-columns: 1fr;
}
.side {
position: static;
display: grid;
grid-template-columns: 1fr 1fr;
align-items: start;
}
}
@media (max-width: 520px) {
.topbar {
flex-direction: column;
align-items: flex-start;
}
.stylist {
width: 100%;
}
.side {
grid-template-columns: 1fr;
}
.summary__grid {
grid-template-columns: 1fr 1fr;
}
.timeline {
--axis-w: 52px;
--hour-h: 78px;
height: 480px;
}
.block__client {
font-size: 15px;
}
.legend {
gap: 10px;
font-size: 11px;
}
.timeline-head {
padding: 14px 16px;
}
.day {
min-width: 58px;
}
}
@media (prefers-reduced-motion: reduce) {
* {
transition-duration: 0.01ms !important;
animation-duration: 0.01ms !important;
}
}
/* Visibility guard: honor the [hidden] attribute over base display */
.detail__card[hidden] {
display: none;
}(() => {
"use strict";
// ---- Config ----------------------------------------------------------
const DAY_START = 9; // 9:00
const DAY_END = 18; // 6:00 pm
const WORK_HOURS = DAY_END - DAY_START; // 9
const HOUR_H = 84; // must match --hour-h in CSS (px per hour)
// ---- Helpers ---------------------------------------------------------
const $ = (sel, root = document) => root.querySelector(sel);
const pad = (n) => String(n).padStart(2, "0");
function fmtTime(decimal) {
let h = Math.floor(decimal);
const m = Math.round((decimal - h) * 60);
const ampm = h >= 12 ? "pm" : "am";
let hh = h % 12;
if (hh === 0) hh = 12;
return m === 0 ? `${hh}:00 ${ampm}` : `${hh}:${pad(m)} ${ampm}`;
}
function fmtDur(hours) {
const m = Math.round(hours * 60);
if (m < 60) return `${m} min`;
const h = Math.floor(m / 60);
const r = m % 60;
return r ? `${h} hr ${r} min` : `${h} hr`;
}
const toast = (() => {
const el = $("#toast");
let timer;
return (msg) => {
el.textContent = msg;
el.classList.add("show");
clearTimeout(timer);
timer = setTimeout(() => el.classList.remove("show"), 2600);
};
})();
// ---- Data: appointments per relative day offset ----------------------
// start/end as decimal hours within the 9–18 window. svc: color|cut|treatment|break
const SCHEDULES = {
0: [
{ start: 9, end: 9.5, svc: "break", client: "Morning prep", service: "Station setup", price: 0,
notes: "Sanitise tools, restock colour bar, review the day's bookings." },
{ start: 9.5, end: 11, svc: "color", client: "Eloise Marchand", service: "Balayage · Half head", price: 185,
notes: "Returning every 8 weeks. Soft caramel ribbons, keep roots natural. Allergy patch on file." },
{ start: 11, end: 12, svc: "cut", client: "Priya Anand", service: "Cut & Blow-dry", price: 78,
notes: "Loves a sleek long bob. Texturising on the ends only." },
{ start: 13, end: 13.75, svc: "treatment", client: "Wren Holloway", service: "Olaplex Bond Treatment", price: 64,
notes: "Bond repair after recent lightening. Gentle heat only." },
{ start: 14, end: 16, svc: "color", client: "Sofia Castellano", service: "Full Colour · Root to Tip", price: 220,
notes: "Cool espresso, 6N base. Bring gloss for shine finish." },
{ start: 16.5, end: 17.5, svc: "cut", client: "Theo Lindqvist", service: "Restyle & Beard Trim", price: 92,
notes: "Wants a sharper fade than last time. Photo saved in client file." },
],
1: [
{ start: 9, end: 9.5, svc: "break", client: "Morning prep", service: "Station setup", price: 0,
notes: "Sanitise tools and prep the colour bar." },
{ start: 10, end: 11.5, svc: "color", client: "Marguerite Bellini", service: "Foil Highlights", price: 165,
notes: "Babylights through the crown, brighten the face frame." },
{ start: 12, end: 13, svc: "treatment", client: "Yusuf Demir", service: "Scalp Detox Ritual", price: 58,
notes: "Sensitive scalp — fragrance-free line." },
{ start: 14.5, end: 16, svc: "cut", client: "Ada Okonkwo", service: "Curl Shaping Cut", price: 110,
notes: "Dry-cut curls, diffuse finish. No heavy products." },
],
2: [
{ start: 9, end: 11, svc: "color", client: "Camille Rousseau", service: "Colour Correction", price: 280,
notes: "Two-step correction from box dye. Strand test before processing." },
{ start: 11.5, end: 12.5, svc: "break", client: "Lunch", service: "Break", price: 0,
notes: "Stepping out to the café next door." },
{ start: 13, end: 14, svc: "cut", client: "Noah Whitfield", service: "Skin Fade & Style", price: 70,
notes: "Regular monthly. Keep length on top." },
{ start: 15, end: 17, svc: "treatment", client: "Isolde Frére", service: "Keratin Smoothing", price: 240,
notes: "Formaldehyde-free formula. 72-hour aftercare card to be given." },
],
"-1": [
{ start: 9.5, end: 11, svc: "cut", client: "Beatrix Vance", service: "Cut & Curl Set", price: 95,
notes: "Special event styling — soft Hollywood waves." },
{ start: 11.5, end: 13.5, svc: "color", client: "Lucia Moreau", service: "Ombré Refresh", price: 195,
notes: "Melt the regrowth, add a warm gloss." },
{ start: 14, end: 15, svc: "treatment", client: "Jonah Reyes", service: "Hydration Mask", price: 52,
notes: "Quick recovery treatment between colour appointments." },
],
};
function scheduleFor(offset) {
return SCHEDULES[String(offset)] || [];
}
// ---- State -----------------------------------------------------------
const today = new Date();
today.setHours(0, 0, 0, 0);
let selectedOffset = 0;
let weekAnchor = 0; // shifts the date strip window
let activeBlockEl = null;
const DOW = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
const MONTHS = ["January", "February", "March", "April", "May", "June",
"July", "August", "September", "October", "November", "December"];
const DAYS_FULL = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"];
function dateFor(offset) {
const d = new Date(today);
d.setDate(d.getDate() + offset);
return d;
}
// ---- Render: date strip ---------------------------------------------
const dateStrip = $("#dateStrip");
function renderStrip() {
dateStrip.innerHTML = "";
for (let i = 0; i < 7; i++) {
const offset = weekAnchor + i;
const d = dateFor(offset);
const li = document.createElement("li");
const btn = document.createElement("button");
btn.className = "day";
btn.type = "button";
btn.setAttribute("role", "tab");
btn.setAttribute("aria-selected", offset === selectedOffset ? "true" : "false");
if (offset === 0) btn.classList.add("day--today");
const count = scheduleFor(offset).filter((a) => a.svc !== "break").length;
btn.innerHTML = `
<span class="day__dow">${DOW[d.getDay()]}</span>
<span class="day__num">${d.getDate()}</span>
<span class="day__count">${count ? count + " appt" + (count > 1 ? "s" : "") : "open"}</span>`;
btn.addEventListener("click", () => selectDay(offset));
li.appendChild(btn);
dateStrip.appendChild(li);
}
}
function selectDay(offset) {
selectedOffset = offset;
renderStrip();
renderDay();
clearDetail();
}
// ---- Render: timeline ------------------------------------------------
const axis = $("#axis");
const grid = $("#grid");
const blocksEl = $("#blocks");
const timelineEl = $("#timeline");
function buildAxisAndGrid() {
axis.innerHTML = "";
grid.innerHTML = "";
const total = WORK_HOURS * HOUR_H;
timelineEl.querySelector(".timeline__axis").style.height = total + "px";
grid.style.height = total + "px";
blocksEl.style.height = total + "px";
for (let h = DAY_START; h <= DAY_END; h++) {
const top = (h - DAY_START) * HOUR_H;
const label = document.createElement("div");
label.className = "axis-hour";
label.style.top = top + "px";
label.textContent = fmtTime(h);
axis.appendChild(label);
const line = document.createElement("div");
line.className = "grid-line";
line.style.top = top + "px";
grid.appendChild(line);
if (h < DAY_END) {
const half = document.createElement("div");
half.className = "grid-line grid-line--half";
half.style.top = top + HOUR_H / 2 + "px";
grid.appendChild(half);
}
}
}
function yFor(decimal) {
return (decimal - DAY_START) * HOUR_H;
}
function renderDay() {
const date = dateFor(selectedOffset);
$("#dayLabel").textContent =
`${DAYS_FULL[date.getDay()]}, ${MONTHS[date.getMonth()]} ${date.getDate()}`;
const appts = scheduleFor(selectedOffset)
.slice()
.sort((a, b) => a.start - b.start);
blocksEl.innerHTML = "";
activeBlockEl = null;
// appointment + break blocks
appts.forEach((appt, idx) => {
const el = document.createElement(appt.svc === "break" ? "div" : "button");
const span = appt.end - appt.start;
el.className = "block" + (appt.svc === "break" ? " block--break" : "") + (span <= 0.6 ? " block--compact" : "");
el.dataset.svc = appt.svc;
el.style.top = yFor(appt.start) + "px";
el.style.height = (span * HOUR_H - 6) + "px";
const timeStr = `${fmtTime(appt.start)} – ${fmtTime(appt.end)}`;
el.innerHTML = `
<span class="block__time">${timeStr}</span>
<p class="block__client">${appt.client}</p>
<p class="block__svc">${appt.service}</p>`;
if (appt.svc !== "break") {
el.type = "button";
el.setAttribute("aria-label", `${appt.client}, ${appt.service}, ${timeStr}`);
el.addEventListener("click", () => {
showDetail(appt, el);
});
}
blocksEl.appendChild(el);
});
// open gaps between appointments (within working hours)
const occupied = appts.map((a) => [a.start, a.end]).sort((a, b) => a[0] - b[0]);
let cursor = DAY_START;
const gaps = [];
occupied.forEach(([s, e]) => {
if (s - cursor >= 0.5) gaps.push([cursor, s]);
cursor = Math.max(cursor, e);
});
if (DAY_END - cursor >= 0.5) gaps.push([cursor, DAY_END]);
gaps.forEach(([s, e]) => {
const g = document.createElement("button");
g.className = "gap";
g.type = "button";
g.style.top = yFor(s) + "px";
g.style.height = ((e - s) * HOUR_H - 6) + "px";
const open = `${fmtTime(s)} – ${fmtTime(e)}`;
g.innerHTML = `<span class="gap__plus">+</span> Add appointment`;
g.setAttribute("aria-label", `Add appointment, open ${open}`);
g.addEventListener("click", () => {
toast(`Open slot ${open} — booking flow coming soon.`);
});
blocksEl.appendChild(g);
});
updateSummary(appts);
positionNowLine();
}
// ---- Now line --------------------------------------------------------
function positionNowLine() {
const nl = $("#nowline");
const now = new Date();
const isToday = selectedOffset === 0;
const dec = now.getHours() + now.getMinutes() / 60;
if (isToday && dec >= DAY_START && dec <= DAY_END) {
nl.hidden = false;
nl.style.top = (8 + yFor(dec)) + "px";
} else {
nl.hidden = true;
}
}
// ---- Summary ---------------------------------------------------------
function updateSummary(appts) {
const billable = appts.filter((a) => a.svc !== "break");
const bookedHours = billable.reduce((s, a) => s + (a.end - a.start), 0);
const revenue = billable.reduce((s, a) => s + a.price, 0);
const pct = Math.round((bookedHours / WORK_HOURS) * 100);
$("#statHours").innerHTML = `${bookedHours.toFixed(1)}<span>h</span>`;
$("#statRev").textContent = `$${revenue.toLocaleString()}`;
$("#statPct").textContent = `${pct}%`;
$("#statSub").textContent = `${bookedHours.toFixed(1)} of ${WORK_HOURS} working hours`;
const fill = $("#meterFill");
fill.style.width = Math.min(pct, 100) + "%";
const meter = $("#meter");
meter.setAttribute("aria-valuenow", String(pct));
}
// ---- Detail panel ----------------------------------------------------
const detailEmpty = $("#detailEmpty");
const detailCard = $("#detailCard");
function clearDetail() {
detailCard.hidden = true;
detailEmpty.style.display = "";
if (activeBlockEl) {
activeBlockEl.classList.remove("is-active");
activeBlockEl = null;
}
}
function showDetail(appt, el) {
if (activeBlockEl) activeBlockEl.classList.remove("is-active");
activeBlockEl = el;
el.classList.add("is-active");
detailEmpty.style.display = "none";
detailCard.hidden = false;
const svcLabel = { color: "Colour", cut: "Cut & Style", treatment: "Treatment" }[appt.svc];
const dur = appt.end - appt.start;
detailCard.innerHTML = `
<span class="dc-tag" data-svc="${appt.svc}">${svcLabel}</span>
<h3 class="dc-client">${appt.client}</h3>
<p class="dc-time">${fmtTime(appt.start)} – ${fmtTime(appt.end)}</p>
<ul class="dc-meta">
<li><span class="k">Service</span><span class="v">${appt.service}</span></li>
<li><span class="k">Duration</span><span class="v">${fmtDur(dur)}</span></li>
<li><span class="k">Price</span><span class="v v--price">$${appt.price}</span></li>
</ul>
<p class="dc-notes"><strong>Stylist notes</strong>${appt.notes}</p>
<div class="dc-actions">
<button class="btn" type="button" data-act="reschedule">Reschedule</button>
<button class="btn btn--primary" type="button" data-act="checkin">Check in</button>
</div>`;
detailCard.querySelector('[data-act="reschedule"]')
.addEventListener("click", () => toast(`Reschedule requested for ${appt.client}.`));
detailCard.querySelector('[data-act="checkin"]')
.addEventListener("click", () => toast(`${appt.client} checked in — chair 02 is ready.`));
}
// ---- Week navigation -------------------------------------------------
$("#dayPrev").addEventListener("click", () => {
weekAnchor -= 7;
renderStrip();
});
$("#dayNext").addEventListener("click", () => {
weekAnchor += 7;
renderStrip();
});
// ---- Keyboard: arrow keys move between days --------------------------
document.addEventListener("keydown", (e) => {
if (e.target.matches("input, textarea")) return;
if (e.key === "ArrowLeft") { selectDay(selectedOffset - 1); ensureVisible(); }
if (e.key === "ArrowRight") { selectDay(selectedOffset + 1); ensureVisible(); }
});
function ensureVisible() {
if (selectedOffset < weekAnchor) { weekAnchor = selectedOffset; renderStrip(); }
if (selectedOffset > weekAnchor + 6) { weekAnchor = selectedOffset - 6; renderStrip(); }
}
// ---- Init ------------------------------------------------------------
buildAxisAndGrid();
renderStrip();
renderDay();
// keep the now-line fresh
setInterval(positionNowLine, 60 * 1000);
})();<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Stylist Day Calendar · Maison Lumière Salon</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Cormorant+Garamond:wght@500;600;700&family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet" />
<link rel="stylesheet" href="style.css" />
</head>
<body>
<div class="app">
<!-- Header -->
<header class="topbar">
<div class="brand">
<span class="brand__mark" aria-hidden="true">ML</span>
<div class="brand__text">
<p class="brand__eyebrow">Maison Lumière Salon</p>
<h1 class="brand__title">Stylist Day Calendar</h1>
</div>
</div>
<div class="stylist">
<img class="stylist__avatar" src="https://i.pravatar.cc/96?img=47" alt="" />
<div class="stylist__meta">
<p class="stylist__name">Aria Vance</p>
<p class="stylist__role">Senior Colorist · Chair 02</p>
</div>
<span class="stylist__status" aria-label="On the floor">
<span class="dot"></span> On floor
</span>
</div>
</header>
<!-- Date strip -->
<nav class="datestrip" aria-label="Select day">
<button class="datestrip__nav" id="dayPrev" aria-label="Previous week">‹</button>
<ul class="datestrip__list" id="dateStrip" role="tablist"></ul>
<button class="datestrip__nav" id="dayNext" aria-label="Next week">›</button>
</nav>
<div class="layout">
<!-- Timeline -->
<main class="timeline-wrap">
<div class="timeline-head">
<h2 class="timeline-head__date" id="dayLabel">Thursday, June 8</h2>
<ul class="legend" aria-label="Service colour key">
<li><span class="swatch" data-svc="color"></span> Colour</li>
<li><span class="swatch" data-svc="cut"></span> Cut & Style</li>
<li><span class="swatch" data-svc="treatment"></span> Treatment</li>
<li><span class="swatch" data-svc="break"></span> Break</li>
</ul>
</div>
<div class="timeline" id="timeline" role="list" aria-label="Appointments timeline">
<div class="timeline__axis" id="axis" aria-hidden="true"></div>
<div class="timeline__grid" id="grid"></div>
<div class="timeline__blocks" id="blocks"></div>
<div class="nowline" id="nowline" hidden><span>now</span></div>
</div>
</main>
<!-- Side: summary + detail -->
<aside class="side">
<section class="summary" aria-label="Day summary">
<h3 class="side__title">Day Summary</h3>
<div class="summary__grid">
<div class="stat">
<p class="stat__label">Booked</p>
<p class="stat__value" id="statHours">0.0<span>h</span></p>
</div>
<div class="stat">
<p class="stat__label">Revenue</p>
<p class="stat__value" id="statRev">$0</p>
</div>
<div class="stat stat--wide">
<div class="stat__row">
<p class="stat__label">Utilisation</p>
<p class="stat__pct" id="statPct">0%</p>
</div>
<div class="meter" role="progressbar" aria-valuemin="0" aria-valuemax="100" aria-valuenow="0" id="meter">
<span class="meter__fill" id="meterFill"></span>
</div>
<p class="stat__sub" id="statSub">0 of 9 working hours</p>
</div>
</div>
</section>
<section class="detail" id="detail" aria-live="polite">
<div class="detail__empty" id="detailEmpty">
<span class="detail__icon" aria-hidden="true">✦</span>
<p>Select an appointment to view its details, or tap an open slot to add one.</p>
</div>
<div class="detail__card" id="detailCard" hidden></div>
</section>
</aside>
</div>
</div>
<div class="toast" id="toast" role="status" aria-live="polite"></div>
<script src="script.js"></script>
</body>
</html>Stylist Day Calendar
A focused day view for a single chair at Maison Lumière Salon. The timeline runs vertically from nine in the morning to six in the evening, with appointment blocks placed and sized to their real duration. Each block is colour-coded by service — colour, cut and style, or treatment — while breaks render as soft hatched slots and the open hours between bookings invite a tap to schedule. A red now-line tracks the present moment on today’s column.
A swipeable date strip across the top moves between days, each chip carrying its own appointment count, and arrow keys nudge the selection left or right. Selecting any appointment lifts it into focus and opens an editorial detail panel: the client name set in Cormorant, the service tag, duration, price and the stylist’s private notes, with check-in and reschedule affordances.
The day summary recomputes live as you move between dates — booked hours, projected revenue and a utilisation meter that fills against the nine-hour working window — so a stylist can read the shape of their day at a glance. Everything is vanilla HTML, CSS and JavaScript, dressed in the house palette of rose-gold, cream and matte black.