A Year in Sales · 2025
Twelve months of outreach, client meetings, and closed deals, laid out as a calendar story. Built with Muze.
Inspired by My Travels in 2025 by Rob Taylor.
What you’re looking at
Every dot is one day of 2025, placed in its real calendar slot. The layout is twelve month blocks, with columns from Monday to Sunday and one row per week. Color shows what happened that day:
- Teal: outreach
- Blue: admin
- Purple: client meetings
- Gold: a closed deal
- Red: the year’s one company holiday
When two things happened on the same day, the dot splits into two half-moons so neither gets lost.
Read across the year and a sales team’s rhythm appears. Outreach dominates, admin forms a steady base, and gold dots mark the closed deals. There are 16 gold days, worth $6.8M together. The biggest is a $970K new-logo win with Pied Piper in late May. Hover any day to read its date, activity, account, and deal value.
How it was built
The whole thing is a single Muze chart. A calendar is not a native chart type, so this viz reuses an approach from the original Tableau workbook.
Each day already has grid coordinates: a column from 1 to 31 and a week from 1 to 22. The viz normalizes those coordinates to a 0-to-1 range and passes them to Muze’s linear x and y scales. Every point then sits in its correct calendar position.
A single DataModel backs every layer. Muze’s transform and source options split it into separate slices:
- the day dots
- the day-of-week (M/T/W) headers
- the month captions
Each layer renders its own slice, so the chart never fetches data twice. A custom SVG shape generator draws the full-circle and half-moon marks. The tooltip shows only the fields a reader needs. The viz turns off click-to-select and drag-to-brush, so it stays hover-only.
To rebuild this with your own numbers, copy the code and data below into Muze Studio.
Take it with you
Paste each piece into the matching Muze Studio panel and load the CSV into a ThoughtSpot Worksheet. The only thing you'll likely change is the field-name references at the top of the JavaScript.
JavaScript
Field names are hoisted to constants at the top. Just edit them to match the columns you get in your Muze Studio Answer and you're all set.
Preview
const { muze, getDataFromSearchQuery } = viz;
// Field names — change these to match your dataset's columns. This is the only
// edit most charts need.
const ACTIVITY_CATEGORY_FIELD = "Category"; // colour by category
const ACTIVITY_FIELD = "Activity"; // free-text activity, in the tooltip
const DEAL_VALUE_FIELD = "Deal Value"; // dollar amount, in the tooltip
const DATE_FIELD = "Date"; // calendar date of each record
const RECORD_ID_FIELD = "Unique ID"; // one mark per record
const CALENDAR_LAYER_FIELD = "Map Layer"; // splits the Dates / Headers / Month Names rows
const OVERLAP_SLOT_FIELD = "Cat Order"; // 0 = solo day; 1 & 2 = the halves of a shared day
const CALENDAR_COLUMN_FIELD = "X- Calendar"; // grid column (day-of-week position)
const CALENDAR_ROW_FIELD = "Y- Calendar"; // grid row (week position)
// Category -> colour (keys are values of ACTIVITY_CATEGORY_FIELD).
const CATEGORY_COLORS = {
"Company Holiday": "#b60a1c",
"Outreach": "#30bcad",
"Deal Closed": "#ffda66",
"Admin": "#4f7cba",
"Client Meeting": "#a26dc2",
};
const { DataModel } = muze;
const { EQUAL } = DataModel.ComparisonOperators;
const data = getDataFromSearchQuery();
function darken(colorStr, factor) {
const hsl = colorStr.match(/hsla?\(\s*([\d.]+)[^,]*,\s*([\d.]+)%\s*,\s*([\d.]+)%/i);
if (hsl) {
const h = parseFloat(hsl[1]);
const s = parseFloat(hsl[2]);
const l = parseFloat(hsl[3]) * (1 - factor);
return "hsl(" + h + ", " + s + "%, " + l + "%)";
}
let r, g, b;
const hex = colorStr.match(/^#([0-9a-f]{3}|[0-9a-f]{6})$/i);
if (hex) {
const h = hex[1];
const full = h.length === 3 ? [...h].map((c) => c + c).join("") : h;
r = parseInt(full.slice(0, 2), 16);
g = parseInt(full.slice(2, 4), 16);
b = parseInt(full.slice(4, 6), 16);
} else {
const rgb = colorStr.match(/rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)/);
if (!rgb) return colorStr;
[r, g, b] = [+rgb[1], +rgb[2], +rgb[3]];
}
r /= 255; g /= 255; b /= 255;
const max = Math.max(r, g, b);
const min = Math.min(r, g, b);
const l = (max + min) / 2;
let h = 0, s = 0;
if (max !== min) {
const d = max - min;
s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
switch (max) {
case r: h = (g - b) / d + (g < b ? 6 : 0); break;
case g: h = (b - r) / d + 2; break;
case b: h = (r - g) / d + 4; break;
}
h *= 60;
}
return "hsl(" + h.toFixed(0) + ", " + (s * 100).toFixed(0) + "%, " + (l * (1 - factor) * 100).toFixed(0) + "%)";
}
const datesOnly = data.select({ field: CALENDAR_LAYER_FIELD, value: "Dates", operator: EQUAL });
const maxOf = (fieldName, dm) =>
Math.max(...dm.getField(fieldName).data().filter((v) => v != null));
const xMax = maxOf(CALENDAR_COLUMN_FIELD, datesOnly);
const yMax = maxOf(CALENDAR_ROW_FIELD, datesOnly);
const withNorm = data
.calculateVariable({ name: "xNorm", type: "measure" }, [CALENDAR_COLUMN_FIELD], (x) => (x == null ? null : x / xMax))
.calculateVariable({ name: "yNorm", type: "measure" }, [CALENDAR_ROW_FIELD], (y) => (y == null ? null : 1 - y / yMax));
const canvas = muze
.canvas()
.rows(["yNorm"])
.columns(["xNorm"])
.transform({
datesSrc: (dm) => dm.select({ field: CALENDAR_LAYER_FIELD, value: "Dates", operator: EQUAL }),
headersSrc: (dm) => dm.select({ field: CALENDAR_LAYER_FIELD, value: "Headers", operator: EQUAL }),
monthNamesSrc: (dm) => dm.select({ field: CALENDAR_LAYER_FIELD, value: "Month Names", operator: EQUAL }),
})
.layers([
{
mark: "point",
source: "datesSrc",
outline: { enable: true, width: 1.5 },
encoding: {
color: ACTIVITY_CATEGORY_FIELD,
opacity: { value: () => 1 },
shape: OVERLAP_SLOT_FIELD,
detail: [RECORD_ID_FIELD],
},
},
{
mark: "point",
source: "datesSrc",
encoding: {
color: ACTIVITY_CATEGORY_FIELD,
shape: OVERLAP_SLOT_FIELD,
detail: [RECORD_ID_FIELD],
},
},
{
mark: "text",
source: "datesSrc",
encoding: {
y: { value: ({ translatedValue }) => translatedValue + 1 },
color: {
field: ACTIVITY_CATEGORY_FIELD,
value: (dataInfo) => darken(dataInfo.translatedValue, 0.5),
},
text: {
field: DATE_FIELD,
formatter: ({ rawValue }) => String(new Date(rawValue).getUTCDate()),
},
},
interactive: false,
},
{
mark: "text",
source: "headersSrc",
encoding: {
text: ACTIVITY_CATEGORY_FIELD,
detail: [RECORD_ID_FIELD],
color: { value: () => "#000000" },
},
className: "viz-calendar-dow-text",
calculateDomain: false,
interactive: false,
},
{
mark: "text",
source: "monthNamesSrc",
encoding: {
text: {
field: DATE_FIELD,
formatter: ({ rawValue }) =>
new Date(rawValue).toLocaleString("en-US", { month: "long", timeZone: "UTC" }),
},
detail: [RECORD_ID_FIELD],
color: { value: () => "#000000" },
},
className: "viz-calendar-month-text",
calculateDomain: false,
interactive: false,
},
])
.config({
legend: {
show: false,
color: {
fields: {
[ACTIVITY_CATEGORY_FIELD]: { domainRangeMap: CATEGORY_COLORS },
},
},
size: { range: [10, 50] },
shape: {
generator: (val) => {
const d = {
0: "M 0,-100 A 100,100 0 1,0 0,100 A 100,100 0 1,0 0,-100 Z",
1: "M 0,-100 A 100,100 0 0,0 0,100 Z",
2: "M 0,-100 A 100,100 0 0,1 0,100 Z",
}[val];
const path = document.createElementNS("http://www.w3.org/2000/svg", "path");
path.setAttribute("d", d);
path.setAttribute("vector-effect", "non-scaling-stroke");
return path;
},
},
},
axes: {
x: { show: false, showAxisLine: false, domain: [0.015, 1.01] },
y: { show: false, showAxisLine: false, domain: [-0.03, 1.01] },
},
gridLines: { show: false },
autoGroupBy: { disabled: true },
interaction: {
tooltip: {
filter: { mode: "include", fields: [ACTIVITY_CATEGORY_FIELD] },
includeContent: [
{
displayName: "Date",
value: (dataInfo) =>
new Date(dataInfo.getRowData()[DATE_FIELD]).toLocaleString("en-US", {
weekday: "short", month: "short", day: "numeric", year: "numeric", timeZone: "UTC",
}),
},
{
displayName: "Activity",
value: (dataInfo) => {
const v = dataInfo.getRowData()[ACTIVITY_FIELD];
return typeof v === "string" && v.trim() ? v : "—";
},
},
{
displayName: "Deal Value",
value: (dataInfo) => {
const amount = dataInfo.getRowData()[DEAL_VALUE_FIELD];
if (typeof amount !== "number" || amount === 0) return "—";
return amount.toLocaleString("en-US", { style: "currency", currency: "USD", maximumFractionDigits: 0 });
},
},
],
},
},
})
.data(withNorm)
.mount("#chart");
// Read-only chart: disable click/drag selection (keeps hover tooltips working).
canvas.once("animationEnd", () => {
muze.ActionModel.for(canvas).dissociateBehaviour(
["select", "click"],
["brush", "drag"],
["select", "longtouch"],
["brush", "touchdrag"],
);
});
CSS
Text weights and tooltip styles. Use this to ensure your Muze Studio Answer matches up with the aesthetics of this showcase.
Preview
/* Muze Studio host defaults (Studio adds these automatically). */
html, body {
margin: 0;
padding: 0;
width: 100vw;
height: 100vh;
overflow: hidden;
}
#chart {
width: 100%;
height: 100%;
}
.viz-calendar-dow-text text {
font-weight: 700;
font-size: 14px;
}
g.viz-calendar-month-text text {
font-weight: 900;
font-size: 16px;
}
.muze-tooltip-box {
font-size: 14px;
}
/* Keep multi-line activity values (shared days) on their own lines */
.muze-tooltip-value-content {
white-space: pre !important;
overflow: visible !important;
text-overflow: clip !important;
width: auto !important;
}
HTML
The mount element the visualization renders into in Muze Studio. Same as the default HTML stippet in a Muze Studio Answer.
Preview
<div id="chart"></div>
Dataset (CSV)
Sales activity data for the Muze showcase above. Load it into a Muze Studio Worksheet and create a Muze Studio Answer with it.