g | x | w | all
Bytes Lang Time Link
098R240417T223157ZGlory2Uk
235Scala 3240421T023027Z138 Aspe
152Python231114T132321ZOndrej S
168POSIX shell231031T024042Zgildux
126Pure Zsh231031T133828ZGammaFun
083Bash and GNU Date231028T161456ZToby Spe
129JavaScript ES6231027T154035ZArnauld
143C#231101T144215ZAcer
102Ruby231029T132918ZAZTECCO
250C gcc231031T054159ZAShelly
096bash and BSD date231030T223853Zgildux
076Google Sheets / Microsoft Excel231031T110356Zdoubleun
234Go231030T180449Zbigyihsu
055PHP231030T161254ZKaddath
066APLDyalog Unicode231030T125934ZRazetime
118PowerShell Core231030T014909ZJulian
104Vyxal231027T213210Zmath sca
136Charcoal231027T154112ZNeil
129Python231027T221306Zcorvus_1
110Factor + math.text.english231027T214534Zchunes
153Python3231027T150546ZAjax1234

R, 111 109 100 98 bytes

\(a,`?`=paste0)sub("1th","1st",(d=format(sort(as.Date(a?-1:-31),T),"%A %e")?"th")[d<"S"|d>"T"])[1]

Attempt This Online!

Inputs a string in yyyy-mm format, then creates a vector of dates for days 1 to 31 (paste negative numbers, the minus being the separation sign here). Actually, only days 26 to 31 are needed, but that way a byte is spared. The invalid dates are automatically converted to NAs which get lost in the sorting step. Argument T allows to sort in decreasing order, so that the first element will be the last date. d<"S" are Friday and Monday and d>"T" are Thursday, Tuesday and Wednesday.

Scala 3, 235 bytes

235 bytes, it can be golfed more.


Golfed version. Attempt This Online!

(y,m)=>{var d=LocalDate.of(y,m,1).lengthOfMonth;var q=LocalDate.of(y,m,d);while(q.getDayOfWeek==SATURDAY||q.getDayOfWeek==SUNDAY){d-=1;q=LocalDate.of(y,m,d)};val s=if(d<2)"st"else"th";println(s"${q.getDayOfWeek} ${q.getDayOfMonth}$s")}

Ungolfed version. Attempt This Online!

import java.time.{LocalDate, DayOfWeek}

object Main {
  def main(args: Array[String]): Unit = {
    printLastWeekdayOfMonth(2023, 10)
    printLastWeekdayOfMonth(2017, 2)
    printLastWeekdayOfMonth(1674, 9)
    printLastWeekdayOfMonth(2043, 1)
    printLastWeekdayOfMonth(2023, 11)
    printLastWeekdayOfMonth(2023, 11)
  }

  def printLastWeekdayOfMonth(year: Int, month: Int): Unit = {
    var day = LocalDate.of(year, month, 1).lengthOfMonth()  // Start from the last day of the month
    var date = LocalDate.of(year, month, day)

    // Loop backwards to find the last weekday (Monday to Friday)
    while (date.getDayOfWeek == DayOfWeek.SATURDAY || date.getDayOfWeek == DayOfWeek.SUNDAY) {
      day -= 1
      date = LocalDate.of(year, month, day)
    }

    // Print the date in the required format with 'st' or 'th'
    val suffix = if (day == 1) "st" else "th"
    println(s"${date.getDayOfWeek} ${date.getDayOfMonth}$suffix")
  }
}

Python, 152 bytes

Since there is already multiple python answers I've tried to use the calendar library since I never used it before. It just uses the fact that the object monthcalendar gives weeks of the month and days of those weeks as a 2d array ->then just a check for highest number in the first 5 days of last week.

from calendar import *
def f(y,m,d=31,s="st"):
 if d in monthcalendar(y, m)[-1][:5]:
  print(day_name[weekday(y,m,d)],str(d)+s)
 else: f(y,m,d-1,s="th")

Attempt This Online!

POSIX shell, 168 bytes

Not the shortest possible with the shell, but it doesn't use any bashism and is funny (see explanation bellow.)

cal $@|tail -2|awk 'NF>1{split("S Mon Tues Wednes Thurs Fri",d)
a=6;if(NF==1){b=$1-2}else if(NF==7){b=$6}else{a=NF;b=$NF}
print d[a]"day",(b==31)?b"st":b"th"}'|tail -1

With the following limitations:

Explanation

We can use the POSIX cal to visually catch the last weekday.

$ # for February 2017
$ cal 2 2017
   February 2017      
Su Mo Tu We Th Fr Sa  
          1  2  3  4  
 5  6  7  8  9 10 11  
12 13 14 15 16 17 18  
19 20 21 22 23 24 25  
26 27 28              
                      

The last week is on the visually shown last line (but there's an extra blank line), so with POSIX tail (for golfing purpose, tail -n -2 is shorten tail -2)

$ # last week of February 2017
$  cal 2 2017 | tail -n -2
26 27 28              
                      

Finally we should pick the last day… Because the column position vary, awk is a better candidate than cut here (But one can prefer perl or some other scripting langage, but I'm trying to use commands that should be always available), hence the following script:

BEGIN  { # on start, create array of days names 
         split("Su Mo Tu We Th Fr Sa", d)
       }
NF > 1 { # on filled line, print last column name and value
         print d[NF],$NF
      }

If the script is put in file LastDay.awk, our chaining become:

$ # last day of February 2017
$  cal 2 2017 | tail -n -2 | awk -f LastDay.awk
Tu 28              

However, note that the first column is Sunday, and we need to deal with boundaries. The modification is:

BEGIN  { # on start, create array of days names 
         split("S Mo Tu We Th", d)
       }
NF > 1 { # check last column
         if (NF==7) {
           print "Fr",$6 # Saturday: we read the previous field
         } else if (NF==1) {
           print "Fr",($1-2) # Sunday: we count two days less
         } else {
           print d[NF],$NF # Week-Day: pick column name and value
         }
      }

For golfing purpose,

But, in order to comply with the output requirements, we have to add more characters for days.

NF > 1 { split("S Mon Tues Wednes Thurs Fri", d)
         if (NF==7) { # sat
           print d[6] "day",$6
         } else if (NF==1) { # sun
           print d[6] "day",($1-2)
         } else { # non w-e
           print d[NF] "day",$NF
         }
      }

For compliance, we have to deal with ordinals too. To avoid repeating ourself many times, let's opt for assignments and put formatting at end.

NF > 1 { split("S Mon Tues Wednes Thurs Fri", d)
         if (NF==7) { # sat
           a=d[6]
           b=$6
         } else if (NF==1) { # sun
           a=d[6]
           b=($1-2)
         } else { # non w-e
           a=d[NF]
           b=$NF
         }
         print a "day", (b==31)?b"st":b"th"
      }

Addendum

Well, cal 12 2001 shows that there wasn't simply a blank line as I believed, the formatting is for six weeks… Then, before invoking AWK, our selection should be changed so:

- cal $1 $2 |                  tail -n -2
+ cal $1 $2 | grep -v '^ *$' | tail -n -1

Alternatively, the AWK output (Friday 28th and Monday 31st) must be filtered though tail -n -1. Either add 14 characters before (and save NF > 1 condition), or add 10 characters after (and keep 4 characters condition). Fifty-fifty, so it's a matter of taste.

(Pure) Zsh, 126 bytes

zmodload zsh/datetime
s=strftime
for d ({31..9})$s -sT %A\ %dth `$s -r %F $1-$d`&&[[ $T = [^S]*$d\th ]]&&break
<<<${T/1th/1st}

Try it online!

Using the builtin strftime rather than any external programs.

zmodload zsh/datetime
# "strftime" is long enough that setting $s and using $s twice is shorter than "strftime" twice.
s=strftime   

# start at the 31st of the month, count downward
for d ({31..9})
    # Get the timestamp of "YYYY-MM-DD" with strftime -r (reverse/strptime)
    # and find "Nameofday DDth" from timestamp. (-s)ave in $T
    $s -sT %A\ %dth `$s -r %F $1-$d` &&
    # if Nameofday begins with an S or we end up on a different day of month
    # (like 02-31 converted to 03-03), keep going.
    [[ $T = [^S]*$d\th ]] && break

# Fix suffix
<<<${T/1th/1st}

Bash and GNU Date, 84 83 bytes

Thanks to gildux for shaving 1 byte.

s=(1 2)
m=1$1+month
date -d$m-$((s[`date -d$m +%w`]+1))day +%A\ %eth|sed s/1th/1st/

Input format is the month name and year as a single word, e.g. October2023 or sep2026.

An English locale (such as the standard C locale) is required for the day names to be in English.

You might need to unset TZ environment variable to get proleptic Gregorian dates.


How it works

  1. m=1$1+month: Create a date input that is one month later than the start of the target month (ie. the first day of the following month). GNU Date allows us to omit all the spaces, which is helpful.

  2. date -d$m +%w: Obtain the day of week of the first day of the following month.

  3. $((s[…]+1)): We'll need to back up one day, plus an additional day if following month starts on Sunday (weekday 0) or an additional two days if it starts on Monday (weekday 1).

    The array s is incomplete; the arithmetic works because empty string concatenates with +1 to give the result we want.

  4. date -d$m-$((…))day +%A\ %eth: Format that last working day, appending th to give a candidate ordinal.

  5. |sed s/1th/1st/: Rewrite 31th as 31st. The last working day must be in the range 26th...31st, so there are no nds or rds to consider.


Full demo

#!/bin/bash

last-working-day()
{
s=(1 2)
m=1$1+month
date -d$m-$((s[`date -d$m +%w`]+1))day +%A\ %eth|sed s/1th/1st/
}

test "$*" || set -- $(for i in {0..35}; do date -d 1jan00+${i}month +%b%Y; done)
    
for i
do
    printf '%s: %s\n' $i "$(last-working-day $i)"
done

Output (no args):

Jan2000: Monday 31st
Feb2000: Tuesday 29th
Mar2000: Friday 31st
Apr2000: Friday 28th
May2000: Wednesday 31st
Jun2000: Friday 30th
Jul2000: Monday 31st
Aug2000: Thursday 31st
Sep2000: Friday 29th
Oct2000: Tuesday 31st
Nov2000: Thursday 30th
Dec2000: Friday 29th
Jan2001: Wednesday 31st
Feb2001: Wednesday 28th
Mar2001: Friday 30th
Apr2001: Monday 30th
May2001: Thursday 31st
Jun2001: Friday 29th
Jul2001: Tuesday 31st
Aug2001: Friday 31st
Sep2001: Friday 28th
Oct2001: Wednesday 31st
Nov2001: Friday 30th
Dec2001: Monday 31st
Jan2002: Thursday 31st
Feb2002: Thursday 28th
Mar2002: Friday 29th
Apr2002: Tuesday 30th
May2002: Friday 31st
Jun2002: Friday 28th
Jul2002: Wednesday 31st
Aug2002: Friday 30th
Sep2002: Monday 30th
Oct2002: Thursday 31st
Nov2002: Friday 29th
Dec2002: Tuesday 31st

JavaScript (ES6), 129 bytes

-1 thanks to @l4m2
-2 thanks to @Shaggy

Expects ([year, month]), as integers.

f=(D,d=31,s="st")=>(q=new Date([...D,d])).getDate()>9&&q.getDay()%6?q.toLocaleString("EN",{weekday:"long"})+' '+d+s:f(D,d-1,"th")

Try it online!

Or 126 bytes if we assume a default English locale.

Commented

f = (                 // recursive function taking:
  D,                  //   D[] = [year, month]
  d = 31,             //   d = day, starting at 31
  s = "st"            //   s = suffix, initialized to "st"
) =>                  //
( q = new Date([      // build the date q for:
    ...D,             //   the year and month stored in D[]
    d                 //   the day d
  ])                  //
).getDate() > 9       // if the resulting day is greater than 9
&&                    // (i.e. we didn't overflow to the next month)
q.getDay()            // and the 0-based day-of-week index
% 6 ?                 // is neither 0 or 6 (Sunday / Saturday):
  q.toLocaleString(   //   return the name of the day
    "EN", {           //   obtained with toLocaleString()
      weekday: "long" //   set up to English
    }                 //
  ) +                 //
  ' ' +               //   followed by a space
  d +                 //   followed by the day
  s                   //   followed by the suffix
:                     // else:
  f(D, d - 1, "th")   //   recursive call with d - 1 and s = "th"

C#, 143 bytes

(y,m)=>new[]{DateTime.DaysInMonth(y,m)is{}z?z:z,z-1,z-2}.Select(x=>new DateTime(y,m,x).ToString("dddd dd")+(x>30?"st":"th")).First(x=>x[0]!=83)

Explanation

f = (y, m) =>                       // lambda that takes year and month
  new[] {                           // create new implicitly typed array
      DateTime.DaysInMonth(y, m)    // get number of days in month
          is {} z ? z : z,          // hack to set variable z to this value inline
      z-1, z-2                      // add the 2 days previous to z
  }                                 // array contains the last 3 days of the given month in descending order
  .Select(                          // transform the elements of the array
      x => new DateTime(y, m, x)    // create new datetime with the given year and month
      .ToString("dddd dd")          // convert to string and format
      + (x > 30 ? "st" : "th"))     // add "st" if is the 31st, "th" otherwise
  .First(                           // get first element that matches condition
      x => x[0] != 83)              // first character is not "S" (i.e not day isn't saturday or sunday)

Try it online!

Ruby, 99 89 102 bytes

->y,m{(1..4).find{|d|(A=Date.new y,m,-d).wday<5}
A.strftime"%A %d#{A.day>30?'st':'th'}"}
require'time'

Try it online!

Explanation

Anonymous function tacking year and month as numbers.

We find which of the last 4 days of month ( checking backwards using -d ) are wdays (week days 0-indexed) less than 5 saving each time date object in constant A for later use.

When it's found loop is stopped so A is the last weekday of the month.

Then we format the output, we can only have 31st or XXth.

C (gcc), 270 250 bytes

char*s[]={"Mon","Tues","Wednes","Thurs","Fri","sth"};l(y){return y/4-y/100+y/400;}
f(m,y){int d,p=l(y);y-=d=m<3;d=6+y+p+(31*(m+=12*d-2))/12;
d+=y=31-(12652612>>(m*2-2)&3)+(m>11&p-l(y));d%=7;y-=p=d&d/4*3;
printf("%sday %d%.2s\n",s[d-p],y,s[5]+(y<31));}

Try it online!

Doing it the hard way - a bunch of mod 7 math to count extra days since the first Monday of Year 0.

p=l(y) returns the number of leap years since 0.
d=m<3 is true for Feb and March
y-=d...m+=12*d-2 sets m to the month, where Mar is 1 and Jan and Feb are 11 and 12 of the prior year.
d=6+y+p+31*m/12 sets d to the number of extra days between the first Monday in year 0 and the last day of the prior month. (the 31*m/12 gives the correct pattern of +3s and +2s)
(m>11&p-l(y) is 1 if it's February of a leap year
12652612 is "31-days" for each month encoded in 2 bits, so
y=31-(12652612>>(m*2-2)&3)+... sets y to the last day in the query month.
d+=y...;d%=7 gets the day of the week (0==Mon) for the query.
p=d&d/4*3 sets p to the days needed to truncates Sat/Sun to Fri (4)
y-=p fixes the last day of the month
s[d-p] is the day string,
2 characters from s[5]+(y<31) is "st" or "th"

bash and BSD date, 96 bytes

As an alternative to Toby Speight's answer but using BSD implementation instead of GNU one

s=(1 2)
m="-v$2y -v$1 -v+1m -v1d"
date $m -v-$((s[`date $m +%w`]+1))d +%A\ %eth|sed s/1th/1st/

It has the following limitations:

There's certainly room for tweaking.

Google Sheets / Microsoft Excel, 76 bytes

=let(d,workday(eomonth(A1,)+1,-1),text(d,"dddd d")&if(day(d)=31,"st","th"))

Put the month and year in cell A1 in one of the formats specified in the question (or any other valid date format), and the formula in B1.

The formula gets the first day of the next month and then finds the previous weekday before that, which is the same as the last weekday of the specified month.

The day number of the last weekday of a month will never end in 2 or 3, so it is enough to add st when the day is 31 and use th with any other date.

The formula is for Google Sheets, but I tested it in the most recent version of Microsoft Excel for the web and it seems to work the fine there as well.

The Google Sheets epoch is 30 December 1899. The formula will work with dates from January 1900 to December 99999. In Microsoft Excel, the range of valid dates seems more limited, and formula will work from January 1900 to November 9999.

input expected formula
10/2023 Tuesday 31st Tuesday 31st
February 2017 Tuesday 28th Tuesday 28th
09/1674 Friday 28th #NUM!
1/2043 Friday 30th Friday 30th

Go, 234 bytes

import(."fmt";."time")
func f(m,y int)string{M,e:=Month(m),"th"
t,W:=Date(y,M,31,0,0,0,0,UTC),(Time).Weekday
for w:=W(t);t.Month()!=M||w<1||w>5;{t=t.Add(Hour*-24);w=W(t)}
if t.Day()>30{e="st"}
return Sprintf("%s %d%s",W(t),t.Day(),e)}

Attempt This Online!

Explanation

import(."fmt";."time")
func f(m,y int)string{M,e:=Month(m),"th"
// start the date off at the 31st of the month, regardless of the month
t,W:=Date(y,M,31,0,0,0,0,UTC),(Time).Weekday
// subtract 1 day while...
for w:=W(t);
// the months don't match, or
t.Month()!=M
// the day is not a weekday
||w<1||w>5;{t=t.Add(Hour*-24);w=W(t)}
// replace "31th" with "31st"
if t.Day()>30{e="st"}
return Sprintf("%s %d%s",W(t),t.Day(),e)}

PHP, -F 55 bytes

<?=date('l jS',strtotime("$argn+1month last weekday"));

Try it online!

Input is in "yyyy-mm" format in my example, but strtotime supports many formats

It's so rare that PHP is competitive! I knew one day would be the glory of this pile of unconsistent mess, that I came to like golfing with ;) I needed to add one month because using strtotime without a day number returns the first day, and last is actually a relative term (opposite of next).

Probably still golfable, still searching for edge cases where it could not work (relative month +1 month can be broken I think, -1 month is for sure)

NOTE: my first version was 4 bytes shorter ++$argn." last weekday", I liked it because it uses PHP's string increment, but even if it works with the tests cases, it will fail for December -> "2023-12" would give "2023-13" and strtotime is not really clever

APL(Dyalog Unicode), 66 bytes SBCS

{⊃'Dddd DDoo'(1200⌶)⊃⌽w/⍨0≠6|7|w←∊{0::⍬⋄,¯1 1 ⎕DT⊂⍵}¨(⊂⌽⍵),¨25+⍳6}

Try it on APLgolf!

woo-hoo. Shaggy waiting room. Takes date as m y.

PowerShell Core, 118 bytes

param($y,$m)for(;!((($k=Date "$y/$m/1"|% *hs 1|% *ys(--$d))|% d*k)%6)){}"{0:dddd} {0:dd}"-f$k+('st','th')[$k.Day-ne31]

Try it online!

Takes in two parameters, $year and $month, returns a string.

Less golfed:

param($y,$m)
for(;(($k=(Date "$y/$m/1").AddMonths(1).AddDays(--$d)).DayOfWeek-in"Saturday","Sunday")){}
"{0:dddd} {0:dd}"-f$k+('st','th')[$k.Day-ne31]

Finds the last day of the month, while it is a Saturday or a Sunday, check one day before Then formats the date

Vyxal, 104 bytes

S½⌊Ḃ÷dN„4/?›13*5/⁰14=[¹›₁1"4*₁JḊ÷¬∧∨28:›"i|30⁰⇧5%₂+]:£Wf⌊∑7%:¥∇2<[›-6]⇩`°⋏ ß₀ †ƛ ßɖ »£`⌈ið„:31=«⟇'Ḋ«½iW∑

Try it Online! | All test cases

Takes the year and the month (one-based) seperately. January and February are considered the 13th and 14th months of the previous year.
Vyxal has no Date/Time built-ins, so this uses a variation of Zeller's Congruence.

S½⌊Ḃ÷dN„4/?›13*5/⁰14=[¹›₁1"4*₁JḊ÷¬∧∨28:›"i|30⁰⇧5%₂+]:£Wf⌊∑7%:¥∇2<[›-6]⇩`°⋏ ß₀ †ƛ ßɖ »£`⌈ið„:31=«⟇'Ḋ«½iW∑


S½⌊Ḃ÷dN„4/?›13*5/ ­⁡​‎‎  ## First Part of Zeller's Congruence Formula⁡⁠⁡‏⁠‎⁡⁠⁢‏⁠‎⁡⁠⁣‏‏​⁡⁠⁡‌⁢​‎‎⁡⁠⁤‏‏​⁡⁠⁡‌⁣​‎‎⁡⁠⁢⁡‏⁠‎⁡⁠⁢⁢‏⁠‎⁡⁠⁢⁣‏‏​⁡⁠⁡‌⁤​‎‎⁡⁠⁢⁤‏‏​⁡⁠⁡‌⁢⁡​‎‎⁡⁠⁣⁡‏⁠‎⁡⁠⁣⁢‏‏​⁡⁠⁡‌⁢⁢​‎‎⁡⁠⁣⁣‏⁠‎⁡⁠⁣⁤‏⁠‎⁡⁠⁤⁡‏⁠‎⁡⁠⁤⁢‏⁠‎⁡⁠⁤⁣‏⁠‎⁡⁠⁤⁤‏⁠‎⁡⁠⁢⁡⁡‏‏​⁡⁠⁡‌­
S½⌊                 # ‎⁡Split year into two halves [century, year of the century]
   Ḃ                # ‎⁢Bifurcate, push this and its reverse
    ÷dN             # ‎⁣Push each item to stack, multiply the year of the century by -2
       „            # ‎⁤Rotate stack ([century, year of the century] is on top)
        4/          # ‎⁢⁡Divide both by 4
          ?›13*5/   # ‎⁢⁢Multiply month + 1 with 13/5


⁰14=[¹›₁1"4*₁JḊ÷¬∧∨28:›"i|30⁰⇧5%₂+]­⁡​‎‎  ## Calculate number of days in month⁡⁠⁡‏⁠‎⁡⁠⁢‏⁠‎⁡⁠⁣‏⁠‎⁡⁠⁤‏⁠‎⁡⁠⁢⁡‏⁠‎⁡⁠⁣⁡⁣‏‏​⁡⁠⁡‌⁢​‎‎⁡⁠⁢⁡‏⁠‎⁡⁠⁢⁣⁢‏‏​⁡⁠⁡‌⁣​‎‎⁡⁠⁢⁢‏⁠‎⁡⁠⁢⁣‏⁠‎⁡⁠⁤⁣‏‏​⁡⁠⁡‌⁤​‎‎⁡⁠⁢⁤‏⁠‎⁡⁠⁣⁡‏⁠‎⁡⁠⁣⁢‏⁠‎⁡⁠⁣⁣‏⁠‎⁡⁠⁣⁤‏⁠‎⁡⁠⁤⁡‏⁠‎⁡⁠⁤⁢‏‏​⁡⁠⁡‌⁢⁡​‎‎⁡⁠⁤⁤‏⁠‎⁡⁠⁢⁡⁡‏⁠‎⁡⁠⁢⁡⁢‏⁠‎⁡⁠⁢⁡⁣‏‏​⁡⁠⁡‌⁢⁢​‎‎⁡⁠⁢⁡⁡‏⁠‎⁡⁠⁢⁡⁢‏⁠‎⁡⁠⁢⁡⁣‏‏​⁡⁠⁡‌⁢⁣​‎‎⁡⁠⁢⁡⁤‏⁠‎⁡⁠⁢⁢⁡‏⁠‎⁡⁠⁢⁢⁢‏⁠‎⁡⁠⁢⁢⁣‏⁠‎⁡⁠⁢⁢⁤‏⁠‎⁡⁠⁢⁣⁡‏‏​⁡⁠⁡‌⁢⁤​‎‎⁡⁠⁢⁣⁢‏⁠‎⁡⁠⁣⁡⁣‏‏​⁡⁠⁡‌⁣⁡​‎⁠⁠‎⁡⁠⁢⁤⁡‏⁠‎⁡⁠⁢⁤⁢‏⁠‎⁡⁠⁢⁤⁣‏⁠‎⁡⁠⁢⁤⁤‏⁠‎⁡⁠⁣⁡⁡‏‏​⁡⁠⁡‌⁣⁢​‎‎⁡⁠⁢⁤⁡‏⁠‎⁡⁠⁢⁤⁢‏⁠‎⁡⁠⁢⁤⁣‏⁠‎⁡⁠⁢⁤⁤‏⁠‎⁡⁠⁣⁡⁡‏‏​⁡⁠⁡‌⁣⁣​‎‎⁡⁠⁢⁣⁣‏⁠‎⁡⁠⁢⁣⁤‏⁠‎⁡⁠⁣⁡⁢‏⁠‏​⁡⁠⁡‌­
⁰14=[                             ]  # ‎⁡‎⁡If month is 14 (February) ...
    [                    |             # ‎⁢‎⁢Leap year calculation:
     ¹›       Ḋ                        # ‎⁣Does year + 1 divide ...
       ₁1"4*₁J                         # ‎⁤... [400, 4, 100] (Pair [100, 1] * 4 and join 100)
               ÷¬∧∨                    # ‎⁢⁡‎⁢⁡Split on stack, logical not, and, or
                ¬∧∨                    # ‎⁢⁢[ (year + 1 divides 4 and not 100) or (year + 1 divides 400) ]
                   28:›"i              # ‎⁢⁣Index into [28, 29]
                         |        ]  # ‎⁢⁤‎⁢⁤Calculate days for other months:
                            ⁰⇧5%₂      # ‎⁣⁡((Year+2) % 5) % 2 == 0
                            ⁰⇧5%₂      # ‎⁣⁢Maps [1, 0, 1, 0, 1, 1, 0, 1, 0, 1, 1] for months 3 to 13 (March=3, ..., January=13)
                          30     +     # ‎⁣⁣Add result to 30

:£Wf⌊∑7%­⁡​‎‎  ## Second Part of Zeller's Congruence Formula⁡⁠⁡‏⁠‎⁡⁠⁢‏‏​⁡⁠⁡‌⁢​‎‎⁡⁠⁣‏⁠‎⁡⁠⁤‏⁠‎⁡⁠⁢⁡‏⁠‎⁡⁠⁢⁢‏‏​⁡⁠⁡‌⁣​‎‎⁡⁠⁢⁣‏⁠‎⁡⁠⁢⁤‏‏​⁡⁠⁡‌­
:£        # ‎⁡Save a copy of number of days in month in register
  Wf⌊∑    # ‎⁢Wrap terms in list, take floor and sum all terms
      7%  # ‎⁣Result modulo 7 (Returns weekday as Saturday=0, Sunday=1, ...)

:¥∇2<[›-6]⇩`°⋏ ß₀ †ƛ ßɖ »£`⌈i­⁡​‎‎  ## Calculate workday (as day of the week and month)⁡⁠⁡‏⁠‎⁡⁠⁢‏⁠‎⁡⁠⁣‏‏​⁡⁠⁡‌⁢​‎‎⁡⁠⁤‏⁠‎⁡⁠⁢⁡‏⁠‎⁡⁠⁢⁢‏⁠‎⁡⁠⁣⁢‏‏​⁡⁠⁡‌⁣​‎‎⁡⁠⁢⁣‏⁠‎⁡⁠⁢⁤‏⁠‎⁡⁠⁣⁡‏‏​⁡⁠⁡‌⁤​‎⁠‎⁡⁠⁣⁤‏⁠‎⁡⁠⁤⁡‏⁠‎⁡⁠⁤⁢‏⁠‎⁡⁠⁤⁣‏⁠‎⁡⁠⁤⁤‏⁠‎⁡⁠⁢⁡⁡‏⁠‎⁡⁠⁢⁡⁢‏⁠‎⁡⁠⁢⁡⁣‏⁠‎⁡⁠⁢⁡⁤‏⁠‎⁡⁠⁢⁢⁡‏⁠‎⁡⁠⁢⁢⁢‏⁠‎⁡⁠⁢⁢⁣‏⁠‎⁡⁠⁢⁢⁤‏⁠‎⁡⁠⁢⁣⁡‏⁠‎⁡⁠⁢⁣⁢‏⁠‎⁡⁠⁢⁣⁣‏‏​⁡⁠⁡‌⁢⁡​‎‎⁡⁠⁣⁣‏⁠‎⁡⁠⁢⁣⁤‏⁠‎⁡⁠⁢⁤⁡‏‏​⁡⁠⁡‌­
:¥∇                            # ‎⁡Push the number of days in the month (register), weekday, weekday
   2<[   ]                     # ‎⁢If weekday < 2 (Saturday or Sunday)
      ›-6                      # ‎⁣Subtract weekday+1 from number of days in month (last workday), Push 6 (Friday)
           `°⋏ ß₀ †ƛ ßɖ »£`    # ‎⁤Compressed string of workdays
          ⇩                ⌈i  # ‎⁢⁡Index workday-2 into list (Monday=0, Tuesday=1, ...)

ð„:31=«⟇'Ḋ«½i­⁡​‎‎  ## Day of the Month formatting⁡⁠⁡‏⁠‎⁡⁠⁢‏‏​⁡⁠⁡‌⁢​‎‎⁡⁠⁣‏⁠‎⁡⁠⁤‏⁠‎⁡⁠⁢⁡‏⁠‎⁡⁠⁢⁢‏‏​⁡⁠⁡‌⁣​‎‎⁡⁠⁢⁣‏⁠‎⁡⁠⁢⁤‏⁠‎⁡⁠⁣⁡‏⁠‎⁡⁠⁣⁢‏⁠‎⁡⁠⁣⁣‏⁠‎⁡⁠⁣⁤‏⁠‎⁡⁠⁤⁡‏‏​⁡⁠⁡‌⁤​‎⁠‎⁡⁠⁤⁢‏⁠‎⁡⁠⁤⁣‏‏​⁡⁠⁡‌­
ð„             # ‎⁡Push space and rotate stack
  :31=         # ‎⁢Is the day of the month 31? (Returns 1 or 0)
      «⟇'Ḋ«½i  # ‎⁣Compressed string "thst", halve and index into string

W∑  # ‎⁤Join all values in the stack (workday as day of the week, space, workday as day of the month)

Charcoal, 137 136 bytes

≔I⪪S/θ≔⁻⁺×¹²⊟θ⊟θ²θ≔EE²⁻θι﹪Σ⟦÷ι⁴⁸⁰⁰±÷ι¹²⁰⁰÷ι⁴⁸÷ι¹²÷×¹³⁺⁴﹪ι¹²¦⁵⟧⁷θ≔⊟θη≔⊟θθ≔⁻⁺²⁸﹪⁻θη⁷∨¬θ⊗¬⊖θθ§⪪”⟲1U↘ዬ@²¦À≧≡FGv_E✂₂↘” ⁺θηday Iθ§⪪stth²‹θ³¹

Try it online! Link is to verbose version of code. Explanation: Uses my code for Zeller's congruence from my answer to ASCII Calendar Planner.

≔I⪪S/θ

Split the date on /.

≔⁻⁺×¹²⊟θ⊟θ²θ

Convert to months since March 1, 1 BC.

≔EE²⁻θι﹪Σ⟦÷ι⁴⁸⁰⁰±÷ι¹²⁰⁰÷ι⁴⁸÷ι¹²÷×¹³⁺⁴﹪ι¹²¦⁵⟧⁷θ

Use a modified Zeller's congruence to extract the day of the week of the first day of next month and this month as a list.

≔⊟θη≔⊟θθ

Extract the list entries into variables to save bytes.

≔⁻⁺²⁸﹪⁻θη⁷∨¬θ⊗¬⊖θθ

Calculate the last working day of the month.

§⪪”...” ⁺θηday 

Output the day of the week.

Iθ

Output the day of the month.

§⪪stth²‹θ³¹

Output the ordinal suffix.

Python, 129 bytes

from datetime import*
def f(y,m,d=31,s="st"):
 try:q=date(y,m,d);1/(q.weekday()<5);print(f'{q:%A %d}{s}')
 except:f(y,m,d-1,"th")

Attempt This Online!

Factor + math.text.english, 110 bytes

[ last-day-of-month dup weekend? [ friday< ] when
dup day-name swap day>> dup ordinal-suffix [I ${} ${}${}I] ]

Attempt This Online!

Takes a timestamp (date object) as input.

Python3, 153 bytes

from datetime import*
T=timedelta(1)
def f(m,y):
 d=date(y+(m>11),m%12+1,1)-T
 while d.weekday()>4:d-=T
 return d.strftime('%A %d')+['th','st'][d.day>30] 

Try it online!