Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Fix DaySlotEvents rendering #1531

Merged
merged 5 commits into from
Jun 13, 2024
Merged

Fix DaySlotEvents rendering #1531

merged 5 commits into from
Jun 13, 2024

Conversation

paulo-rico
Copy link
Contributor

A fix to address the following forum posts -

Scheduler Weird behavior when adding 45 min appointments
Scheduler Week View Time Slots Render Issue

Whilst testing the rendering as per multiple events per slot but no time overlap, I noticed that the height of the event wasn't correct.

Fixed that by changing to var height = Math.Max(1.5, 1.5 * eventEnd.Subtract(eventStart).TotalMinutes / MinutesPerSlot);
This is the same calculation as setting the top of the event, which was always correct. Not sure why these were different.

Admittedly, I haven't carried out extensive tests. Just that appointments don't render side-by-side in same slot when the actually times don't overlap.

@akorchev
Copy link
Collaborator

Wouldn't this lead to quite a few more rendering passes - 60 per slot v.s. 60 / MinutesPerSlot (two by default)? Not sure if this is the right approach.

@paulo-rico
Copy link
Contributor Author

paulo-rico commented May 26, 2024

@akorchev

It would. And I was initially worried about the rendering passes (especially as I had already raised my concerns earlier about an OnSlotRender implementation that would be called 504 times on the YearView).

We find ourselves between a rock and a hard place. Basically, the current implementation has the shortfalls that mean it can render incorrectly based on a combination of MinutesPerSlot and Appointment lengths.

This solution, on initial testing, makes it right, but at the expense of running loop cycles that may or may not be required.
It's an age old problem (travelling salesman, I believe). In order to render correctly, each appointment has to have knowledge of the other appointments, unfortunately, at a granular level.

I think this is where the back and forth begins!

60 minutes * 24 hours = 1440 passes

In the real world, are we going to be booking appointments less than five minutes in duration?

Instead of one minute in the loop, we could make it five minutes: That makes it 1440 / 5 = 288 passes (baring in mind that 1440 passes didn't have any detrimental effects in my testing, even on week view, which runs seven times).

This could be a parameter on the day and week views called something like RenderMinutes
(a) initialized to five minutes
(b) choose your own name

that the developer can set as appropriate.

What do you think?

@akorchev
Copy link
Collaborator

This could be a parameter on the day and week views called something like RenderMinutes

I am against such properties. They usually reveal a major weakness in the implementation - we didn't know what value to pick here so we let you pick it for us. And then the hell starts - what value should I set this to? How do I determine it? Been there, not a nice place to put yourself in.

TBH I am still not sure what the wrong behavior is. To me it seems fine - there is an empty space below one of the events but the events still start and end at the right place.

@paulo-rico
Copy link
Contributor Author

Yeah, I was just throwing ideas around. I didn't like it myself and have since ditched it.

There's definitely a problem with the height of the appointments. If you check this out in the demo

`@inject DialogService DialogService

<RadzenScheduler @ref=@Scheduler SlotRender=@OnSlotRender style="height: 768px;" TItem="Appointment" Data=@appointments StartProperty="Start" EndProperty="End"
TextProperty="Text" SelectedIndex="2"
SlotSelect=@OnSlotSelect AppointmentSelect=@OnAppointmentSelect AppointmentRender=@OnAppointmentRender
AppointmentMove=@OnAppointmentMove >



<EventConsole @ref=@Console />

@code {
RadzenScheduler scheduler;
EventConsole console;
Dictionary<DateTime, string> events = new Dictionary<DateTime, string>();

IList<Appointment> appointments = new List<Appointment>
{
    new Appointment { Start = DateTime.Today.AddHours(09), End = DateTime.Today.AddHours(14).AddMinutes(30), Text = "Folkjokopus" },
    new Appointment { Start = DateTime.Today.AddHours(14).AddMinutes(30), End = DateTime.Today.AddHours(16).AddMinutes(30), Text = "HQ" },
    new Appointment { Start = DateTime.Today.AddHours(10), End = DateTime.Today.AddHours(15).AddMinutes(30), Text = "Bullinamingvase" },
    new Appointment { Start = DateTime.Today.AddHours(15).AddMinutes(30), End = DateTime.Today.AddHours(16).AddMinutes(30), Text = "Unknown Soldier" },
    new Appointment { Start = DateTime.Today.AddHours(11), End = DateTime.Today.AddHours(16).AddMinutes(30), Text = "Decendants of Smith" },
    new Appointment { Start = DateTime.Today.AddHours(16).AddMinutes(30), End = DateTime.Today.AddHours(17).AddMinutes(30), Text = "Once" },
};

void OnSlotRender(SchedulerSlotRenderEventArgs args)
{
    // Highlight today in month view
    if (args.View.Text == "Month" && args.Start.Date == DateTime.Today)
    {
        args.Attributes["style"] = "background: rgba(255,220,40,.2);";
    }

    // Highlight working hours (9-18)
    if ((args.View.Text == "Week" || args.View.Text == "Day") && args.Start.Hour > 8 && args.Start.Hour < 19)
    {
        args.Attributes["style"] = "background: rgba(255,220,40,.2);";
    }
}

async Task OnSlotSelect(SchedulerSlotSelectEventArgs args)
{
    console.Log($"SlotSelect: Start={args.Start} End={args.End}");

    if (args.View.Text != "Year")
    {
        Appointment data = await DialogService.OpenAsync<AddAppointmentPage>("Add Appointment",
            new Dictionary<string, object> { { "Start", args.Start }, { "End", args.End } });

        if (data != null)
        {
            appointments.Add(data);
            // Either call the Reload method or reassign the Data property of the Scheduler
            await scheduler.Reload();
        }
    }
}

async Task OnAppointmentSelect(SchedulerAppointmentSelectEventArgs<Appointment> args)
{
    console.Log($"AppointmentSelect: Appointment={args.Data.Text}");

    var copy = new Appointment
    {
        Start = args.Data.Start,
        End = args.Data.End,
        Text = args.Data.Text
    };

    var data = await DialogService.OpenAsync<EditAppointmentPage>("Edit Appointment", new Dictionary<string, object> { { "Appointment", copy } });

    if (data != null)
    {
        // Update the appointment
        args.Data.Start = data.Start;
        args.Data.End = data.End;
        args.Data.Text = data.Text;
    }

    await scheduler.Reload();
}

void OnAppointmentRender(SchedulerAppointmentRenderEventArgs<Appointment> args)
{
    // Never call StateHasChanged in AppointmentRender - would lead to infinite loop

    if (args.Data.Text == "Birthday")
    {
        args.Attributes["style"] = "background: red";
    }
}

async Task OnAppointmentMove(SchedulerAppointmentMoveEventArgs args)
{
    var draggedAppointment = appointments.FirstOrDefault(x => x == args.Appointment.Data);

    if (draggedAppointment != null)
    {
        draggedAppointment.Start = draggedAppointment.Start + args.TimeSpan;

        draggedAppointment.End = draggedAppointment.End + args.TimeSpan;

        await scheduler.Reload();
    }
}

}`

the appointment heights are incorrect. I think the change to the height calculation in the PR sorts this out.

The problem with the empty space is more to do with immediate interpretation from a GUI point of view. Even though the appointments are mathematically correct, the user automatically assumes that the appointments overlap, when in fact they don't.
I've improved on the existing mechanism with minimal render passes, but it's not ready as it still fails in certain situations.

This is a lot trickier than I first anticipated.

@akorchev
Copy link
Collaborator

I think the issue itself doesn't justify the potential performance hit or breaking in other supported cases. You can try addressing the issue in AppointmentGroups() and DetermineLeft which is actually what returns the "left" position of an event.

@paulo-rico
Copy link
Contributor Author

I'll give it a bit more thought around those two functions.

@paulo-rico
Copy link
Contributor Author

Hi @akorchev

I'm pretty sure I've sorted this. Just need to do a little more testing before pushing. In the meantime, when rendering the appointment, i.e. -

` @if (item.Start >= start && item.Start <= end)

    {
        <Appointment Data=@item Top=@top Left=@left Width=@width Height=@height Click=@OnAppointmentSelect
            CssClass="@(CurrentDate.Date == date.Date && object.Equals(Appointments.Where(i => i.Start.Date == CurrentDate.Date).OrderBy(i => i.Start).ThenByDescending(i => i.End).ElementAtOrDefault(CurrentAppointment),item) ? "rz-state-focused" : "")" 
            DragStart=@OnAppointmentDragStart />

    } 

    else if (date == StartDate)

    {
        <Appointment Data=@item Top=@top Left=@left Width=@width Height=@height Click=@OnAppointmentSelect
            CssClass="@(CurrentDate == date && CurrentAppointment != - 1 && object.Equals(appointments.ElementAtOrDefault(CurrentAppointment),item) ? "rz-state-focused" : "")" 
            DragStart=@OnAppointmentDragStart />

    }`

there's the bit about setting the rz-state-focused class, but I cannot for the life in me see what this actually does. Can you let me know what this is used for?

Many thanks

Paul

@akorchev
Copy link
Collaborator

akorchev commented Jun 3, 2024

The rz-state-focused class is used during keyboard navigation - highlights the active appointment.

@paulo-rico
Copy link
Contributor Author

@akorchev
Ok nearly there. Found something that is out of scope for this PR but I think needs addressing. When navigating through appointments in Day View, they don't highlight like in the other views. The class IS being added correctly. It looks like rz-state-focused hasn't been defined for Day View in the theme css files.

@paulo-rico
Copy link
Contributor Author

paulo-rico commented Jun 3, 2024

OK, I'm pushing this PR.

As you will see, I have ditched the AppointmentGroups() and DetermineLeft in favour of AssignColumns() and AssignLeftAndWidth().

Most of the rendering problems occur when the appointment durations are not multiples of MinutesPerSlot.
I felt that the rendering of the appointments should be relative to each other (appointments) and not dependent on the MinutesPerSlot parameter which should only be used to calculate the top and height of the appointments.

With that in mind, I've achieved this using two main functions as described above.

AssignColumns() will run through all the appointments in a day and work out how they stack against each other. It follows this procedure per appointment -

Is there a column where this appointment has an equal or greater time than the last appointment in the column? If so, add it to that column. If not, create a new column and place it there.

AssignLeftAndWidth() will run through all the days appointments and calculate the left and width based on it's own column and the next column filled in to it's right. Procedurally, this is -

Get all appointments that overlap this appointment where the column is greater. If there is one, the width is calculated up to that appointment, if not, maxColumnCountis used.

All my tests indicate that this has sorted out the rendering issues described earlier in the comments.

Here is some code to paste into the demo site. There are a few days appointments that highlight the rendering issues that occur at the moment and how they render with this new procedure.

Because it's all calculated based on the appointments and not the slots (which a user could easily set MinutesPerSlot to one), it is a more efficient process.

`@inject DialogService DialogService

<RadzenScheduler @ref=@Scheduler SlotRender=@OnSlotRender style="height: 1200px;" TItem="Appointment" Data=@appointments StartProperty="Start" EndProperty="End"
TextProperty="Text" SelectedIndex="2"
SlotSelect=@OnSlotSelect AppointmentSelect=@OnAppointmentSelect AppointmentRender=@OnAppointmentRender
AppointmentMove=@OnAppointmentMove >



<EventConsole @ref=@Console />

@code {
RadzenScheduler scheduler;
EventConsole console;
Dictionary<DateTime, string> events = new Dictionary<DateTime, string>();

IList<Appointment> appointments = new List<Appointment>
{
    new Appointment { Start = DateTime.Today.AddDays(-2), End = DateTime.Today.AddDays(-2), Text = "Birthday" },
    new Appointment { Start = DateTime.Today.AddDays(-11), End = DateTime.Today.AddDays(-10), Text = "Day off" },
    new Appointment { Start = DateTime.Today.AddDays(-10), End = DateTime.Today.AddDays(-8), Text = "Work from home" },
    new Appointment { Start = DateTime.Today.AddHours(10), End = DateTime.Today.AddHours(12), Text = "Online meeting" },
    new Appointment { Start = DateTime.Today.AddHours(10), End = DateTime.Today.AddHours(13), Text = "Skype call" },
    new Appointment { Start = DateTime.Today.AddHours(14), End = DateTime.Today.AddHours(14).AddMinutes(30), Text = "Dentist appointment" },

    new Appointment { Start = DateTime.Today.AddDays(1).AddHours(09), End = DateTime.Today.AddDays(1).AddHours(14).AddMinutes(30), Text = "Folkjokopus" },
    new Appointment { Start = DateTime.Today.AddDays(1).AddHours(14).AddMinutes(30), End = DateTime.Today.AddDays(1).AddHours(16).AddMinutes(30), Text = "HQ" },
    new Appointment { Start = DateTime.Today.AddDays(1).AddHours(10), End = DateTime.Today.AddDays(1).AddHours(15).AddMinutes(30), Text = "Bullinamingvase" },
    new Appointment { Start = DateTime.Today.AddDays(1).AddHours(15).AddMinutes(30), End = DateTime.Today.AddDays(1).AddHours(16).AddMinutes(30), Text = "Unknown Soldier" },
    new Appointment { Start = DateTime.Today.AddDays(1).AddHours(11), End = DateTime.Today.AddDays(1).AddHours(16).AddMinutes(30), Text = "Decendants of Smith" },
    new Appointment { Start = DateTime.Today.AddDays(1).AddHours(16).AddMinutes(29), End = DateTime.Today.AddDays(1).AddHours(17).AddMinutes(30), Text = "Once" },

    new Appointment { Start = DateTime.Today.AddDays(2).AddHours(15).AddMinutes(30), End = DateTime.Today.AddDays(2).AddHours(16).AddMinutes(0), Text = "Test 1" },
    new Appointment { Start = DateTime.Today.AddDays(2).AddHours(16).AddMinutes(00), End = DateTime.Today.AddDays(2).AddHours(16).AddMinutes(30), Text = "Test 2" },
    new Appointment { Start = DateTime.Today.AddDays(2).AddHours(16).AddMinutes(30), End = DateTime.Today.AddDays(2).AddHours(17).AddMinutes(15), Text = "Test 3" },
    new Appointment { Start = DateTime.Today.AddDays(2).AddHours(17).AddMinutes(15), End = DateTime.Today.AddDays(2).AddHours(18).AddMinutes(0), Text = "Test 4" },

    new Appointment { Start = DateTime.Today.AddDays(7), End = DateTime.Today.AddDays(18), Text = "Vacation" },	

};

void OnSlotRender(SchedulerSlotRenderEventArgs args)
{
    // Highlight today in month view
    if (args.View.Text == "Month" && args.Start.Date == DateTime.Today)
    {
        args.Attributes["style"] = "background: rgba(255,220,40,.2);";
    }

    // Highlight working hours (9-18)
    if ((args.View.Text == "Week" || args.View.Text == "Day") && args.Start.Hour > 8 && args.Start.Hour < 19)
    {
        args.Attributes["style"] = "background: rgba(255,220,40,.2);";
    }
}

async Task OnSlotSelect(SchedulerSlotSelectEventArgs args)
{
    console.Log($"SlotSelect: Start={args.Start} End={args.End}");

    if (args.View.Text != "Year")
    {
        Appointment data = await DialogService.OpenAsync<AddAppointmentPage>("Add Appointment",
            new Dictionary<string, object> { { "Start", args.Start }, { "End", args.End } });

        if (data != null)
        {
            appointments.Add(data);
            // Either call the Reload method or reassign the Data property of the Scheduler
            await scheduler.Reload();
        }
    }
}

async Task OnAppointmentSelect(SchedulerAppointmentSelectEventArgs<Appointment> args)
{
    console.Log($"AppointmentSelect: Appointment={args.Data.Text}");

    var copy = new Appointment
    {
        Start = args.Data.Start,
        End = args.Data.End,
        Text = args.Data.Text
    };

    var data = await DialogService.OpenAsync<EditAppointmentPage>("Edit Appointment", new Dictionary<string, object> { { "Appointment", copy } });

    if (data != null)
    {
        // Update the appointment
        args.Data.Start = data.Start;
        args.Data.End = data.End;
        args.Data.Text = data.Text;
    }

    await scheduler.Reload();
}

void OnAppointmentRender(SchedulerAppointmentRenderEventArgs<Appointment> args)
{
    // Never call StateHasChanged in AppointmentRender - would lead to infinite loop

    if (args.Data.Text == "Birthday")
    {
        args.Attributes["style"] = "background: red";
    }
}

async Task OnAppointmentMove(SchedulerAppointmentMoveEventArgs args)
{
    var draggedAppointment = appointments.FirstOrDefault(x => x == args.Appointment.Data);

    if (draggedAppointment != null)
    {
        draggedAppointment.Start = draggedAppointment.Start + args.TimeSpan;

        draggedAppointment.End = draggedAppointment.End + args.TimeSpan;

        await scheduler.Reload();
    }
}

}`

It's worth experimenting with overlapping some of the appointments. Also, changing the MinutesPerSlot parameter.

Speak soon

Paul

@akorchev
Copy link
Collaborator

akorchev commented Jun 4, 2024

I am afraid that's too big of a change. I don't understand the new code and algorithm and thus can't merge it with clear conscience. Could you implement your fix with a minimal change to that file?

@paulo-rico
Copy link
Contributor Author

@akorchev
Admittedly, it is a big change. The existing algorithm using AppointmentGroups and the main rendering loop are based around the MinutesPerSlot parameter. This appears to be the root cause of invalid displays when Appointment durations are not multiples of MinutesPerSlot.
In order that the algorithm worked without reliance on MinutesPerSlot and to operate with maximum efficiency, it had to be redesigned around the actual Appointments for the day themselves.

I've also just checked the difference file for what I sent. There has been a change in spacing for existing code that has not actually changed, and therefore reported those as changes also. Apologies for that.

My next steps will be as follow

  1. Send you a copy of the DaySlotEvents.razor file that will be heavily commented to highlight the workings of the algorithms.
  2. Try to reverse my changes and re-do them without any existing format (tabs/spaces) changes.

@paulo-rico
Copy link
Contributor Author

An aside related to this PR (and previous ones)

I don't know how the GitHub network is organized, but would you / Radzen be willing to put their weight behind the following proposal? PR Temporary Comments. It's something I've been thinking about for a while. It could really save some time for both the PR developers and the project owners.

Regards

Paul

@akorchev
Copy link
Collaborator

akorchev commented Jun 4, 2024

Unfortunately we don't have any weight when it comes to github feature requests. By the way we don't mind code comments. The issue here is different - the rendering algorithm is completely replaced and not by us. As maintainers we have the responsibility to ensure existing apps don't break in such cases. We also have to support this change - which means we have to fully understand it and be able to fix issues with it. We can't just ping the contribute whenever an issue arises.

This is the reason why I undid my fix for this issue - it broke a few cases of existing users.

The bigger issue is that this thing doesn't have tests. I couldn't think of a good way to create tests for the layout and describe all edge cases.

I think we should abandon this effort for now (unless you can fix it without completely changing the rendering). The effort already spent on it is not on par with its severity. Heck I just tried running it through Github Copilot Chat and ChatGPT for one hour without any success :) I think I am done with it.

@paulo-rico
Copy link
Contributor Author

Fully understand @akorchev

Due to the fact that it's the existing rendering based on MinutesPerSlot that causes the rendering anomalies, I'm sure I won't be able to replicate the fix by changing the existing mechanism.

Although I know that the various checks I did against my code (hard coded appointments, click and change appointment time, drag drop, e.t.c.) came out displaying correctly, I'm in no way going to claim that I had covered all possibilities, and it is imperative that you don't introduce any bugs e.t.c., especially on something that hasn't yet caused you guys too much pain.
It really is something that cannot be fully tested!!!

For now, I'll include the DaySlotEvents.razor file at the end of this post with all the comments and it may be something you can pick up on at a later date. From my tests and what I have seen with the existing rendering, I do believe this issue will be featured in the forums in the future.

Stay well, Atanas

Paul

P.S.
Not sure if you ran the code (for the demo page) with the existing system, but this was one of my experiences -

This first picture is rendered as I would expect.

Render-One

After changing the last appointment "Once" to commence at 14:29 (overlapping three other appointments), it correctly moves to one side. But the rendering rest of the appointments has obvious errors.

Render-Two

`
@using Radzen.Blazor

@{
// because we are going to create the layout based on the relation between all the appointments in a day
// the first thing we need to do is get the earliest appointment for the day as a starting point

// InitialzeView returns a bool indicating that there are actually appointments for this day
if(InitializeView())
{
    // first thing we need to do is to organize the appointments onto columns
    AssignColumns();
    // we can then assign them a 'Left' and a 'Width' based on their 'Column', other appointments columns and the maxColumnCount variable
    AssignLeftAndWidth();
}

}

@foreach (var appointment in dayAppointments)
{
<Appointment Data=@((AppointmentData)appointment) Top=@appointment.Top Left=@appointment.Left Width=@appointment.Width Height=@appointment.Height Click=@OnAppointmentSelect
CssClass="@(CurrentDate.Date >= appointment.Start.Date && CurrentDate.Date <= appointment.End.Date && object.Equals(dayAppointments.Where(i => i.Start.Date == CurrentDate.Date).OrderBy(i => i.Start).ThenByDescending(i => i.End).ElementAtOrDefault(CurrentAppointment),appointment) ? " rz-state-focused" : "" )"
DragStart=@OnAppointmentDragStart />
}

@code {
[Parameter]
public int CurrentAppointment { get; set; } = -1;

[Parameter]
public DateTime CurrentDate { get; set; }

[CascadingParameter]
public IScheduler Scheduler { get; set; }

[Parameter]
public DateTime StartDate { get; set; }

[Parameter]
public DateTime EndDate { get; set; }

[Parameter]
public int MinutesPerSlot { get; set; }

[Parameter]
public EventCallback<AppointmentData> AppointmentDragStart { get; set; }

[Parameter]
public IList<AppointmentData> Appointments { get; set; }

public AppointmentDataRender[] dayAppointments { get; set; }

public int maxColumnCount { get; set; }
public double columnWidth { get; set; }

private bool InitializeView()
{
    // get all appointments for the day
    var appointments = AppointmentsInSlot(StartDate, EndDate);
    // project the appointments (AppointmentData[]) to the extended AppointmentDataRender[] class
    dayAppointments = appointments.Select(e => new AppointmentDataRender(e)).ToArray();
    // this variable will hold the count of columns required for this display
    maxColumnCount = 0;

    // we need to have a starting point in order to begin relating the other appointments. This is the first appointment
    if (dayAppointments.Length > 0)
    {
        dayAppointments[0].Column = 1;
        dayAppointments[0].EventStart = dayAppointments[0].Start < StartDate ? StartDate : dayAppointments[0].Start;
        dayAppointments[0].EventEnd = dayAppointments[0].End > EndDate ? EndDate : dayAppointments[0].End;
        dayAppointments[0].Length = dayAppointments[0].EventStart.Subtract(StartDate).TotalMinutes / MinutesPerSlot; ;
        dayAppointments[0].Top = 1.5 * dayAppointments[0].Length;
        dayAppointments[0].Height = Math.Max(1.5, 1.5 * dayAppointments[0].EventEnd.Subtract(dayAppointments[0].EventStart).TotalMinutes / MinutesPerSlot);
        dayAppointments[0].Left = 0;
        dayAppointments[0].Width = 0;

        maxColumnCount = 1;
    }

    // if there are any appointments, retun True, else False
    return dayAppointments.Length > 0;
}

private void AssignColumns()
{

    // to get here, we know there is at least one appointment and that first appointment has been assigned it's column property in the InitializeView function
    // so we'll loop through all other appointments (Column will not have been set yet)
    // future reference to 'loop appointment' mean this foreach variable
    @foreach (var appointment in dayAppointments.Where(app => app.Column == 0))
    {
        // get all appointments that have been assigned a column already 
        // *** ON FIRST PASS, this would only be the first appointment set in InitializeView ***
        var assignedAppointments = dayAppointments.Where(app => app.Column > 0);
        // now get the Column of the first assignedAppointment whose End is less than or equal to the loop appointment Start
        var available = assignedAppointments.GroupBy(g => g.Column).Select(s => s.OrderByDescending(o => o.EventEnd).FirstOrDefault()).Where(w => w.End <= appointment.Start);            
        var available2 = available.Count() > 0 ? available.First() : null;

        // if no such column exists (all Columns last appointments Ends in assignedAppointments are greater than the Start of the loop appointmenmt), this appointment must be alocated to a new column
        // *** ON FIRST PASS, there is only one column and one appointment. If this loop appointment's Start is less than the first appointments End, it must be assigned a new column (2)
        // *** otherwise, the appointment sits below the existing appointment and will be assigned the same column (1) ***
        appointment.Column = available2 != null ? available2.Column : assignedAppointments.Max(m => m.Column) + 1;

        // now set some of the other properties
        appointment.EventStart = appointment.Start < StartDate ? StartDate : appointment.Start;
        appointment.EventEnd = appointment.End > EndDate ? EndDate : appointment.End;
        appointment.Length = appointment.EventStart.Subtract(StartDate).TotalMinutes / MinutesPerSlot; ;
        appointment.Top = 1.5 * appointment.Length;
        appointment.Height = Math.Max(1.5, 1.5 * appointment.EventEnd.Subtract(appointment.EventStart).TotalMinutes / MinutesPerSlot);

        // these will eventually be set in the AssignLeftAndWidth method
        appointment.Left = 0;
        appointment.Width = 0;

        // make sure maxColumnCount is kept up to date
        maxColumnCount = Math.Max(maxColumnCount, appointment.Column);
    }
    
    // set the columnWidth variable now that we know how many columns are required
    columnWidth = 90.0 / maxColumnCount;
}

private void AssignLeftAndWidth()
{
    @foreach (var appointment in dayAppointments)
    {
        // we first need the column number of the nearest adjacent (to the right) appointment to this one
        var nextAdjacentAppointment = OverlappingAppointments(appointment.EventStart, appointment.EventEnd).Where(o => o.Column > appointment.Column).OrderBy(o => o.Column).FirstOrDefault();
        // if there isn't one, use maxColumn + 1 i.e. the end of the "display"
        var nextColumn = nextAdjacentAppointment == null ? maxColumnCount + 1 : nextAdjacentAppointment.Column;
        // set the Width of this appointment appropriately. Up to the next adjacent appointment or up to the right hand edge of the "display"
        appointment.Width = (nextColumn - appointment.Column) * columnWidth;
        // set the appointments Left
        appointment.Left = (appointment.Column - 1) * columnWidth;
    }
}

async Task OnAppointmentSelect(AppointmentData data)
{
    await Scheduler.SelectAppointment(data);
}

private AppointmentData[] AppointmentsInSlot(DateTime start, DateTime end)
{
    if (Appointments == null)
    {
        return Array.Empty<AppointmentData>();
    }

    return Appointments.Where(item => Scheduler.IsAppointmentInRange(item, start, end)).OrderBy(item => item.Start).ThenByDescending(item => item.End).ToArray();
}

// this function could probably supercede the above (AppointmentsInSlot) function. One thing at a time
private AppointmentDataRender[] OverlappingAppointments(DateTime start, DateTime end)
{
    if (dayAppointments == null)
    {
        return Array.Empty<AppointmentDataRender>();
    }

    return dayAppointments.Where(item => Scheduler.IsAppointmentInRange(item, start, end)).OrderBy(item => item.Start).ThenByDescending(item => item.End).ToArray();
}

public async Task OnAppointmentDragStart(AppointmentData Data)
{
    await AppointmentDragStart.InvokeAsync(Data);
}

public class AppointmentDataRender : AppointmentData
{
    public AppointmentDataRender(AppointmentData appointment)
    {
        this.Start = appointment.Start;
        this.End = appointment.End;
        this.Data = appointment.Data;
        this.Text = appointment.Text;
    }

    public DateTime EventStart { get; set; }
    public DateTime EventEnd { get; set; }
    public double Length { get; set; }
    public double Top { get; set; }
    public double Height { get; set; }
    public double Left { get; set; }
    public double Width { get; set; }
    public int Column { get; set; }
}

}
`

@akorchev
Copy link
Collaborator

akorchev commented Jun 4, 2024

I am sorry but the comments don't improve my understanding of the implementation. Some of the comments explain what the code does but not why. Some examples:

  // get all appointments for the day
    var appointments = AppointmentsInSlot(StartDate, EndDate);

No need for this comment at all.

    // project the appointments (AppointmentData[]) to the extended AppointmentDataRender[] class
    dayAppointments = appointments.Select(e => new AppointmentDataRender(e)).ToArray();

No information is given about why AppointmentDataRender is needed.

// if there are any appointments, retun True, else False
    return dayAppointments.Length > 0;

Documents the implementation (which everybody understands) but not what actually true and false means. I won't expect a method called InitializeView to return true and false. And if an Initialize method returns false I would expect that initialization has failed for some reason.

public AppointmentDataRender[] dayAppointments { get; set; }

public int maxColumnCount { get; set; }
public double columnWidth { get; set; }

Why are those properties? And why are they public? They should probably be private fields but I am not sure.

 dayAppointments[0].Column = 1;

Why is Column set to 1? Are columns zero-based or not? If not zero based - why not?

@foreach (var appointment in dayAppointments.Where(app => app.Column == 0))

Why is there @ before @foreach here? My guess is that this is a mistake but again I don't know :)

// *** ON FIRST PASS, this would only be the first appointment set in InitializeView ***
        var assignedAppointments = dayAppointments.Where(app => app.Column > 0);

What does ON FIRST PASS mean? Why is it in caps? Are there multiple passes? What is a pass really?

var available2 = available.Count() > 0 ? available.First() : null;

What is available2? Could it be just available.FirstOrDefault()?

// if no such column exists (all Columns last appointments Ends in assignedAppointments are greater than the Start of the loop appointmenmt), this appointment must be alocated to a new column

Couldn't wrap my head around this - call me stupid.

 // these will eventually be set in the AssignLeftAndWidth method
        appointment.Left = 0;
        appointment.Width = 0;

What is the purpose of this?

 // this function could probably supercede the above (AppointmentsInSlot) function. One thing at a time
private AppointmentDataRender[] OverlappingAppointments(DateTime start, DateTime end)

Again not sure about this one. It seems identical to AppointmentsInSlot though.

@paulo-rico
Copy link
Contributor Author

paulo-rico commented Jun 10, 2024

Hopefully, the new comments can be understood and followed through the code. I have always struggled with technical writing. I find it hard to reverse engineer my own code into English, even though it is my first and only language!

Also streamlined the code a little.

@akorchev
Copy link
Collaborator

Thanks! Thats a lot better. A few issues that I see:

  1. Could this lead to a null reference exception when FirstOrDefault returns null?
    .Select(s => s.OrderByDescending(o => o.EventEnd).FirstOrDefault()).Where(w => w.End <= appointment.Start)
    
  2. The focusing logic is broken - it compares the appointment by reference and it will never match. Here is what I propose as a fix - do not inherit from AppointmentData but encapsulate it (also I don't like the name AppointmentDataExtended - RenderedAppointment or DaySlotAppintment sound better):
class RenderedAppointment
{
     public AppointmentData Appointment { get; set; }
     // We can now rename it to Start as it no longer conflicts with the Start of AppointmentData
     public DateTime EventStart { get; set; }
     // We can now rename it to End as it no longer conflicts with the End of AppointmentData
     public DateTime EventEnd { get; set; }
     public int Column { get; set; }
}

Then use appointment.Data.xxx when needed.
3. Extract the whole rendering initialization in a new method which returns RenderedAppointment[]. The end code should be something like:

@foreach (var appointment in Layout())
{
    <Appointment Data=@appointment.Data ... />
}

This will remove the need of a few of the variables and fields used - they will be local in the Layout method.

@paulo-rico
Copy link
Contributor Author

Issues resolved -

  1. I've debugged this assignedAppointments.GroupBy(g => g.Column).Select(s => s.OrderByDescending(o => o.End).FirstOrDefault() with no appointments, consecutive appointments and overlapping appointments. Always returns something, even in the event of no data. Its empty rather than null. I suspect that this is to do with the GroupBy although I haven't been able to confirm this in any Linq documentation.
  2. Changed as per suggestion. There is still the outstanding issue highlighted in the above comment - Fix DaySlotEvents rendering #1531 (comment), but that may be for the Css guy.
  3. Took me a while to understand what you meant. Hope this is it. Managed to remove AppointmentsInSlot() as well. Was only used in one place, so put the code in Layout() instead.

@akorchev
Copy link
Collaborator

Seems nice! Indeed the rz-focused-state state isn't styled in day view. Something that @yordanov would handle.

Thank you for this great contribution!

@akorchev akorchev merged commit 349e771 into radzenhq:master Jun 13, 2024
1 check passed
@paulo-rico
Copy link
Contributor Author

Thanks @akorchev 👍

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

None yet

2 participants