Add calendar/events backend API (#8)

* Align EntityFrameworkCore.SqlServer and Tools to 8.0.8

* Add calendar/events backend API

Cherry-picked from main-backup (19994ef); scratch notes file removed.

* Require authentication on CalendarController

Security review found [Authorize] commented out, leaving all 6 calendar
endpoints anonymous. Enforce auth to match the API convention (17/23
controllers).

* Add CalendarController unit tests (xUnit + EF InMemory)
This commit is contained in:
Adam Moussa 2026-06-22 18:46:46 -04:00 • committed by GitHub
parent dd1ffba06d
commit 1f3972ae49
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 677 additions and 3 deletions

View file

@ -0,0 +1,25 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
<IsTestProject>true</IsTestProject>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.11.1" />
<PackageReference Include="xunit" Version="2.9.2" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2" />
<PackageReference Include="Moq" Version="4.20.72" />
<PackageReference Include="FluentAssertions" Version="6.12.1" />
<PackageReference Include="Microsoft.EntityFrameworkCore.InMemory" Version="8.0.8" />
</ItemGroup>
<ItemGroup>
<FrameworkReference Include="Microsoft.AspNetCore.App" />
<ProjectReference Include="..\Api.SeaHavenIndustries\Api.SeaHavenIndustries.csproj" />
</ItemGroup>
</Project>

View file

@ -0,0 +1,206 @@
using System.Collections;
using Api.SeaHavenIndustries.Controllers;
using Api.SeaHavenIndustries.DTOs;
using Data.SeaHavenIndustries;
using FluentAssertions;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using Moq;
using Xunit;
namespace Api.SeaHavenIndustries.Tests;
public class CalendarControllerTests
{
private static ApplicationDbContext NewContext()
{
var options = new DbContextOptionsBuilder<ApplicationDbContext>()
.UseInMemoryDatabase(databaseName: Guid.NewGuid().ToString())
.Options;
return new ApplicationDbContext(options);
}
private static UserManager<ApplicationUser> MockUserManager()
{
var store = new Mock<IUserStore<ApplicationUser>>();
return new Mock<UserManager<ApplicationUser>>(
store.Object, null!, null!, null!, null!, null!, null!, null!, null!).Object;
}
private static CalendarController NewController(ApplicationDbContext ctx) =>
new(MockUserManager(), ctx);
private static EditEvent_DTO ValidDto(int? id = null, string title = "Standup") => new()
{
Id = id,
Title = title,
ReminderMinutes = 15,
EventColor = "primary",
StartDate = new DateTime(2026, 6, 1),
StartTime = "08:00 AM",
EndDate = new DateTime(2026, 6, 1),
EndTime = "08:30 AM"
};
private static Events SeedEvent(ApplicationDbContext ctx, string title, DateTime startDate, bool? isDeleted = false)
{
var ev = new Events
{
Title = title,
EventColor = "primary",
ReminderMinutes = 15,
StartDate = startDate,
EndDate = startDate,
IsDeleted = isDeleted,
CreatedDate = DateTime.Now
};
ctx.Events.Add(ev);
ctx.SaveChanges();
return ev;
}
private static object Prop(object source, string name) =>
source.GetType().GetProperty(name)!.GetValue(source)!;
[Fact]
public async Task AddEvent_WhenValid_PersistsEventAndReturnsOk()
{
using var ctx = NewContext();
var result = await NewController(ctx).AddEvent(ValidDto());
var ok = result.Should().BeOfType<OkObjectResult>().Subject;
ok.Value.Should().BeOfType<DataResponse>().Which.Status.Should().Be("200");
var saved = ctx.Events.Single();
saved.Title.Should().Be("Standup");
saved.IsDeleted.Should().Be(false);
saved.CreatedDate.Should().NotBeNull();
}
[Fact]
public async Task EditEvent_WhenIdMissing_ReturnsBadRequest()
{
using var ctx = NewContext();
var result = await NewController(ctx).EditEvent(ValidDto(id: null));
result.Should().BeOfType<BadRequestObjectResult>();
}
[Fact]
public async Task EditEvent_WhenEventNotFound_ReturnsNotFound()
{
using var ctx = NewContext();
var result = await NewController(ctx).EditEvent(ValidDto(id: 999));
result.Should().BeOfType<NotFoundObjectResult>();
}
[Fact]
public async Task EditEvent_WhenEventExists_UpdatesFieldsAndStampsModified()
{
using var ctx = NewContext();
var existing = SeedEvent(ctx, "Old", new DateTime(2026, 6, 1));
var result = await NewController(ctx).EditEvent(ValidDto(id: existing.Id, title: "New"));
result.Should().BeOfType<OkObjectResult>();
var updated = ctx.Events.Single();
updated.Title.Should().Be("New");
updated.LastModificationTime.Should().NotBeNull();
}
[Fact]
public async Task GetEventById_WhenExists_ReturnsEventDto()
{
using var ctx = NewContext();
var existing = SeedEvent(ctx, "Review", new DateTime(2026, 6, 1));
var result = await NewController(ctx).GetEventById(existing.Id);
var ok = result.Should().BeOfType<OkObjectResult>().Subject;
var dto = ok.Value.Should().BeOfType<DataResponse>().Subject.Data as Event_DTO;
dto!.Title.Should().Be("Review");
}
[Fact]
public async Task GetEventById_WhenSoftDeleted_ReturnsNotFound()
{
using var ctx = NewContext();
var deleted = SeedEvent(ctx, "Gone", new DateTime(2026, 6, 1), isDeleted: true);
var result = await NewController(ctx).GetEventById(deleted.Id);
result.Should().BeOfType<NotFoundObjectResult>();
}
[Fact]
public async Task DeleteEvent_WhenExists_SoftDeletesAndKeepsRow()
{
using var ctx = NewContext();
var existing = SeedEvent(ctx, "Cancel", new DateTime(2026, 6, 1));
var result = await NewController(ctx).DeleteEvent(existing.Id);
result.Should().BeOfType<OkObjectResult>();
var row = ctx.Events.Single();
row.IsDeleted.Should().Be(true);
row.DeletionTime.Should().NotBeNull();
}
[Fact]
public async Task DeleteEvent_WhenNotFound_ReturnsNotFound()
{
using var ctx = NewContext();
var result = await NewController(ctx).DeleteEvent(12345);
result.Should().BeOfType<NotFoundObjectResult>();
}
[Fact]
public void GetEventList_ExcludesDeletedAndCountsAfterFilter()
{
using var ctx = NewContext();
SeedEvent(ctx, "Alpha meeting", new DateTime(2026, 6, 1));
SeedEvent(ctx, "Beta meeting", new DateTime(2026, 6, 2));
SeedEvent(ctx, "Alpha review", new DateTime(2026, 6, 3));
SeedEvent(ctx, "Alpha archived", new DateTime(2026, 6, 4), isDeleted: true);
var result = NewController(ctx).GetEventList(search: "alpha", page: 1, pageSize: 10);
var ok = result.Should().BeOfType<OkObjectResult>().Subject;
var data = ok.Value.Should().BeOfType<DataResponse>().Subject.Data!;
((int)Prop(data, "totalCount")).Should().Be(2);
}
[Fact]
public void GetEventList_OrdersByStartDateDescending()
{
using var ctx = NewContext();
SeedEvent(ctx, "Earliest", new DateTime(2026, 6, 1));
SeedEvent(ctx, "Latest", new DateTime(2026, 6, 10));
SeedEvent(ctx, "Middle", new DateTime(2026, 6, 5));
var result = NewController(ctx).GetEventList(page: 1, pageSize: 10);
var data = (result as OkObjectResult)!.Value.As<DataResponse>().Data!;
var rows = ((IEnumerable)Prop(data, "rows")).Cast<Event_DTO>().ToList();
rows.Select(r => r.Title).Should().ContainInOrder("Latest", "Middle", "Earliest");
}
[Fact]
public void GetEventsForCalendar_ReturnsOnlyEventsOverlappingRange()
{
using var ctx = NewContext();
SeedEvent(ctx, "InRange", new DateTime(2026, 6, 15));
SeedEvent(ctx, "BeforeRange", new DateTime(2026, 5, 1));
SeedEvent(ctx, "AfterRange", new DateTime(2026, 7, 1));
var result = NewController(ctx).GetEventsForCalendar(
start: new DateTime(2026, 6, 1), end: new DateTime(2026, 6, 30));
var ok = result.Should().BeOfType<OkObjectResult>().Subject;
var events = ok.Value.As<DataResponse>().Data as IEnumerable<Event_DTO>;
events!.Select(e => e.Title).Should().BeEquivalentTo(new[] { "InRange" });
}
}

View file

@ -0,0 +1,306 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using Api.SeaHavenIndustries.DTOs;
using Data.SeaHavenIndustries;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
namespace Api.SeaHavenIndustries.Controllers
{
[Authorize]
[ApiController]
[Route("api/Calendar")]
public class CalendarController : Controller
{
private readonly UserManager<ApplicationUser> _userManager;
private readonly ApplicationDbContext _db;
public CalendarController(UserManager<ApplicationUser> userManager, ApplicationDbContext db)
{
_userManager = userManager;
_db = db;
}
// ===================================================================================
// CREATE EVENT
// ===================================================================================
[HttpPost]
[Route("AddEvent")]
public async Task<IActionResult> AddEvent(EditEvent_DTO eventDto)
{
try
{
var model = new Events
{
Title = eventDto.Title,
Description = eventDto.Description,
Location = eventDto.Location,
RecordID = eventDto.RecordID,
RecordName = eventDto.RecordName,
RecordTargetType = eventDto.RecordTargetType,
ReminderMinutes = eventDto.ReminderMinutes,
EventColor = eventDto.EventColor,
StartDate = eventDto.StartDate,
StartTime = eventDto.StartTime,
EndDate = eventDto.EndDate,
EndTime = eventDto.EndTime,
AllDay = eventDto.AllDay,
CreatedDate = DateTime.Now,
IsDeleted = false
};
_db.Events.Add(model);
await _db.SaveChangesAsync();
return Ok(new DataResponse { Message = "Event Created Successfully", Status = "200" });
}
catch (Exception ex)
{
return BadRequest(new Response { Status = "Error", Message = ex.Message });
}
}
// ===================================================================================
// UPDATE EVENT
// ===================================================================================
[HttpPost]
[Route("EditEvent")]
public async Task<IActionResult> EditEvent(EditEvent_DTO eventDto)
{
try
{
if (!eventDto.Id.HasValue)
{
return BadRequest(new Response { Status = "Error", Message = "Event ID is required for editing" });
}
var model = await _db.Events.FindAsync(eventDto.Id.Value);
if (model == null || model.IsDeleted == true)
{
return NotFound(new Response { Status = "Error", Message = "Event not found" });
}
model.Title = eventDto.Title;
model.Description = eventDto.Description;
model.Location = eventDto.Location;
model.RecordID = eventDto.RecordID;
model.RecordName = eventDto.RecordName;
model.RecordTargetType = eventDto.RecordTargetType;
model.ReminderMinutes = eventDto.ReminderMinutes;
model.EventColor = eventDto.EventColor;
model.StartDate = eventDto.StartDate;
model.StartTime = eventDto.StartTime;
model.EndDate = eventDto.EndDate;
model.EndTime = eventDto.EndTime;
model.AllDay = eventDto.AllDay;
model.LastModificationTime = DateTime.Now;
_db.Events.Update(model);
await _db.SaveChangesAsync();
return Ok(new DataResponse { Message = "Event Updated Successfully", Status = "200" });
}
catch (Exception ex)
{
return BadRequest(new Response { Status = "Error", Message = ex.Message });
}
}
// ===================================================================================
// GET EVENT BY ID
// ===================================================================================
[HttpGet]
[Route("GetEventById/{id}")]
public async Task<IActionResult> GetEventById(int id)
{
try
{
var model = await _db.Events
.Where(e => e.Id == id && e.IsDeleted != true)
.FirstOrDefaultAsync();
if (model == null)
{
return NotFound(new Response { Status = "Error", Message = "Event not found" });
}
var dto = new Event_DTO
{
Id = model.Id,
Title = model.Title,
Description = model.Description,
Location = model.Location,
RecordID = model.RecordID,
RecordName = model.RecordName,
RecordTargetType = model.RecordTargetType,
ReminderMinutes = model.ReminderMinutes,
EventColor = model.EventColor,
StartDate = model.StartDate,
StartTime = model.StartTime,
EndDate = model.EndDate,
EndTime = model.EndTime,
AllDay = model.AllDay,
CreatedDate = model.CreatedDate,
createdby = model.createdby
};
return Ok(new DataResponse { Message = "Success", Status = "200", Data = dto });
}
catch (Exception ex)
{
return BadRequest(new Response { Status = "Error", Message = ex.Message });
}
}
// ===================================================================================
// DELETE EVENT
// ===================================================================================
[HttpDelete]
[Route("DeleteEvent/{id}")]
public async Task<IActionResult> DeleteEvent(int id)
{
try
{
var model = await _db.Events.FindAsync(id);
if (model == null)
{
return NotFound(new Response { Status = "Error", Message = "Event not found" });
}
// Soft delete
model.IsDeleted = true;
model.DeletionTime = DateTime.Now;
_db.Events.Update(model);
await _db.SaveChangesAsync();
return Ok(new DataResponse { Message = "Event Deleted Successfully", Status = "200" });
}
catch (Exception ex)
{
return BadRequest(new Response { Status = "Error", Message = ex.Message });
}
}
// ===================================================================================
// GET EVENTS LIST (WITH PAGINATION AND FILTERS)
// ===================================================================================
[HttpGet]
[Route("GetEventList")]
public IActionResult GetEventList(string? search = "", int page = 1, int pageSize = 10)
{
try
{
var query = _db.Events.Where(e => e.IsDeleted != true);
// Apply search filter if provided
if (!string.IsNullOrWhiteSpace(search))
{
var searchLower = search.ToLower();
query = query.Where(e =>
e.Title.ToLower().Contains(searchLower) ||
(e.Description != null && e.Description.ToLower().Contains(searchLower)) ||
(e.Location != null && e.Location.ToLower().Contains(searchLower))
);
}
// IMPORTANT: Calculate totalCount AFTER applying filters
var totalCount = query.Count();
// Apply pagination
var events = query
.OrderByDescending(e => e.StartDate)
.ThenByDescending(e => e.CreatedDate)
.Skip((page - 1) * pageSize)
.Take(pageSize)
.Select(e => new Event_DTO
{
Id = e.Id,
Title = e.Title,
Description = e.Description,
Location = e.Location,
RecordID = e.RecordID,
RecordName = e.RecordName,
RecordTargetType = e.RecordTargetType,
ReminderMinutes = e.ReminderMinutes,
EventColor = e.EventColor,
StartDate = e.StartDate,
StartTime = e.StartTime,
EndDate = e.EndDate,
EndTime = e.EndTime,
AllDay = e.AllDay,
CreatedDate = e.CreatedDate,
createdby = e.createdby
})
.ToList();
return Ok(new DataResponse
{
Message = "Success",
Status = "200",
Data = new { rows = events, totalCount }
});
}
catch (Exception ex)
{
return BadRequest(new Response { Status = "Error", Message = ex.Message });
}
}
// ===================================================================================
// GET EVENTS FOR CALENDAR (Date Range Filter)
// ===================================================================================
[HttpGet]
[Route("GetEventsForCalendar")]
public IActionResult GetEventsForCalendar([FromQuery] DateTime? start, [FromQuery] DateTime? end)
{
try
{
var query = _db.Events.Where(e => e.IsDeleted != true);
// Filter by date range if provided
if (start.HasValue && end.HasValue)
{
query = query.Where(e =>
(e.StartDate >= start.Value && e.StartDate <= end.Value) ||
(e.EndDate >= start.Value && e.EndDate <= end.Value) ||
(e.StartDate <= start.Value && e.EndDate >= end.Value)
);
}
var events = query
.Select(e => new Event_DTO
{
Id = e.Id,
Title = e.Title,
Description = e.Description,
Location = e.Location,
RecordID = e.RecordID,
RecordName = e.RecordName,
RecordTargetType = e.RecordTargetType,
ReminderMinutes = e.ReminderMinutes,
EventColor = e.EventColor,
StartDate = e.StartDate,
StartTime = e.StartTime,
EndDate = e.EndDate,
EndTime = e.EndTime,
AllDay = e.AllDay,
CreatedDate = e.CreatedDate,
createdby = e.createdby
})
.ToList();
return Ok(new DataResponse { Message = "Success", Status = "200", Data = events });
}
catch (Exception ex)
{
return BadRequest(new Response { Status = "Error", Message = ex.Message });
}
}
}
}

View file

@ -0,0 +1,48 @@
using System;
using System.ComponentModel.DataAnnotations;
namespace Api.SeaHavenIndustries.DTOs
{
/// <summary>
/// DTO for POST/PUT requests - Create/Edit Event
/// Used to create or update event information
/// </summary>
public class EditEvent_DTO
{
public int? Id { get; set; } // Null for create, populated for edit
[Required(ErrorMessage = "Please enter a title")]
[StringLength(100, ErrorMessage = "Title cannot be longer than 100 characters")]
public string Title { get; set; } = null!;
public string? Description { get; set; }
[StringLength(150, ErrorMessage = "Location cannot be longer than 150 characters")]
public string? Location { get; set; }
public int? RecordID { get; set; }
public string? RecordName { get; set; }
public string? RecordTargetType { get; set; }
[Required(ErrorMessage = "The Remind Attendees field is required")]
public int ReminderMinutes { get; set; } = 15;
[Required(ErrorMessage = "The Select a Calendar Color field is required")]
public string EventColor { get; set; } = "primary";
[Required(ErrorMessage = "Please select a start date")]
public DateTime StartDate { get; set; }
[Required(ErrorMessage = "Please select a start time")]
public string StartTime { get; set; } = null!;
[Required(ErrorMessage = "Please select an end date")]
public DateTime EndDate { get; set; }
[Required(ErrorMessage = "Please select an end time")]
public string EndTime { get; set; } = null!;
public bool AllDay { get; set; } = false;
}
}

View file

@ -0,0 +1,33 @@
using System;
namespace Api.SeaHavenIndustries.DTOs
{
/// <summary>
/// DTO for GET responses - Event data
/// Used to return event information to the client
/// </summary>
public class Event_DTO
{
public int Id { get; set; }
public string Title { get; set; } = null!;
public string? Description { get; set; }
public string? Location { get; set; }
public int? RecordID { get; set; }
public string? RecordName { get; set; }
public string? RecordTargetType { get; set; }
public int ReminderMinutes { get; set; }
public string EventColor { get; set; } = null!;
public DateTime StartDate { get; set; }
public string? StartTime { get; set; }
public DateTime EndDate { get; set; }
public string? EndTime { get; set; }
public bool AllDay { get; set; }
public DateTime? CreatedDate { get; set; }
public string? createdby { get; set; }
}
}

View file

@ -66,6 +66,7 @@ namespace Data.SeaHavenIndustries
public DbSet<FollowUps> FollowUps { get; set; }
public DbSet<PMSchedules> PMSchedules { get; set; }
public DbSet<ContactDetails> ContactDetails { get; set; }
public DbSet<Events> Events { get; set; }
// Employee related DbSets
public DbSet<EmployeeAddress> EmployeeAddresses { get; set; }

View file

@ -9,7 +9,7 @@
<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.Identity.EntityFrameworkCore" Version="8.0.8" />
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="8.0.8" />
<PackageReference Include="Microsoft.EntityFrameworkCore.SqlServer" Version="8.0.4" />
<PackageReference Include="Microsoft.EntityFrameworkCore.SqlServer" Version="8.0.8" />
</ItemGroup>
</Project>

View file

@ -0,0 +1,49 @@
using System;
using System.ComponentModel.DataAnnotations;
namespace Data.SeaHavenIndustries
{
/// <summary>
/// Events entity for Calendar/Events module
/// Tracks calendar events, appointments, meetings, etc.
/// </summary>
public class Events : FullAuditEntity
{
[Required]
[StringLength(100)]
public string Title { get; set; } = null!;
public string? Description { get; set; }
[StringLength(150)]
public string? Location { get; set; }
// Related Record (can link to Accounts, Contacts, Work Orders, etc.)
public int? RecordID { get; set; }
public string? RecordName { get; set; }
public string? RecordTargetType { get; set; }
// Reminder Settings
public int ReminderMinutes { get; set; } = 15; // Default: 15 minutes before
// Color for calendar display
[StringLength(20)]
public string EventColor { get; set; } = "primary"; // primary, success, info, warning, danger
// Schedule
[Required]
public DateTime StartDate { get; set; }
[StringLength(10)]
public string? StartTime { get; set; } // e.g., "08:00 AM"
[Required]
public DateTime EndDate { get; set; }
[StringLength(10)]
public string? EndTime { get; set; } // e.g., "08:30 AM"
public bool AllDay { get; set; } = false;
}
}

View file

@ -13,6 +13,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SeaHaven.Services", "SeaHav
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SeaHaven.DataServices", "SeaHaven.DataServices\SeaHaven.DataServices.csproj", "{60DF3943-9BC3-4444-8E7A-EEC34DDC7806}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Api.SeaHavenIndustries.Tests", "Api.SeaHavenIndustries.Tests\Api.SeaHavenIndustries.Tests.csproj", "{1222E2E5-2FB0-4AA8-842C-0D77CFBA5E45}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
@ -39,6 +41,10 @@ Global
{60DF3943-9BC3-4444-8E7A-EEC34DDC7806}.Debug|Any CPU.Build.0 = Debug|Any CPU
{60DF3943-9BC3-4444-8E7A-EEC34DDC7806}.Release|Any CPU.ActiveCfg = Release|Any CPU
{60DF3943-9BC3-4444-8E7A-EEC34DDC7806}.Release|Any CPU.Build.0 = Release|Any CPU
{1222E2E5-2FB0-4AA8-842C-0D77CFBA5E45}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{1222E2E5-2FB0-4AA8-842C-0D77CFBA5E45}.Debug|Any CPU.Build.0 = Debug|Any CPU
{1222E2E5-2FB0-4AA8-842C-0D77CFBA5E45}.Release|Any CPU.ActiveCfg = Release|Any CPU
{1222E2E5-2FB0-4AA8-842C-0D77CFBA5E45}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE

View file

@ -13,8 +13,8 @@
<PackageReference Include="BlazorGoogleMaps" Version="4.7.7" />
<PackageReference Include="EPPlus" Version="7.3.0" />
<PackageReference Include="Microsoft.AspNetCore.Diagnostics.EntityFrameworkCore" Version="8.0.4" />
<PackageReference Include="Microsoft.EntityFrameworkCore.SqlServer" Version="8.0.4" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Tools" Version="8.0.4" />
<PackageReference Include="Microsoft.EntityFrameworkCore.SqlServer" Version="8.0.8" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Tools" Version="8.0.8" />
<PackageReference Include="PasswordGenerator" Version="2.1.0" />
<PackageReference Include="PuppeteerSharp" Version="19.0.1" />
<PackageReference Include="Radzen.Blazor" Version="5.1.3" />