This site is driven by two keys, like the machine it came from. Reading: j and k scroll, d and u move half a page, gg goes to the top, G to the bottom, H and L switch windows, ? opens the help. Space is the Neovim leader and handles content: Space then h home, r research, p projects, g gear, a about, / tags, or Space then a digit to jump to that window. Home is the tmux prefix and handles windows: Home then c opens a terminal, Home then & closes a window, Home then space goes to the next one. While focus is on the keyboard, h j k l move between keys and Enter opens one.

ResearchR AboutA Gear — G for gear — keyboard, terminal, editor, homelab.G
    ProjectsP Tags — / as in vim: search./
    ×
    Menu

    Marcom: A Marketing Communications Material Dashboard

    An internal dashboard for tracking marketing communications material by brand, cluster, feature, and type — with expiry reminders and realtime updates. Next.js 15 + React 19 on the front, Bun + Elysia behind it.

    Marcom is an internal dashboard for managing marketing communications materi (material): each one carries a brand, a cluster, a feature, a type, a start date and an end date. The problem it solves is simple but miserable by hand — material that is about to expire has to surface before it expires, not after.

    This repo is the front end (Next.js). The back end lives in marcom_services — Bun + Elysia + MySQL — and both are brought up together by an orchestrator repo using git submodules and Docker Compose.

    Four axes, one table

    Everything filterable in the dashboard comes off the same four master-data dimensions:

    // constants/filter-options.ts
    export type FilterKey = "brand" | "cluster" | "fitur" | "status" | "jenis";

    That shows up in the code as master-data.store.ts holding the four lists and filter-materi.store.ts holding the current selection. The material table never filters anything itself — it reads whatever useFilteredMateri returns. When a new dimension was requested, only those two places changed.

    Date ranges get their own presets, because 90% of the usage only ever needs four of them:

    export enum PresetDate {
      ALL_TIME = "All time",
      THIS_MONTH = "Bulan ini",
      LAST_MONTH = "Bulan lalu",
      THIS_YEAR = "Tahun ini",
      CUSTOM = "Pilih tanggal tertentu",
    }

    Expiry reminders

    This is the part that got rethought the most. The backend has an expiryService that pulls material approaching its end date, and the condition is written straight into SQL:

    WHERE COALESCE(m.notification_count, 0) < 1
      AND m.end_date BETWEEN CURDATE() AND DATE_ADD(CURDATE(), INTERVAL ? DAY)

    notification_count is what stops people being mailed the same reminder every day until the material actually expires. The cron isn’t a separate process either — it’s an HTTP endpoint in cronController guarded by cronAuth, so scheduling can be handed to whatever scheduler already exists and the service stays one binary.

    Realtime, but only where it earns it

    Socket.IO runs on its own port (SOCKET_IO, default 5002), separate from the HTTP API (PORT, default 5001). The front end has exactly one useSocket, used so an open material list updates when someone else saves a change. Everything else stays ordinary requests through TanStack Query — not everything needs to be a stream.

    Front-end layout

    AreaContents
    app/dashboardmaterial table, stats, chart, filters (material + date), material form [id]
    app/master-dataCRUD for brand / cluster / feature / type
    app/usersuser management
    stores/Zustand: auth, materi, filter, master data, activity log
    hooks/useFilteredMateri, useStatsData, useDateRange, useDebounce, useAutoLogout, useSocket

    useAutoLogout exists because this dashboard tends to be left open on a shared screen. An idle session drops itself rather than waiting for the token to expire.

    What it taught me

    Splitting front end and back end into two repos and rejoining them with submodules gives each its own release cycle, but the price is that every newcomer has to remember --recurse-submodules. Forget it and the folder is empty and Compose fails with a message that explains nothing. It is still the one step written in bold in the README.

    id en
    rss gh in