Black Friday 2025
Meta Box

How to Create a Dynamic Timetable with Meta Box

A well-organized timetable makes it much easier for visitors to find the right class, session, or teacher at a glance. But creating a timetable that is both dynamic and easy to manage in WordPress can be challenging.

In this tutorial, we’ll show you how to build a dynamic timetable in WordPress with Meta Box. You’ll learn how to display schedules in a clear, visual layout and let users filter sessions by teacher with just one click.


This approach works especially well for schools, training centers, language academies, fitness clubs, and yoga studios, or any website that needs to display schedules in a more flexible and user-friendly way.

ResultLet’s see what tools we need for this tutorial.

Video Version

Before Getting Started

In this tutorial, we’ll create a custom post type to manage all courses in the timetable. Each course will contain custom fields for schedule details such as start time, end time, weekday, and room. Meanwhile, teachers will be managed as a custom taxonomy so we can filter courses by teacher more easily.

So, I recommend using Meta Box AlO, which includes the framework and all extensions we need for this tutorial

  • MB Admin Columns: display important course information such as start time, end time, schedule day, room, and assigned teacher directly in the admin dashboard. This makes course management much easier;
  • MB Custom Post Type: create a custom post type for courses, as well as a custom taxonomy for teachers;
  • MB Views: build the timetable template and display course data on the frontend;
  • MB Builder: create custom fields for storing all course schedule information.

Step 1 Create a Custom Post Type, a Taxonomy, and Fields

For this tutorial, I’ve already prepared a custom post type for courses. Each course is saved as a post in this post type, making it easier to manage and display later in the timetable.

Post type
I’ve also created a taxonomy for related teachers, as you can see here.

Taxonomy

Make sure to choose the associated post type. And in the Advanced tab, enable the option to display the taxonomy in the admin columns. This option is available when the MB Admin Columns extension is activated, and it allows you to display a taxonomy column on the post type listing screen.

Show admin column for taxonomy

And then, create some terms for this taxonomy. In my case, these terms represent different teachers.

terms

For convenience, I’ve created the field group in advance. It will include some fields like course color, classroom, course day, start time, and end time. For any field you want to manage more easily, you can enable this option to display it directly in the admin columns. Note that you can create any field you need for the courses.

Custom fields

Once done, move to the Settings tab and set the Location as Post Type. Choose Course to ensure that the custom fields appear only for course posts.

Set location

Now, in the post editor of the Set locationpost type, we’ll see all custom fields along with the taxonomy section, where we can assign the teacher.

Just fill in all the information.

Fields and taxonomy in post editor

These are some sample courses that I created. All the needed information saved in custom fields appears on the admin dashboard. The teacher, as well as the taxonomy, is also displayed.

Admin columns

Step 2 Show Course Timetable

We first need to make sure that our courses are displayed correctly on the frontend. So, we create a new page specifically for the course listing. This page will act as the main timetable page for users to browse.

Now, go to Meta Box, Views, and create a new template specifically for this page. With MB Views, you can add some lines of code directly in the Template tab to query and retrieve all posts from the Course post type.

MB Views makes this step very convenient; you don’t need to write all the codes manually.

To display the course information from your custom fields, simply click the Insert Field button and select any fields from the right sidebar.

Insert fields

After inserting all the fields, move to the Settings section to set where this template will appear. Set the Type to Singular, and choose the page you created for the course listings.

 Template location

On the frontend, you’ll see all the course information displayed. At this stage, it appears as a basic list without any styling.

Basic information
To transform it into a timetable layout, we need to add more code in the Template tab, along with some CSS and JavaScript.

<script src="https://cdnjs.cloudflare.com/ajax/libs/jquery/3.3.1/jquery.min.js"></script>
{% set courses = mb.get_posts({ post_type: 'course', posts_per_page: -1, orderby: 'title', order: 'ASC'}) %}
{% set course_data = [] %}
{% set teacher_map = {} %}
{% for post in courses %}
    {% set teacher_slugs = [] %}
    {% set teachers = mb.get_the_terms(post.ID, 'teacher') %}
    {% if teachers %}
        {% for t in teachers %}
            {% set teacher_slugs = teacher_slugs|merge([t.slug]) %}
            {% set teacher_map = teacher_map|merge({ (t.slug): t.name }) %}
        {% endfor %}
    {% endif %}
    {% set course_data = course_data|merge([{
        id: post.ID,
        title: post.post_title,
        description: post.content|striptags|slice(0, 120),
        day: post.course_day,
        start: post.start_time,
        end: post.end_time,
        color: post.course_color,
        room: post.classroom.value,
        teachers: teacher_slugs
    }]) %}
{% endfor %}
<div id="mb-course-schedule"
    data-courses='{{ course_data|json_encode() }}'
    data-teachers='{{ teacher_map|json_encode() }}'>
</div>

Explanation:

<script src="https://cdnjs.cloudflare.com/ajax/libs/jquery/3.3.1/jquery.min.js"></script>

First, this one is to load the jQuery library because the JavaScript code later will use it to render the timetable and handle the filtering interactions.

{% set course_data = [] %}
{% set teacher_map = {} %}

We initialize two empty arrays. The first array stores all processed course data. The second array stores teacher data, which will later be used for filtering.

Then, for each course, we retrieve all terms from the Teacher taxonomy assigned to that course.

{% set teacher_slugs = [] %}
{% set teachers = mb.get_the_terms(post.ID, 'teacher') %}

The following code adds all teacher slugs into an array and also creates a key-value map containing both teacher slug and teacher name. We’ll use this data later to generate the teacher filter buttons.

{% set teacher_slugs = teacher_slugs|merge([t.slug]) %}
{% set teacher_map = teacher_map|merge({ (t.slug): t.name }) %}

After that, each loop creates one course object and adds it into the main course array. Later, JavaScript will use this array to render the timetable.

{% set course_data = course_data|merge([{
    id: post.ID,
    title: post.post_title,
    description: post.content|striptags|slice(0, 120),
    day: post.course_day,
    start: post.start_time,
    end: post.end_time,
    color: post.course_color,
    room: post.classroom.value,
    teachers: teacher_slugs
}]) %}

And we pass both course data and teacher data as JSON through HTML attributes. This allows JavaScript to access all necessary data directly from the frontend.

data-courses='{{ course_data|json_encode() }}'
data-teachers='{{ teacher_map|json_encode() }}'>

Then switch to the CSS tab to style the timetable.

css tab

#mb-course-schedule {
    width: 100%;
    font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
}

/* ── Filter ── */
.schedule-filter {
    margin-bottom: 16px;
    border-bottom: 2px solid #e0e0e0;
    padding-bottom: 0;
}
.schedule-filter button {
    border: none;
    border-bottom: 3px solid transparent;
    background: transparent;
    padding: 8px 16px;
    margin-bottom: -2px;
    margin-right: 4px;
    cursor: pointer;
    font-size: 17px;
    font-weight: 500;
    color: #666;
    border-radius: 0;
}
.schedule-filter button.active {
    color: #009688;
    border-bottom-color: #009688;
}
.schedule-filter button:hover:not(.active) {
    color: #222;
}

/* ── Grid ── */
.schedule-grid {
    position: relative;
    display: grid;
    grid-template-columns: 80px repeat(6, 1fr);
    border: 1px solid #e0e0e0;
    border-radius: 4px;
    overflow: hidden;
}

/* ── Header ── */
.schedule-header {
    display: contents;
}
.schedule-header div {
    background: #e8e8e8;
    text-align: center;
    padding: 12px 4px;
    font-weight: 600;
    font-size: 17px;
    color: #333;
    border-bottom: 2px solid #d0d0d0;
    border-right: 1px solid #d0d0d0;
}
.schedule-header div:last-child {
    border-right: none;
}

/* ── Time cells ── */
.time-cell {
    height: 30px;
    border-bottom: 1px solid #e8e8e8;
    border-right: 1px solid #d8d8d8;
    padding-right: 8px;
    font-size: 15px;
    color: #999;
    display: flex;
    align-items: center;
    justify-content: flex-end;
    box-sizing: border-box;
}
.time-cell.row-even { background: #ffffff; }
.time-cell.row-odd { background: #f5f5f5; }

/* ── Day cells ── */
.day-cell {
    height: 30px;
    border-bottom: 1px solid #e8e8e8;
    border-right: 1px solid #eeeeee;
    box-sizing: border-box;
}
.day-cell:nth-child(7n) { border-right: none; }
.day-cell.row-even { background: #ffffff; }
.day-cell.row-odd { background: #f7f7f7; }

/* ── Course blocks ── */
.course-block {
    position: absolute;
    color: #fff;
    padding: 8px 10px;
    border-radius: 4px;
    overflow: hidden;
    box-sizing: border-box;
    font-size: 13px;
    line-height: 1.4;
    text-align: center;
    cursor: pointer;
    transition: transform .2s ease, box-shadow .2s ease;
    z-index: 1;
}
.course-block:hover {
    transform: scale(1.04);
    box-shadow: 0 4px 16px rgba(0,0,0,0.18);
    z-index: 10;
}
.course-title {
    font-weight: 700;
    font-size: 15px;
    margin-bottom: 10px;
}
.course-time {
    font-size: 13px;
    font-weight: bold;
    margin-bottom: 5px;
}
.course-desc {
    font-size: 13px;
    opacity: .88;
    line-height: 1.4;
    margin-bottom: 3px;
    overflow: hidden;
    display: -webkit-box;
    -webkit-line-clamp: 3;
    -webkit-box-orient: vertical;
}
.course-room {
    font-size: 13px;
    font-weight: bold;
    opacity: .85;
    margin-top: 10px;
}
.course-teacher {
    font-size: 15px;
    opacity: .88;
    margin-top: 7px;
    text-transform: uppercase;
    font-style: italic;
}

Now, in the JavaScript tab, add scripts to make the timetable interactive.

jQuery(function ($) {
    const $container = $('#mb-course-schedule');
    if (!$container.length) return;

    const courses = $container.data('courses') || [];
    const teachers = $container.data('teachers') || {};
    const days = ['monday','tuesday','wednesday','thursday','friday','saturday'];
    const minH = Math.min(...courses.map(c => parseInt(c.start)));
    const maxH = Math.max(...courses.map(c => parseInt(c.end)));
    const rowH = 30;

    let html = `<div class="schedule-filter"><button class="active" data-teacher="all">All Teachers</button>`;
    $.each(teachers, (slug, name) => { html += `<button data-teacher="${slug}">${name}</button>`; });
    html += `</div><div class="schedule-grid"><div class="schedule-header"><div></div>`;
    days.forEach(d => { html += `<div>${d.charAt(0).toUpperCase() + d.slice(1)}</div>`; });
    html += `</div>`;
    let ri = 0;
    for (let h = minH; h <= maxH; h++) {
        for (let m = 0; m < 60; m += 15) {
            const rc = ri++ % 2 === 0 ? 'row-even' : 'row-odd';
            html += `<div class="time-cell ${rc}">${String(h).padStart(2,'0')}:${String(m).padStart(2,'0')}</div>`;
            for (let d = 0; d < 6; d++) html += `<div class="day-cell ${rc}"></div>`;
        }
    }
    html += `</div>`;
    $container.html(html);

    const $grid = $('.schedule-grid');
    setTimeout(() => {
    const timeColW = $grid.find('.time-cell').first().outerWidth();
    const headerH = $grid.find('.schedule-header div').first().outerHeight();
    const dayW = ($grid.outerWidth() - timeColW) / 6;

    courses.forEach(course => {
        const [sh, sm] = course.start.split(':').map(Number);
        const [eh, em] = course.end.split(':').map(Number);
        const startMin = (sh - minH) * 60 + sm;
        const endMin = (eh - minH) * 60 + em;
        const dayIndex = days.indexOf(course.day);
        if (dayIndex < 0) return;

        const teacherNames = (course.teachers || []).map(s => teachers[s] || s);

        $('<div>', {
            class: 'course-block',
            'data-course': course.id,
            'data-teacher': (course.teachers || []).join(',')
        }).css({
            background: course.color,
            top: headerH + (startMin / 15 * rowH),
            left: timeColW + dayIndex * dayW + 2,
            width: dayW - 4,
            height: ((endMin - startMin) / 15) * rowH - 2
        }).html(`
            <div class="course-title">${course.title}</div>
            <div class="course-time">${course.start} - ${course.end}</div>
            ${course.description ? `<div class="course-desc">${course.description}</div>` : ''}
            ${course.room ? `<div class="course-room">🏫 ${course.room}</div>` : ''}
            ${teacherNames.length ? `<div class="course-teacher">👤 ${teacherNames.join(', ')}</div>` : ''}
        `).appendTo($grid);
    });
}, 0);

    $container.on('click', '.schedule-filter button', function () {
        const teacher = $(this).data('teacher');
        $('.schedule-filter button').removeClass('active');
        $(this).addClass('active');
        $('.course-block').each(function () {
            const list = ($(this).data('teacher') || '').split(',');
            $(this).toggle(teacher === 'all' || list.includes(String(teacher)));
        });
    });
});

Explanation:

jQuery(function ($) {

We wait until the HTML document is fully loaded before running the code.

Then, we look for the timetable container. If it doesn’t exist, the script stops immediately.

const $container = $('#mb-course-schedule');
if (!$container.length) return;

We retrieve the course and teacher data from the HTML attributes that we embedded earlier.

const courses = $container.data('courses') || [];
const teachers = $container.data('teachers') || {};

Next, we calculate the earliest start time and the latest end time among all courses. This helps the timetable automatically adjust its size based on the actual schedule data.

const days = ['monday','tuesday','wednesday','thursday','friday','saturday'];
const minH = Math.min(...courses.map(c => parseInt(c.start)));
const maxH = Math.max(...courses.map(c => parseInt(c.end)));

We define rowH as 30 pixels. This value is used later to calculate the vertical position and height of course blocks based on their time slots.

const rowH = 30;

Next, we build the timetable grid. For each hour, we create four 15-minute time slots, with one time cell and six day cells for each slot.

let ri = 0;
for (let h = minH; h <= maxH; h++) {
    for (let m = 0; m < 60; m += 15) {
         const rc = ri++ % 2 === 0 ? 'row-even' : 'row-odd';
         html += `<div class="time-cell ${rc}">${String(h).padStart(2,'0')}:${String(m).padStart(2,'0')}</div>`;
         for (let d = 0; d < 6; d++) html += `<div class="day-cell ${rc}"></div>`;
    }
}
html += `</div>`;
$container.html(html);

After rendering the grid, we use setTimeout() to delay the positioning calculations slightly, giving the browser time to render the timetable layout before measuring its dimensions.

The final code listens for clicks on the teacher filter buttons. When a button is clicked, we get the selected teacher, update the active button state, and show only the matching course. If users click All Teachers, all courses remain visible.

$container.on('click', '.schedule-filter button', function () {
    const teacher = $(this).data('teacher');
    $('.schedule-filter button').removeClass('active');
    $(this).addClass('active');
    $('.course-block').each(function () {
        const list = ($(this).data('teacher') || '').split(',');
        $(this).toggle(teacher === 'all' || list.includes(String(teacher)));
    });
});

That’s all for the code. I’ve put everything on GitHub, so you can check it out there.

Let’s move to the frontend to see the result. Now the timetable is fully functional, and users can filter courses by teacher easily.

result

Last Words

With Meta Box, you can easily build a dynamic timetable that is clear, flexible, and easy to filter. Want to explore more ways to build dynamic content in WordPress? Check out our related Meta Box tutorial for another practical example.

Uyen Hoang
Uyen Hoang
With 4 years of experience in WordPress marketing, I specialize in writing practical how-to guides and tutorials. My goal is to help readers build and optimize websites more effectively through clear, valuable, and easy-to-follow content.
Leave a Reply

Your email address will not be published. Required fields are marked *