unfazed

← examples

Weekly habit grid

Jun 12, 2026 Sonnet 4.6 Claude Code
app.unfazed.dev/habit-tracker open (opens in new tab)
session
user

build me a weekly habit tracker: i add habits as rows, columns are mon–sun checkboxes, everything persists in localstorage, and show a small streak counter per habit. clean minimal look. deploy it on unfazed and give me the public link.

I'll read the unfazed docs first, then build and deploy the habit tracker.

ToolSearch(query: "select:ReadMcpResourceTool,mcp__unfazed_…", max_results: 5)
input
{
  "query": "select:ReadMcpResourceTool,mcp__unfazed__whoami,mcp__unfazed__create_workspace,mcp__unfazed__write_workspace_text_file,mcp__unfazed__create_service_deployment",
  "max_results": 5
}
result
[
  {
    "type": "tool_reference",
    "tool_name": "ReadMcpResourceTool"
  },
  {
    "type": "tool_reference",
    "tool_name": "mcp__unfazed__whoami"
  },
  {
    "type": "tool_reference",
    "tool_name": "mcp__unfazed__create_workspace"
  },
  {
    "type": "tool_reference",
    "tool_name": "mcp__unfazed__write_workspace_text_file"
  },
  {
    "type": "tool_reference",
    "tool_name": "mcp__unfazed__create_service_deployment"
  }
]
ReadMcpResourceTool(server: "unfazed", uri: "unfazed://llms.txt")
input
{
  "server": "unfazed",
  "uri": "unfazed://llms.txt"
}
result
{
  "contents": [
    {
      "uri": "unfazed://llms.txt",
      "mimeType": "text/plain; charset=utf-8",
      "text": "# Unfazed\n\nUnfazed is a self-hosted agent deployment sandbox at https://unfazed.dev.\nIt is intended for small local/agent workflows, not high-scale production\nhosting.\n\n## API\n\nPreferred agent endpoint:\n\nhttps://unfazed.dev/mcp\n\nRemote MCP uses Streamable HTTP with OAuth bearer tokens.\nIf security policy or technical limitations do not allow adding the MCP server,\nuse the GraphQL fallback at https://unfazed.dev/query with the same bearer\ntoken. GraphQL introspection is available.\n\nAuth discovery:\n\n- https://unfazed.dev/auth.md\n- POST https://unfazed.dev/agents/register for self-service agent tokens\n- Agent registration may return claim.url. Claiming is optional and only for\n  human management of limits, domains, billing, revocation, or handoff.\n- If the initial claim URL/code is lost, call MCP tool create_claim_attempt or\n  POST https://unfazed.dev/agents/claim-attempts with the agent bearer token.\n\nOAuth discovery:\n\n- https://unfazed.dev/.well-known/oauth-protected-resource/mcp\n- https://unfazed.dev/.well-known/oauth-authorization-server\n- https://unfazed.dev/.well-known/openid-configuration\n\nMCP tools:\n\n- get_service_info\n- whoami\n- create_claim_attempt\n- create_workspace\n- write_workspace_text_file\n- write_workspace_base64_file\n- read_workspace_file\n- list_workspace_files\n- list_workspace_env\n- set_workspace_env\n- set_workspace_secret\n- delete_workspace_env\n- list_deployments\n- list_deployment_events\n- create_static_deployment\n- create_service_deployment\n- get_service_status\n- get_service_logs\n- restart_service\n- stop_service\n\nMCP list/read/log tools are context-safe by default.\n\n- Call whoami first when identity matters. It returns the current actor kind\n  (owner, user, or agent), nested user/agent identity, linked agents for a\n  user, scopes, claim state, and free-tier usage/resource counters when\n  available.\n- list_deployments, list_deployment_events, list_workspace_files, and\n  list_workspace_env return pages with total, limit, offset, has_more, and\n  next_offset. Continue with offset: next_offset while has_more is true.\n- list_deployments, list_deployment_events, and list_workspace_env default to\n  format: \"compact\". Use format: \"full\" only when detailed fields, event\n  metadata, or plaintext non-secret env values are needed.\n- read_workspace_file returns a text chunk. Continue with offset: next_offset\n  while truncated is true.\n- get_service_logs returns recent stdout/stderr tails. Pass tail_bytes only when\n  a larger tail is needed; it is capped at 64 KiB per stream.\n- write_workspace_text_file writes UTF-8 content.\n- write_workspace_base64_file writes bytes read from a local file or binary\n  asset. If your client supports GraphQL multipart upload and that is more\n  convenient, use uploadWorkspaceFile at https://unfazed.dev/query instead.\n\nTypical MCP static deploy flow:\n\n- create_workspace({ name })\n- write_workspace_text_file({ workspace_id, path, text })\n- write_workspace_base64_file({ workspace_id, path, content_base64 }) for local\n  files or binary assets\n- create_static_deployment({ workspace_id, alias, spa_mode: true })\n- share url from the deployment result\n\nTypical MCP service deploy flow:\n\n- create_workspace({ name })\n- write_workspace_text_file({ workspace_id, path: \"package.json\", text })\n- write_workspace_text_file({ workspace_id, path: \"server.ts\", text })\n- write_workspace_text_file again for each additional text source file\n- optional: set_workspace_env({ workspace_id, name, value }) for non-secret config\n- optional: set_workspace_secret({ workspace_id, name, value }) for credentials\n- create_service_deployment({ workspace_id, alias })\n- call url from the deployment result\n- optional: list_deployment_events({ deployment_id, limit: 20, offset: 0 })\n- optional: get_service_status({ deployment_id })\n- optional: get_service_logs({ deployment_id, tail_bytes: 16384 })\n- optional: restart_service({ deployment_id }) or stop_service({ deployment_id })\n\nSecrets are encrypted at rest and never returned by API tools. Use\nlist_workspace_env to see names; secret values are redacted. Env/secrets are\nsnapshotted when a deployment is created, so changing workspace env later does\nnot mutate older deployments.\n\nFor a long-lived supervised service, pass:\n\n- create_service_deployment({ workspace_id, alias, service_mode: \"always_on\", health_path: \"/health\" })\n\nThe always-on watchdog is platform-managed. User code does not need to ping koo.\nIf health_path is set, koo checks that HTTP path from the host and restarts the\nFirecracker VM when it fails. If health_path is omitted, koo checks TCP reachability.\n\nMinimal server.ts:\n\nimport { Hono } from \"hono\";\n\nconst app = new Hono();\napp.get(\"/\", (c) =\u003e c.text(\"hello\"));\n\nexport default app;\n\nCompatibility GraphQL endpoint:\n\nhttps://unfazed.dev/query\n\nAuthentication for /query:\n\n- Authorization: Bearer \u003cKOO_API_TOKEN\u003e\n- or X-Koo-Token: \u003cKOO_API_TOKEN\u003e\n- or Authorization: Bearer \u003cagent_access_token\u003e from /agents/register\n\nUse GraphQL when MCP cannot be added as a trusted server. Do not hand-roll MCP\nJSON-RPC with curl if an agent harness blocks it; use /query instead.\n\nPublished deployments are public and do not require a token.\n\n## Supported Workflows\n\n1. Static deployments\n\nUse this when an agent needs to publish HTML reports, small sites, CSS,\nJavaScript, images, or other assets. Files can be added incrementally.\n\nFlow:\n\n- createWorkspace(input: { name })\n- writeWorkspaceTextFile(input: { workspaceID, path, text })\n- writeWorkspaceBase64File(input: { workspaceID, path, contentBase64 }) for binary or\n  local-file bytes\n- uploadWorkspaceFile(input: { workspaceID, path, file }) when the GraphQL\n  client supports multipart upload\n- writeWorkspaceTextFile or writeWorkspaceBase64File again for each additional\n  file\n- createDeployment(input: { workspaceID, kind: STATIC, alias, spaMode })\n- share deployment.url\n\nStable public URL:\n\nhttps://app.unfazed.dev/\u003calias\u003e/\n\nImmutable public URL:\n\nhttps://unfazed.dev/d/\u003cdeployment-id\u003e/\n\nDeploying again with the same alias moves /a/\u003calias\u003e/ to the newest snapshot.\nOlder /d/\u003cdeployment-id\u003e/ URLs remain immutable.\n\n2. Service deployments\n\nUse this when an agent needs to publish a small HTTP backend or combined app.\nThe workspace must contain server.ts. If it has dependencies, include\npackage.json. Build and runtime happen inside Firecracker VMs with Bun.\n\nFlow:\n\n- createWorkspace(input: { name })\n- writeWorkspaceTextFile(input: { workspaceID, path: \"package.json\", text })\n- writeWorkspaceTextFile(input: { workspaceID, path: \"server.ts\", text })\n- optional: setWorkspaceEnv(input: { workspaceID, name, value })\n- optional: setWorkspaceSecret(input: { workspaceID, name, value })\n- createDeployment(input: { workspaceID, kind: SERVICE, alias })\n- call deployment.url\n\nFor always-on:\n\n- createDeployment(input: { workspaceID, kind: SERVICE, alias, serviceMode: ALWAYS_ON, healthPath: \"/health\" })\n\nserver.ts must default-export one of:\n\n- a fetch(req) function\n- a Hono app\n- an object with fetch\n\nPublic URL:\n\nhttps://app.unfazed.dev/\u003calias\u003e/\n\nRuntime control:\n\n- deploymentEvents(deploymentID, limit) / list_deployment_events returns recent\n  persistent deployment and runtime events such as deployment.created,\n  service.build_succeeded, service.vm_started, service.vm_stopped,\n  service.watchdog_restart, service.restart_requested, and service.stop_requested.\n- serviceStatus(deploymentID) returns live VM health, VM id, in-flight count,\n  restart count, timestamps, and last runtime error.\n- serviceLogs(deploymentID, tailBytes) returns bounded stdout/stderr tails.\n  Secret values configured with setWorkspaceSecret are redacted.\n- restartService(input: { deploymentID }) restarts the service VM and returns\n  status.\n- stopService(input: { deploymentID }) stops the current service VM and\n  always-on supervisor.\n\n3. TypeScript lambdas\n\nregisterFunction(input: { name, file, packageJSON, bunLock }) uploads a TypeScript module.\ninvokeFunction(input: { functionID, payload }) runs it inside a Firecracker VM.\n\nA lambda module must export a default function.\n\n## Minimal Static GraphQL\n\nmutation Create($name: String!) {\n  createWorkspace(input: { name: $name }) {\n    workspace { id name }\n  }\n}\n\nmutation Write($workspaceID: ID!, $path: String!, $text: String!) {\n  writeWorkspaceTextFile(input: { workspaceID: $workspaceID, path: $path, text: $text }) {\n    file { path sizeBytes sha256 updatedAt }\n  }\n}\n\nmutation WriteBase64($workspaceID: ID!, $path: String!, $contentBase64: String!) {\n  writeWorkspaceBase64File(input: { workspaceID: $workspaceID, path: $path, contentBase64: $contentBase64 }) {\n    file { path sizeBytes sha256 updatedAt }\n  }\n}\n\nmutation Upload($workspaceID: ID!, $path: String!, $file: File!) {\n  uploadWorkspaceFile(input: { workspaceID: $workspaceID, path: $path, file: $file }) {\n    file { path sizeBytes sha256 updatedAt }\n  }\n}\n\nmutation Deploy($workspaceID: ID!, $alias: String!) {\n  createDeployment(input: { workspaceID: $workspaceID, kind: STATIC, alias: $alias, spaMode: true }) {\n    deployment { id url alias status filesCount sizeBytes spaMode createdAt }\n  }\n}\n\n## Minimal Service GraphQL\n\nmutation DeployService($workspaceID: ID!, $alias: String!) {\n  createDeployment(input: { workspaceID: $workspaceID, kind: SERVICE, alias: $alias }) {\n    deployment { id url alias kind serviceMode healthPath status filesCount sizeBytes createdAt }\n  }\n}\n\nmutation SetRuntimeConfig($workspaceID: ID!) {\n  setWorkspaceEnv(input: { workspaceID: $workspaceID, name: \"PUBLIC_GREETING\", value: \"hello\" }) {\n    env { name value secret updatedAt }\n  }\n  setWorkspaceSecret(input: { workspaceID: $workspaceID, name: \"API_TOKEN\", value: \"secret\" }) {\n    env { name value secret updatedAt }\n  }\n}\n\nmutation DeployAlwaysOnService($workspaceID: ID!, $alias: String!) {\n  createDeployment(input: { workspaceID: $workspaceID, kind: SERVICE, alias: $alias, serviceMode: ALWAYS_ON, healthPath: \"/health\" }) {\n    deployment { id url alias kind serviceMode healthPath status createdAt }\n  }\n}\n\nquery DeploymentEvents($deploymentID: ID!) {\n  deploymentEventsPage(input: { deploymentID: $deploymentID, limit: 20, offset: 0 }) {\n    total\n    hasMore\n    nextOffset\n    items {\n      type\n      rawType\n      severity\n      message\n      vmID\n      createdAt\n    }\n  }\n}\n\nquery ServiceStatus($deploymentID: ID!) {\n  serviceStatus(deploymentID: $deploymentID) {\n    deploymentID\n    running\n    health\n    vmID\n    inFlight\n    restartCount\n    lastError\n  }\n}\n\nquery ServiceLogs($deploymentID: ID!) {\n  serviceLogs(deploymentID: $deploymentID, tailBytes: 16384) {\n    stdout\n    stderr\n    truncated\n    stdoutBytes\n    stderrBytes\n    tailBytes\n  }\n}\n\nmutation RestartService($deploymentID: ID!) {\n  restartService(input: { deploymentID: $deploymentID }) {\n    deploymentID\n    running\n    health\n    vmID\n    restartCount\n  }\n}\n\n## App State (durable per-app storage)\n\nA SERVICE deployment with an alias gets a durable key/value store keyed by the\napp's stable alias, so data survives redeploys and VM restarts. It is the place\nto keep app data (todo lists, counters, user records). Two env vars are injected\ninto the running VM at boot (only for an aliased SERVICE):\n\n- KOO_STATE_URL: base URL of this app's state API (e.g. http://10.254.0.1:9000).\n- KOO_STATE_TOKEN: per-alias bearer for every state call (also used for streams).\n\nThe state HTTP API (call it from inside server.ts; every call carries\nAuthorization: Bearer ${KOO_STATE_TOKEN}):\n\n- GET    ${KOO_STATE_URL}/state/\u003ckey\u003e   -\u003e 200 with the raw stored bytes, or 404.\n- PUT    ${KOO_STATE_URL}/state/\u003ckey\u003e   body = raw bytes -\u003e 204. (\u003c=64 KiB/value.)\n- DELETE ${KOO_STATE_URL}/state/\u003ckey\u003e   -\u003e 204.\n- GET    ${KOO_STATE_URL}/state         -\u003e 200 {\"keys\":[...]} (key names only).\n\nStore JSON by writing JSON.stringify(value) and reading JSON.parse(text). Keys\nare app-chosen strings. Alias-less deployments get neither var; fall back to\nin-memory only for local dev.\n\nExample (persist a list across requests):\n\n  const URL = process.env.KOO_STATE_URL, TOK = process.env.KOO_STATE_TOKEN;\n  const auth = { authorization: `Bearer ${TOK}` };\n  async function load() {\n    const r = await fetch(`${URL}/state/todos`, { headers: auth });\n    return r.status === 404 ? [] : JSON.parse(await r.text());\n  }\n  async function save(v) {\n    await fetch(`${URL}/state/todos`, { method: \"PUT\",\n      headers: { ...auth, \"content-type\": \"application/json\" },\n      body: JSON.stringify(v) });\n  }\n\nAgents can also seed or inspect an app's state out-of-band with the MCP tools\nget_app_state / set_app_state / list_app_state / delete_app_state (by alias).\n\n## Apps can be MCP servers\n\nA SERVICE app can itself be a remote MCP server: serve Streamable HTTP MCP from\nserver.ts (handle POST with JSON-RPC: initialize, tools/list, tools/call) and\nkeep data in App State above. Deploy it always_on with an alias and connect an\nMCP client (e.g. Claude Desktop) to the public URL https://app.unfazed.dev/\u003calias\u003e/.\nTool results may include an embedded resource of mimeType text/html (a ui://\nresource) that MCP-Apps-capable hosts render as an interactive card; plain hosts\nshow the text content.\n\n## Realtime Streams\n\nA SERVICE deployment with an alias gets a per-alias durable-streams primitive\nfor pushing realtime updates (UI patches, progress, events) to browsers. It is\ninjected as runtime env, alongside the app-state vars:\n\n- KOO_STREAMS_URL: base URL of this app's stream producer surface.\n- KOO_STATE_TOKEN: the per-alias bearer used for both state and streams writes.\n\nAlias-less deployments get neither (streams require a stable alias).\n\nThe app WRITES (producer, server-side only):\n\n- Create a stream once: PUT KOO_STREAMS_URL/\u003cpath\u003e\n- Append a message: POST KOO_STREAMS_URL/\u003cpath\u003e\n- Both carry Authorization: Bearer ${KOO_STATE_TOKEN}.\n- Use Content-Type: application/json for JSON patches; the body is one JSON\n  value or an array of values, stored as discrete messages.\n- Writes are namespaced to the app's own alias automatically; the app only ever\n  uses its own stream names and can never touch another app's streams.\n\nBrowsers SUBSCRIBE (consumer, read-only) at the app-relative path:\n\n- _streams/\u003cpath\u003e under https://app.unfazed.dev/\u003calias\u003e/\n- Catch-up history: GET _streams/\u003cpath\u003e?offset=-1\n- Live SSE: new EventSource(\"_streams/\u003cpath\u003e?offset=-1\u0026live=sse\"), reading\n  \"data\" events; JSON streams deliver each event as a one-element JSON array.\n- Live long-poll: GET _streams/\u003cpath\u003e?offset=\u003coffset\u003e\u0026live=long-poll\n- Reads are resumable by the Stream-Next-Offset returned on each response.\n- The public path is read-only: only GET and HEAD are allowed (writes are\n  app-only, via the bearer token); other methods return 405.\n\nExample: an app appends UI patches to its \"ui\" stream with\nPOST KOO_STREAMS_URL/ui, and its HTML page renders them live by subscribing to\n_streams/ui over SSE. See examples/streams-ui.ts.\n\nStreams are in-memory for now: they do not survive a koo restart. Treat them as\nlive transport, and keep durable state in app state or your own store.\n\n## Constraints\n\n- Deployment kinds are STATIC and SERVICE.\n- Workspace file paths must be relative and cannot contain ..\n- Aliases may contain letters, digits, - and _ only.\n- The GraphQL file API currently accepts text content.\n- Do not put secrets into static deployment files.\n- SERVICE deployments require server.ts and run as small Bun HTTP services.\n- Runtime env names must be safe env identifiers. Reserved names such as KOO_*,\n  HOST, PORT, PATH, LD_*, DYLD_*, and BUN_* are rejected.\n- setWorkspaceSecret/set_workspace_secret values are encrypted at rest and are\n  injected only into SERVICE runtime VMs.\n- SERVICE serviceMode defaults to ON_DEMAND. ALWAYS_ON starts the VM immediately,\n  keeps it warm, restarts it after watchdog failure, and replaces older always-on\n  VMs when the same alias is redeployed.\n- Self-registered agent tokens are limited to 5 workspaces and 5 SERVICE\n  deployments. STATIC deployments are not capped.\n"
    }
  ]
}

Good. Let me load the static deployment tool and then build and deploy in parallel.

ToolSearch(query: "select:mcp__unfazed__create_static_deplo…", max_results: 1)
input
{
  "query": "select:mcp__unfazed__create_static_deployment",
  "max_results": 1
}
result
[
  {
    "type": "tool_reference",
    "tool_name": "mcp__unfazed__create_static_deployment"
  }
]

Now I'll create the workspace and write the HTML file.

create_workspace(name: "habit-tracker")
input
{
  "name": "habit-tracker"
}
result
{
  "created_at": "2026-06-12T04:41:24Z",
  "id": "f13a7f69-07a4-447f-87eb-24375d006f7f",
  "name": "habit-tracker",
  "updated_at": "2026-06-12T04:41:24Z"
}
write_workspace_text_file(workspace_id: "f13a7f69-07a4-447f-87eb-24375d006f7f", path: "index.html"…)
input
{
  "workspace_id": "f13a7f69-07a4-447f-87eb-24375d006f7f",
  "path": "index.html",
  "text": "\u003c!DOCTYPE html\u003e\n\u003chtml lang=\"en\"\u003e\n\u003chead\u003e\n  \u003cmeta charset=\"UTF-8\"\u003e\n  \u003cmeta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\"\u003e\n  \u003ctitle\u003eHabits\u003c/title\u003e\n  \u003cstyle\u003e\n    *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }\n\n    body {\n      font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif;\n      background: #fff;\n      color: #111;\n      min-height: 100vh;\n      padding: 2.5rem 1.5rem 5rem;\n      display: flex;\n      justify-content: center;\n    }\n\n    @media (max-width: 480px) {\n      body { padding: 1.5rem 1rem 4rem; }\n    }\n\n    .app { width: 100%; max-width: 680px; }\n\n    .top {\n      display: flex;\n      align-items: center;\n      justify-content: space-between;\n      margin-bottom: 2rem;\n      gap: 1rem;\n    }\n\n    h1 {\n      font-size: 1.375rem;\n      font-weight: 700;\n      letter-spacing: -0.025em;\n    }\n\n    .week-nav {\n      display: flex;\n      align-items: center;\n      gap: 0.375rem;\n      flex-shrink: 0;\n    }\n\n    .nav-btn, .today-btn {\n      background: none;\n      border: 1px solid #e4e4e4;\n      border-radius: 5px;\n      cursor: pointer;\n      font-size: 0.75rem;\n      color: #888;\n      padding: 0.2rem 0.55rem;\n      line-height: 1.5;\n      font-family: inherit;\n      transition: border-color 0.12s, color 0.12s;\n    }\n    .nav-btn:hover, .today-btn:hover { border-color: #bbb; color: #333; }\n\n    .week-label {\n      font-size: 0.75rem;\n      color: #999;\n      min-width: 110px;\n      text-align: center;\n    }\n\n    /* Grid */\n    .row {\n      display: grid;\n      grid-template-columns: 1fr repeat(7, 38px) 50px;\n      align-items: stretch;\n    }\n\n    @media (max-width: 500px) {\n      .row { grid-template-columns: 1fr repeat(7, 30px) 40px; }\n    }\n\n    /* Header */\n    .hdr-day {\n      text-align: center;\n      padding-bottom: 0.6rem;\n      display: flex;\n      flex-direction: column;\n      align-items: center;\n      gap: 2px;\n    }\n    .hdr-day-name {\n      font-size: 0.58rem;\n      font-weight: 700;\n      text-transform: uppercase;\n      letter-spacing: 0.08em;\n      color: #c8c8c8;\n    }\n    .hdr-day-num {\n      font-size: 0.65rem;\n      font-weight: 400;\n      color: #d4d4d4;\n    }\n    .hdr-day.is-today .hdr-day-name { color: #555; }\n    .hdr-day.is-today .hdr-day-num  { color: #111; font-weight: 700; }\n\n    .hdr-streak {\n      text-align: center;\n      padding-bottom: 0.6rem;\n      font-size: 0.58rem;\n      font-weight: 700;\n      text-transform: uppercase;\n      letter-spacing: 0.08em;\n      color: #d97706;\n      display: flex;\n      align-items: flex-end;\n      justify-content: center;\n    }\n\n    /* Habit rows */\n    .habit-row { border-top: 1px solid #f2f2f2; }\n    .habit-row:hover .del-btn { opacity: 1; }\n\n    .name-cell {\n      display: flex;\n      align-items: center;\n      gap: 0.3rem;\n      padding: 0.6rem 0.5rem 0.6rem 0;\n      min-width: 0;\n    }\n\n    .habit-name {\n      flex: 1;\n      min-width: 0;\n      font-size: 0.875rem;\n      line-height: 1.35;\n      white-space: nowrap;\n      overflow: hidden;\n      text-overflow: ellipsis;\n      outline: none;\n      cursor: text;\n      border-bottom: 1px solid transparent;\n      padding-bottom: 1px;\n    }\n    .habit-name:focus {\n      white-space: normal;\n      overflow: visible;\n      border-bottom-color: #ccc;\n    }\n\n    .del-btn {\n      flex-shrink: 0;\n      opacity: 0;\n      background: none;\n      border: none;\n      cursor: pointer;\n      color: #ddd;\n      font-size: 0.9rem;\n      line-height: 1;\n      padding: 0;\n      width: 14px;\n      transition: color 0.1s, opacity 0.1s;\n    }\n    .del-btn:hover { color: #e44; }\n\n    .check-cell {\n      display: flex;\n      align-items: center;\n      justify-content: center;\n      padding: 0.5rem 0;\n    }\n    .check-cell.is-today { background: #fffcf0; }\n\n    .chk {\n      width: 20px;\n      height: 20px;\n      border: 1.5px solid #e0e0e0;\n      border-radius: 4px;\n      cursor: pointer;\n      display: flex;\n      align-items: center;\n      justify-content: center;\n      transition: background 0.1s, border-color 0.1s;\n      flex-shrink: 0;\n    }\n    @media (max-width: 500px) {\n      .chk { width: 17px; height: 17px; border-radius: 3px; }\n    }\n    .chk:hover   { border-color: #aaa; }\n    .chk.is-today { border-color: #c0b070; }\n    .chk.on      { background: #111; border-color: #111; }\n    .chk.on::after {\n      content: '';\n      display: block;\n      width: 9px;\n      height: 5px;\n      border-left: 1.5px solid #fff;\n      border-bottom: 1.5px solid #fff;\n      transform: rotate(-45deg) translateY(-1px);\n    }\n    @media (max-width: 500px) {\n      .chk.on::after { width: 7px; height: 4px; }\n    }\n\n    .streak-cell {\n      display: flex;\n      align-items: center;\n      justify-content: center;\n      font-size: 0.72rem;\n      font-weight: 600;\n      color: #d97706;\n      padding: 0.5rem 0;\n    }\n    .streak-cell.zero { color: #e0e0e0; font-weight: 400; }\n\n    /* Add area */\n    .add-area { border-top: 1px solid #f2f2f2; padding-top: 0.75rem; }\n\n    .add-form { display: none; align-items: center; gap: 0.5rem; margin-bottom: 0.5rem; }\n    .add-form.open { display: flex; }\n\n    .add-input {\n      flex: 1;\n      font-size: 0.875rem;\n      font-family: inherit;\n      border: none;\n      border-bottom: 1.5px solid #ddd;\n      outline: none;\n      background: none;\n      padding: 0.25rem 0;\n      color: #111;\n    }\n    .add-input:focus { border-bottom-color: #555; }\n\n    .add-ok {\n      font-family: inherit;\n      background: #111;\n      color: #fff;\n      border: none;\n      border-radius: 4px;\n      font-size: 0.75rem;\n      padding: 0.3rem 0.7rem;\n      cursor: pointer;\n      transition: background 0.1s;\n    }\n    .add-ok:hover { background: #333; }\n\n    .add-cancel {\n      font-family: inherit;\n      background: none;\n      border: none;\n      font-size: 0.75rem;\n      color: #bbb;\n      cursor: pointer;\n      padding: 0.3rem 0;\n      transition: color 0.1s;\n    }\n    .add-cancel:hover { color: #666; }\n\n    .add-trigger {\n      font-family: inherit;\n      background: none;\n      border: none;\n      cursor: pointer;\n      font-size: 0.8125rem;\n      color: #bbb;\n      display: flex;\n      align-items: center;\n      gap: 0.4rem;\n      padding: 0;\n      transition: color 0.1s;\n    }\n    .add-trigger:hover { color: #555; }\n\n    .plus-icon {\n      width: 18px;\n      height: 18px;\n      border: 1.5px solid currentColor;\n      border-radius: 4px;\n      display: flex;\n      align-items: center;\n      justify-content: center;\n      font-size: 0.9rem;\n      line-height: 1;\n    }\n\n    .empty-msg {\n      padding: 2.5rem 0 1.5rem;\n      text-align: center;\n      font-size: 0.8125rem;\n      color: #ccc;\n      grid-column: 1 / -1;\n    }\n  \u003c/style\u003e\n\u003c/head\u003e\n\u003cbody\u003e\n  \u003cdiv class=\"app\"\u003e\n    \u003cdiv class=\"top\"\u003e\n      \u003ch1\u003eHabits\u003c/h1\u003e\n      \u003cdiv class=\"week-nav\"\u003e\n        \u003cbutton class=\"nav-btn\" id=\"prev\"\u003e\u0026#8249;\u003c/button\u003e\n        \u003cspan class=\"week-label\" id=\"week-label\"\u003e\u003c/span\u003e\n        \u003cbutton class=\"nav-btn\" id=\"next\"\u003e\u0026#8250;\u003c/button\u003e\n        \u003cbutton class=\"today-btn\" id=\"today\"\u003eToday\u003c/button\u003e\n      \u003c/div\u003e\n    \u003c/div\u003e\n\n    \u003cdiv class=\"tracker\"\u003e\n      \u003cdiv class=\"row\" id=\"hdr\"\u003e\n        \u003cdiv\u003e\u003c/div\u003e\n        \u003c!-- day headers injected --\u003e\n        \u003cdiv class=\"hdr-streak\"\u003eStreak\u003c/div\u003e\n      \u003c/div\u003e\n      \u003cdiv id=\"list\"\u003e\u003c/div\u003e\n      \u003cdiv class=\"add-area\"\u003e\n        \u003cdiv class=\"add-form\" id=\"add-form\"\u003e\n          \u003cinput class=\"add-input\" id=\"add-input\" type=\"text\" placeholder=\"Name this habit…\" maxlength=\"60\" autocomplete=\"off\"\u003e\n          \u003cbutton class=\"add-ok\" id=\"add-ok\"\u003eAdd\u003c/button\u003e\n          \u003cbutton class=\"add-cancel\" id=\"add-cancel\"\u003eCancel\u003c/button\u003e\n        \u003c/div\u003e\n        \u003cbutton class=\"add-trigger\" id=\"add-trigger\"\u003e\n          \u003cspan class=\"plus-icon\"\u003e+\u003c/span\u003e\n          Add habit\n        \u003c/button\u003e\n      \u003c/div\u003e\n    \u003c/div\u003e\n  \u003c/div\u003e\n\n  \u003cscript\u003e\n    const DAYS = ['Mon','Tue','Wed','Thu','Fri','Sat','Sun'];\n    let habits = [], woff = 0;\n\n    function dk(d) {\n      return d.getFullYear() + '-' +\n        String(d.getMonth()+1).padStart(2,'0') + '-' +\n        String(d.getDate()).padStart(2,'0');\n    }\n\n    function todayKey() { return dk(new Date()); }\n\n    function getMonday(off) {\n      const t = new Date();\n      t.setHours(0,0,0,0);\n      const dow = t.getDay();\n      t.setDate(t.getDate() + (dow===0 ? -6 : 1-dow) + off*7);\n      return t;\n    }\n\n    function weekDates() {\n      const m = getMonday(woff);\n      return Array.from({length:7}, (_,i) =\u003e {\n        const d = new Date(m);\n        d.setDate(m.getDate()+i);\n        return d;\n      });\n    }\n\n    function calcStreak(dates) {\n      if (!dates || !dates.length) return 0;\n      const s = new Set(dates);\n      const t = new Date();\n      t.setHours(0,0,0,0);\n      const cursor = new Date(t);\n      if (!s.has(dk(t))) cursor.setDate(cursor.getDate()-1);\n      let n = 0;\n      while (s.has(dk(cursor))) {\n        n++;\n        cursor.setDate(cursor.getDate()-1);\n      }\n      return n;\n    }\n\n    function load() {\n      try { habits = JSON.parse(localStorage.getItem('ht-v1') || '[]'); }\n      catch { habits = []; }\n    }\n\n    function save() {\n      localStorage.setItem('ht-v1', JSON.stringify(habits));\n    }\n\n    function fmtWeek(dates) {\n      const MO = ['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec'];\n      const s = dates[0], e = dates[6];\n      return s.getMonth() === e.getMonth()\n        ? MO[s.getMonth()] + ' ' + s.getDate() + '–' + e.getDate()\n        : MO[s.getMonth()] + ' ' + s.getDate() + ' – ' + MO[e.getMonth()] + ' ' + e.getDate();\n    }\n\n    function esc(s) {\n      return s.replace(/\u0026/g,'\u0026amp;').replace(/\u003c/g,'\u0026lt;').replace(/\u003e/g,'\u0026gt;').replace(/\"/g,'\u0026quot;');\n    }\n\n    function renderHdr(dates) {\n      const hdr = document.getElementById('hdr');\n      while (hdr.children.length \u003e 2) hdr.removeChild(hdr.children[1]);\n      const streakEl = hdr.lastElementChild;\n      const today = todayKey();\n      dates.forEach((d, i) =\u003e {\n        const el = document.createElement('div');\n        el.className = 'hdr-day' + (dk(d)===today ? ' is-today' : '');\n        el.innerHTML = '\u003cspan class=\"hdr-day-name\"\u003e' + DAYS[i] + '\u003c/span\u003e' +\n          '\u003cspan class=\"hdr-day-num\"\u003e' + d.getDate() + '\u003c/span\u003e';\n        hdr.insertBefore(el, streakEl);\n      });\n    }\n\n    function renderList(dates) {\n      const today = todayKey();\n      const list = document.getElementById('list');\n      list.innerHTML = '';\n\n      if (!habits.length) {\n        const msg = document.createElement('p');\n        msg.className = 'empty-msg';\n        msg.textContent = 'No habits yet — add one below';\n        list.appendChild(msg);\n        return;\n      }\n\n      habits.forEach(h =\u003e {\n        const str = calcStreak(h.d);\n        const row = document.createElement('div');\n        row.className = 'row habit-row';\n\n        // Name\n        const nc = document.createElement('div');\n        nc.className = 'name-cell';\n        nc.innerHTML =\n          '\u003cspan class=\"habit-name\" contenteditable=\"true\" spellcheck=\"false\" data-id=\"' + esc(h.id) + '\"\u003e' + esc(h.name) + '\u003c/span\u003e' +\n          '\u003cbutton class=\"del-btn\" data-id=\"' + esc(h.id) + '\" aria-label=\"Remove\"\u003e\u0026#215;\u003c/button\u003e';\n        row.appendChild(nc);\n\n        // Day checkboxes\n        dates.forEach(d =\u003e {\n          const key = dk(d);\n          const on = (h.d||[]).includes(key);\n          const isToday = key === today;\n          const cc = document.createElement('div');\n          cc.className = 'check-cell' + (isToday ? ' is-today' : '');\n          const chk = document.createElement('div');\n          chk.className = 'chk' + (on ? ' on' : '') + (isToday ? ' is-today' : '');\n          chk.dataset.id = h.id;\n          chk.dataset.date = key;\n          chk.setAttribute('role','checkbox');\n          chk.setAttribute('aria-checked', on ? 'true' : 'false');\n          cc.appendChild(chk);\n          row.appendChild(cc);\n        });\n\n        // Streak\n        const sc = document.createElement('div');\n        sc.className = 'streak-cell' + (str===0 ? ' zero' : '');\n        sc.textContent = str ? str + 'd' : '—';\n        row.appendChild(sc);\n\n        list.appendChild(row);\n      });\n    }\n\n    function render() {\n      const dates = weekDates();\n      document.getElementById('week-label').textContent = fmtWeek(dates);\n      renderHdr(dates);\n      renderList(dates);\n    }\n\n    // Checkbox toggle \u0026 delete\n    document.getElementById('list').addEventListener('click', e =\u003e {\n      const chk = e.target.closest('.chk');\n      if (chk) {\n        const h = habits.find(x =\u003e x.id === chk.dataset.id);\n        if (!h) return;\n        h.d = h.d || [];\n        const i = h.d.indexOf(chk.dataset.date);\n        i \u003e= 0 ? h.d.splice(i,1) : h.d.push(chk.dataset.date);\n        save(); render(); return;\n      }\n      const del = e.target.closest('.del-btn');\n      if (del) {\n        habits = habits.filter(x =\u003e x.id !== del.dataset.id);\n        save(); render();\n      }\n    });\n\n    // Inline name editing\n    document.getElementById('list').addEventListener('blur', e =\u003e {\n      const n = e.target.closest('.habit-name');\n      if (!n) return;\n      const h = habits.find(x =\u003e x.id === n.dataset.id);\n      if (!h) return;\n      const v = n.textContent.trim();\n      if (v) { h.name = v; save(); }\n      else n.textContent = h.name;\n    }, true);\n\n    document.getElementById('list').addEventListener('keydown', e =\u003e {\n      if (e.key === 'Enter' \u0026\u0026 e.target.closest('.habit-name')) {\n        e.preventDefault();\n        e.target.blur();\n      }\n    });\n\n    // Week navigation\n    document.getElementById('prev').addEventListener('click', () =\u003e { woff--; render(); });\n    document.getElementById('next').addEventListener('click', () =\u003e { woff++; render(); });\n    document.getElementById('today').addEventListener('click', () =\u003e { woff=0; render(); });\n\n    // Add habit\n    const trigger = document.getElementById('add-trigger');\n    const form    = document.getElementById('add-form');\n    const inp     = document.getElementById('add-input');\n\n    function openForm()  { form.classList.add('open'); trigger.style.display='none'; inp.value=''; inp.focus(); }\n    function closeForm() { form.classList.remove('open'); trigger.style.display=''; }\n    function confirmForm() {\n      const v = inp.value.trim();\n      if (!v) { closeForm(); return; }\n      habits.push({ id: Date.now().toString(36) + Math.random().toString(36).slice(2,6), name: v, d: [] });\n      save(); render(); closeForm();\n    }\n\n    trigger.addEventListener('click', openForm);\n    document.getElementById('add-ok').addEventListener('click', confirmForm);\n    document.getElementById('add-cancel').addEventListener('click', closeForm);\n    inp.addEventListener('keydown', e =\u003e {\n      if (e.key === 'Enter') confirmForm();\n      if (e.key === 'Escape') closeForm();\n    });\n\n    load();\n    render();\n  \u003c/script\u003e\n\u003c/body\u003e\n\u003c/html\u003e\n"
}
result
{
  "path": "index.html",
  "sha256": "62fb78c24a030e9be9938b3beec223b94f43bbefbc3672ac51c97e3dd373e266",
  "size": "14.5 KB",
  "size_bytes": 14845,
  "updated_at": "2026-06-12T04:42:28Z"
}
create_static_deployment(workspace_id: "f13a7f69-07a4-447f-87eb-24375d006f7f", …)
input
{
  "workspace_id": "f13a7f69-07a4-447f-87eb-24375d006f7f",
  "alias": "habit-tracker",
  "spa_mode": false
}
result
{
  "alias": "habit-tracker",
  "created_at": "2026-06-12T04:42:32Z",
  "files_count": 1,
  "id": "b5c4f9ac-34ba-4e59-8c99-97129e8b5f5b",
  "kind": "STATIC",
  "service_mode": "ON_DEMAND",
  "size": "14.5 KB",
  "size_bytes": 14845,
  "spa_mode": false,
  "status": "READY",
  "url": "https://app.unfazed.dev/habit-tracker/",
  "workspace_id": "f13a7f69-07a4-447f-87eb-24375d006f7f"
}

Live at https://app.unfazed.dev/habit-tracker

Here's what it has:

  • Rows — one per habit; click + Add habit to create, click the name to edit it inline, hover to reveal the × delete button
  • Mon–Sun checkboxes — dark fill when checked, today's column is subtly tinted yellow so the current day stands out
  • Streak counter — counts consecutive days back from today (shown in amber as Nd; dashes out at zero)
  • Week navigation‹ › arrows to browse past/future weeks, "Today" snaps back
  • localStorage — all habits and check states persist across sessions under key ht-v1
result
app.unfazed.dev/habit-tracker open (opens in new tab)